← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/lazr-js-kicking-and-screaming into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/lazr-js-kicking-and-screaming into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/lazr-js-kicking-and-screaming/+merge/66438

This mp moves lazr-js Javascript into Launchpad's tree and deletes the lazr-js egg from the Launchpad build dependencies. It also packages YUI as a separate dependency.

== Implementation ==

-YUI Repacking-

Make a yui tarball (eg yui-3.3.tar.gz) and check into download-cache (done already).
Add a section to buildout.cfg which unpacks the tarball to download-cache/../yui-3.3 (or yui-3.4 etc)
The lp Makefile was changed to pull the yui files from this new location instead of from within the lazr-js tree

-Lazr-js Repacking-

The lazr-js Javascript was copied across to lib/lp/app/javascrip/lazr
The lp Makefile was changed to pull the js files to make lazr.js from this new location
The lazr js build tools (build.py, jsmin.py etc) were copied across to lib/lp/scripts/js and the various buildout/make artifacts updated accordingly.

-Dependencies update-

versions.cfg was updated to remove lazr-js and to add yui 3.3

-Next Steps-

The next branch(es) will:
- remove unused lazr-js code
- move lazr-js javascript up from lazrjs directory to become "first class" lp javascript
- separate lazr.js file will not be generated as part of build process
- lazr-js and lp components consolidated (eg picker)

== Tests ==

A build was done before and after the changes and launchpad.js (which is the output artifact of the js build process) was identical in both cases.
YUI tests pass.
Launchpad runs as expected with no new js console errors/warnings.





-- 
The attached diff has been truncated due to its size.
https://code.launchpad.net/~wallyworld/launchpad/lazr-js-kicking-and-screaming/+merge/66438
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/lazr-js-kicking-and-screaming into lp:launchpad.
=== modified file 'Makefile'
--- Makefile	2011-06-27 15:29:59 +0000
+++ Makefile	2011-06-30 12:01:55 +0000
@@ -29,11 +29,11 @@
 JS_YUI := $(shell utilities/yui-deps.py $(JS_BUILD:raw=))
 JS_LAZR := $(LAZR_BUILT_JS_ROOT)/lazr.js
 JS_OTHER := $(wildcard lib/canonical/launchpad/javascript/*/*.js)
-JS_LP := $(shell find lib/lp/*/javascript ! -path '*/tests/*' -name '*.js' ! -name '.*.js' )
+JS_LP := $(shell find lib/lp/*/javascript ! -path '*/tests/*' ! -path '*/app/javascript/lazr/*' -name '*.js' ! -name '.*.js' )
 JS_ALL := $(JS_YUI) $(JS_LAZR) $(JS_OTHER) $(JS_LP)
 JS_OUT := $(LP_BUILT_JS_ROOT)/launchpad.js
 
-MINS_TO_SHUTDOWN=15
+INS_TO_SHUTDOWN=15
 
 CODEHOSTING_ROOT=/var/tmp/bazaar.launchpad.dev
 
@@ -151,6 +151,18 @@
 
 build: compile apidoc jsbuild css_combine sprite_image
 
+# LP_SOURCEDEPS_PATH should point to the sourcecode directory, but we
+# want the parent directory where the download-cache and eggs directory
+# are. We re-use the variable that is using for the rocketfuel-get script.
+download-cache:
+ifdef LP_SOURCEDEPS_PATH
+	utilities/link-external-sourcecode $(LP_SOURCEDEPS_PATH)/..
+else
+	@echo "Missing ./download-cache."
+	@echo "Developers: please run utilities/link-external-sourcecode."
+	@exit 1
+endif
+
 css_combine: sprite_css bin/combine-css
 	${SHHH} bin/combine-css
 
@@ -170,13 +182,12 @@
 # its jsTestDriver test harness modifications in the lazr.js and
 # launchpad.js roll-up files.  They fiddle with built-in functions!
 # See Bug 482340.
-jsbuild_lazr: bin/jsbuild
+jsbuild_minify: bin/jsbuild
 	${SHHH} bin/jsbuild \
 	    --builddir $(LAZR_BUILT_JS_ROOT) \
-	    --exclude testing/ --filetype $(JS_BUILD) \
-	    --copy-yui-to $(LAZR_BUILT_JS_ROOT)/yui
+	    --exclude testing/ --filetype $(JS_BUILD)
 
-$(JS_YUI) $(JS_LAZR): jsbuild_lazr
+$(JS_YUI) $(JS_LAZR): jsbuild_minify
 
 $(JS_OUT): $(JS_ALL)
 ifeq ($(JS_BUILD), min)
@@ -185,25 +196,13 @@
 	cat $^ > $@
 endif
 
-jsbuild: $(JS_OUT)
+jsbuild: $(PY) $(JS_OUT)
 
 eggs:
 	# Usually this is linked via link-external-sourcecode, but in
 	# deployment we create this ourselves.
 	mkdir eggs
 
-# LP_SOURCEDEPS_PATH should point to the sourcecode directory, but we
-# want the parent directory where the download-cache and eggs directory
-# are. We re-use the variable that is using for the rocketfuel-get script.
-download-cache:
-ifdef LP_SOURCEDEPS_PATH
-	utilities/link-external-sourcecode $(LP_SOURCEDEPS_PATH)/..
-else
-	@echo "Missing ./download-cache."
-	@echo "Developers: please run utilities/link-external-sourcecode."
-	@exit 1
-endif
-
 buildonce_eggs: $(PY)
 	find eggs -name '*.pyc' -exec rm {} \;
 
@@ -483,6 +482,6 @@
 	test_build test_inplace pagetests check schema default \
 	launchpad.pot pull_branches scan_branches sync_branches	\
 	reload-apache hosted_branches check_mailman check_config \
-	jsbuild jsbuild_lazr clean_js clean_buildout buildonce_eggs \
+	jsbuild jsbuild_minify clean_js clean_buildout buildonce_eggs \
 	build_eggs sprite_css sprite_image css_combine compile \
 	check_schema pydoctor clean_logs 

=== modified file 'buildout-templates/bin/combine-css.in'
--- buildout-templates/bin/combine-css.in	2011-03-24 15:31:53 +0000
+++ buildout-templates/bin/combine-css.in	2011-06-30 12:01:55 +0000
@@ -8,8 +8,8 @@
 
 import os
 
-from lazr.js.build import ComboFile
-from lazr.js.combo import combine_files
+from lp.scripts.utilities.js.jsbuild import ComboFile
+from lp.scripts.utilities.js.combo import combine_files
 
 # This constant helps us meet maximum line-length goals.
 GALLERY_ACCORDION = 'yui3-gallery/gallery-accordion/assets/'

=== modified file 'buildout.cfg'
--- buildout.cfg	2011-06-27 15:29:59 +0000
+++ buildout.cfg	2011-06-30 12:01:55 +0000
@@ -3,6 +3,7 @@
 
 [buildout]
 parts =
+    yui
     scripts
     filetemplates
     tags
@@ -28,11 +29,20 @@
 
 prefer-final = true
 
-develop = .
+develop = . 
 
 [configuration]
 instance_name = development
 
+[yui]
+recipe = plone.recipe.command
+command =
+    mkdir -p download-cache/../yui-${versions:yui} && \
+    tar -zxf download-cache/dist/yui-${versions:yui}.tar.gz \
+        -C download-cache/../yui-${versions:yui} && \
+    mkdir lazr-js/build && \
+    ln -s ../../download-cache/../yui-${versions:yui}/yui lazr-js/build/yui
+
 [filetemplates]
 recipe = z3c.recipe.filetemplate
 source-directory = buildout-templates
@@ -55,10 +65,7 @@
     main('${configuration:instance_name}') # Initializes LP environment.
 entry-points = stxdocs=zope.configuration.stxdocs:main
     googletestservice=lp.services.googlesearch.googletestservice:main
-    jsbuild=lazr.js.build:main
-    jslint=lazr.js.jslint:main
     tracereport=zc.zservertracelog.tracereport:main
-    jssize=lp.scripts.utilities.jssize:main
 
 [iharness]
 recipe = z3c.recipe.scripts

=== added directory 'lib/lp/app/javascript/lazr'
=== added file 'lib/lp/app/javascript/lazr/__init__.py'
=== added directory 'lib/lp/app/javascript/lazr/actions'
=== added file 'lib/lp/app/javascript/lazr/actions/actions.js'
--- lib/lp/app/javascript/lazr/actions/actions.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/actions/actions.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,326 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.actions', function(Y) {
+
+Y.namespace('lazr.actions');
+
+var ACTION = "action",
+    ACTIONCLASS = "Action",
+    ACTIONS = "actions",
+    ACTIONS_HELPER = "ActionsHelper",
+    ACTIONS_ID = "actionsId",
+    ITEM = "item",
+    ITEMCLASSNAME = "itemClassName",
+    LABEL = "label",
+    LAZR_ACTION_DISABLED = 'lazr-action-disabled',
+    LINK = "link",
+    LINKCLASSNAME = "linkClassName",
+    PERMISSION = "permission",
+    RUNNING = "running",
+    TITLE = "title";
+
+/*
+ * The Actions and ActionsHelper widgets allow for creating arbitrary collections of behavioral
+ * links and situating them in DOM elements on the page. In the absence of a label attribute,
+ * they can be presented with CSS sprites for graphical representation. When actions are running,
+ * they have their primary linkClassName CSS class replaced with lazr-waiting, which can be
+ * styled as needed (spinner, hidden, greyed-out, &c). Each action can be governed by a
+ * permission, which will fire at the time of rendering and, if failing, decorate the action with
+ * the lazr-action-disabled class, which can be styled as needed (hidden, greyed-out, &c).
+*/
+
+/*
+ * The ActionsHelper widget collects and delegates Action
+ * widgets associated with a common Node
+ *
+ * @class ActionsHelper
+ */
+var ActionsHelper = function(config) {
+    ActionsHelper.superclass.constructor.apply(this, arguments);
+};
+
+ActionsHelper.NAME = ACTIONS_HELPER;
+
+ActionsHelper.ATTRS = {
+    actions: { valueFn: function() { return []; }},
+    actionsId: { valueFn: function() { return Y.guid(); }},
+};
+
+Y.extend(ActionsHelper, Y.Base, {
+    /**
+     * Render actions
+     * <p>
+     * This method is called to render each of its Actions in turn, in the specified node.
+     * </p>
+     *
+     * @method render
+     * @param node {Node} The node that should contain the ActionsHelper
+     */
+    render: function(node) {
+        var doc = Y.config.doc;
+        var actions = this.get(ACTIONS);
+        var actionsId = this.get(ACTIONS_ID);
+
+        // Check if we already have an instance of the
+        // container in the DOM.
+        var actionsContainer = Y.one("#" + actionsId);
+
+        if (actionsContainer) {
+            // If the container already exists in the DOM,
+            // unattach it so that it can be moved to a
+            // new parent.
+            actionsContainer.remove()
+        } else {
+            actionsContainer = new Y.Node.create(
+                "<ul id='" + actionsId + "' />");
+        }
+        // If there are no icons to be displayed, don't bother
+        // creating the container.
+        if (actions.length) {
+            // Render each action as a separate item inside
+            // the container.
+            for (var i=0; i<actions.length; i++){
+                var action = actions[i];
+                action.render(actionsContainer);
+            }
+        }
+
+        // Finally, if a container was created or an existing
+        // one was found, append it to the Document Fragment
+        // for later attachment to the new destination node.
+        if (actionsContainer !== null) {
+            node.appendChild(Y.Node.getDOMNode(actionsContainer));
+        }
+    },
+});
+
+Y.lazr.actions.ActionsHelper = ActionsHelper;
+
+/**
+ * This class provides a self-protecting action, governed by permissions,  attached
+ * to a link that can have its style updated when running.
+ *
+ * @class Action
+ * @constructor
+ */
+var Action = function(config) {
+    Action.superclass.constructor.apply(this, arguments);
+};
+
+/**
+ * Dictionary of selectors to define subparts of the widget that we care about.
+ * YUI calls ATTRS.set(foo) for each foo defined here
+ *
+ * @property Action.NAME
+ * @type String
+ * @static
+ */
+Action.NAME = ACTIONCLASS;
+
+Action.ATTRS = {
+    /**
+     * A function representing the underlying behavior of this action.
+     *
+     * @attribute action
+     * @type Function
+     */
+    action: {
+        value: null
+    },
+
+    /**
+     * A function which runs at render time, evaluating to true or fase, determining
+     * whether or not the action should be disabled.
+     *
+     * @attribute permission
+     * @type Function
+     */
+    permission: {
+        value: null
+    },
+
+    /**
+     * Optional text label for the Action. If present, it will be the text of the
+     * anchor tag
+     *
+     * @attribute label
+     * @type String
+     */
+    label: {
+        value: null
+    },
+
+    /**
+     * Title attribute of the inner anchor tag
+     *
+     * @attribute title
+     * @type String
+     */
+    title: {
+        value: null
+    },
+
+    /**
+     * A special CSS class name for the list element of the Action
+     *
+     * @attribute itemClassName
+     * @type String
+     */
+    itemClassName: {
+        value: null
+    },
+
+    /**
+     * A special CSS class name for the inner anchor element of the Action, will get
+     * swapped out with Y.lazr.ui.CSS_WAITING when the action is running.
+     *
+     * @attribute linkClassName
+     * @type String
+     */
+    linkClassName: {
+        value: null
+    },
+
+    /**
+     * A flag determining whether the current Action is engaged in its action
+     *
+     * @attribute running
+     * @type Boolean
+     */
+    running: {
+        value: false,
+        setter: function(v) { return this._updateRunState(v); },
+        getter: function(v) { return v; }
+    },
+
+    /**
+     * A list element, decorated with our CSS class and with our link attached.
+     *
+     * @attribute item
+     * @type Node
+     */
+    item: {
+        valueFn: function() { return this._createItem(); }
+    },
+
+    /**
+     * An anchor element, decorated with our CSS class and with our behavior attached.
+     *
+     * @attribute link
+     * @type Node
+     */
+    link: {
+        valueFn: function() { return this._createLink(); }
+    }
+
+};
+
+Y.extend(Action, Y.Base, {
+
+    /**
+     * Helper method to toggle the CSS_WAITING class on the link element
+     *
+     * @method
+     * @private
+     */
+    _updateRunState: function(isRunning) {
+        // when we get set to true:
+        //   - turn our icon to a spinner, if appropriate
+        if (this.get(LINKCLASSNAME) !== null) {
+            if (isRunning) {
+                this.get(LINK).replaceClass(
+                    this.get(LINKCLASSNAME),
+                    Y.lazr.ui.CSS_WAITING);
+            } else {
+                this.get(LINK).replaceClass(
+                    Y.lazr.ui.CSS_WAITING,
+                    this.get(LINKCLASSNAME));
+            }
+        }
+        return isRunning;
+    },
+
+    /**
+     * Helper method to create the link element, and perform initial decoration
+     *
+     * @method
+     * @private
+     */
+    _createLink: function() {
+        var label = this.get(LABEL);
+        var title = this.get(TITLE);
+        var linkClassName = this.get(LINKCLASSNAME);
+        var link = Y.Node.create(
+        "<a href='#' alt='" + title +
+            "' title='" + title + "'></a>");
+        link.on("click", this.actionRunner, this);
+
+        if (label !== null) {
+            link.append(Y.Node.create(label));
+        }
+
+        if (linkClassName !== null) {
+            link.addClass(linkClassName);
+        }
+
+        return link;
+    },
+
+    /**
+     * Helper method to create the list item element, and perform initial decoration
+     *
+     * @method
+     * @private
+     */
+    _createItem: function() {
+        var itemClassName = this.get(ITEMCLASSNAME);
+        var link = this.get(LINK);
+        var item = Y.Node.create('<li/>'); 
+
+        if (itemClassName !== null) {
+            item.addClass(itemClassName);
+        }
+
+        item.append(link);
+
+        return item;
+    },
+
+    /**
+     * Render the action and attach it to a node
+     *
+     * @method render
+     */
+    render: function(node) {
+        // Build the link, labeling it if necessary
+        // Compose the item, decorating it if necessary
+        var item = this.get(ITEM);
+        var permission = this.get(PERMISSION);
+
+        if (permission && !permission()) {
+            item.addClass(LAZR_ACTION_DISABLED);
+        } else {
+            item.removeClass(LAZR_ACTION_DISABLED);
+        }
+
+        // Place the item
+        node.append(item);
+    },
+
+    /**
+     * Wrap the actual function so that it short-circuits if the action is currently
+     * running
+     *
+     * @method actionRunner
+     */
+    actionRunner: function() {
+        if (!this.get(RUNNING)) {
+            // Not running, fire.
+            this.get(ACTION)();
+        }
+    }
+});
+
+Y.lazr.actions.Action = Action;
+
+}, "0.1.", {"requires": ["oop", "base", "node", "lazr.base"]});

=== added directory 'lib/lp/app/javascript/lazr/actions/tests'
=== added file 'lib/lp/app/javascript/lazr/actions/tests/actions.html'
--- lib/lp/app/javascript/lazr/actions/tests/actions.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/actions/tests/actions.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,28 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Actions</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../actions/actions.js"></script>
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="actions.js"></script>
+
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+</head>
+<body class="yui3-skin-sam">
+
+<div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/actions/tests/actions.js'
--- lib/lp/app/javascript/lazr/actions/tests/actions.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/actions/tests/actions.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,87 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.actions', 'lazr.testing.runner', 'node',
+          'event', 'event-simulate', 'console', function(Y) {
+
+var Assert = Y.Assert;  // For easy access to isTrue(), etc.
+
+var suite = new Y.Test.Suite("Actions Tests");
+
+var permissions = {};
+
+var permission_factory = function(perm) {
+    var perm_check = function() {
+        return permissions[perm];
+    };
+    return perm_check;
+};
+
+suite.add(new Y.Test.Case({
+
+    name: 'actions_basics',
+
+    setUp: function() {
+        this.workspace = Y.one('#workspace');
+        if (!this.workspace){
+            Y.one(document.body).append(Y.Node.create(
+                '<div id="workspace">'
+                + '<div id="monkeys"></div>'
+                + '<div id="monkeys-container"></div>'
+                + '</div>'));
+            this.workspace = Y.one('#workspace');
+        }
+        this.actions_helper = new Y.lazr.actions.ActionsHelper(
+            {
+                actions: [
+                    new Y.lazr.actions.Action(
+                        {title: "See No Evil",
+                         action: function() { Y.log('Saw some evil.'); },
+                         permission: permission_factory('can_see')}),
+                    new Y.lazr.actions.Action(
+                        {title: "Hear No Evil",
+                         action: function() { Y.log('Heard some evil.'); },
+                         permission: permission_factory('can_hear')}),
+                    new Y.lazr.actions.Action(
+                        {title: "Speak No Evil",
+                         action: function() { Y.log('Spoke some evil.'); }})
+                    ],
+                actionsId: "monkeys-container"
+            });
+    },
+
+    tearDown: function() {
+        this.workspace.remove();
+    },
+
+    test_can_see_and_hear_and_talk: function() {
+        permissions.can_see = true;
+        permissions.can_hear = true;
+        permissions.can_speak = false; // won't matter, action isn't bound to permission
+
+        var container = Y.one("#monkeys");
+        this.actions_helper.render(container);
+
+        Assert.isTrue(container.all('li').size() == 3, 'Woops, wrong number of children')
+    },
+
+    test_can_see_and_talk: function() {
+        permissions.can_see = true;
+        permissions.can_hear = false;
+        permissions.can_speak = false; // won't matter, action isn't bound to permission
+
+        var container = Y.one("#monkeys");
+        this.actions_helper.render(container);
+
+        var second_action = this.actions_helper.get('actions')[1];
+        var second_item = second_action.get('item');
+
+        // the actions helper still renders
+        Assert.isTrue(container.all('li').size() == 3, 'Woops, wrong number of children');
+        // but the second item is now disabled, via CSS
+        Assert.isTrue(second_item.hasClass('lazr-action-disabled'), "Didn't get disabled properly");
+    }
+}));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+});

=== added directory 'lib/lp/app/javascript/lazr/activator'
=== added file 'lib/lp/app/javascript/lazr/activator/activator.js'
--- lib/lp/app/javascript/lazr/activator/activator.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/activator/activator.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,285 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.activator', function(Y) {
+
+var ACTIVATOR     = 'activator',
+
+    // Local aliases
+    getCN = Y.ClassNameManager.getClassName,
+
+    // Templates.
+    MESSAGE_HEADER_TEMPLATE = '<div></div>',
+    MESSAGE_BODY_TEMPLATE = '<div></div>',
+    MESSAGE_CLOSE_BUTTON_TEMPLATE = '<button>Close</button>',
+
+    // Events.
+    ACT           = 'act',
+
+    // Class for hiding elements.
+    C_HIDDEN      = getCN(ACTIVATOR, 'hidden'),
+
+    // Classes identifying elements.
+    C_ACT         = getCN(ACTIVATOR, 'act'),
+    C_DATA_BOX    = getCN(ACTIVATOR, 'data-box'),
+    C_MESSAGE_BOX = getCN(ACTIVATOR, 'message-box'),
+
+    // Classes for internally created elements.
+    C_MESSAGE_CLOSE  = getCN(ACTIVATOR, 'message-close'),
+    C_MESSAGE_HEADER = getCN(ACTIVATOR, 'message-header'),
+    C_MESSAGE_BODY   = getCN(ACTIVATOR, 'message-body'),
+
+    // Classes indicating status.
+    C_PROCESSING  = getCN(ACTIVATOR, 'processing'),
+    C_CANCEL      = getCN(ACTIVATOR, 'cancellation'),
+    C_SUCCESS     = getCN(ACTIVATOR, 'success'),
+    C_FAILURE     = getCN(ACTIVATOR, 'failure'),
+
+    ALL_STATUSES  = [C_SUCCESS, C_FAILURE, C_CANCEL, C_PROCESSING];
+
+/**
+ * The Activator widget will hook up an element to trigger an action,
+ * and it will change the display to indicate whether the action
+ * is processing, ended successfully, ended in error, or was cancelled.
+ *
+ * A CSS class is applied in each different state. Success and
+ * failure also trigger a green or red flash animation.
+ *
+ * @class Activator
+ * @constructor
+ * @extends Widget
+ */
+var Activator = function() {
+    Activator.superclass.constructor.apply(this, arguments);
+};
+
+Activator.NAME = ACTIVATOR;
+
+Activator.ATTRS = {};
+
+Y.extend(Activator, Y.Widget, {
+
+    /**
+     * Destination of status messages.
+     *
+     * @property message_box
+     * @type Node
+     */
+    message_box: null,
+
+    /**
+     * Destination of new data. Useful when activating an editor.
+     *
+     * @property data_box
+     * @type Node
+     */
+    data_box: null,
+
+    /**
+     * Element that triggers the event.
+     *
+     * @property action_element
+     * @type Node
+     */
+    action_element: null,
+
+    /**
+     * Set the CSS class on the context box to indicate that the status is
+     * either error, success, cancellation, or processing.
+     *
+     * @method _setStatusClass
+     * @protected.
+     */
+    _setStatusClass: function(css_class) {
+        Y.Array.each(ALL_STATUSES, function (old_class, i) {
+            this.get('contentBox').removeClass(old_class);
+        }, this);
+        this.get('contentBox').addClass(css_class);
+    },
+
+    /**
+     * Display message for either error, success, cancellation, or
+     * processing.
+     *
+     * @method _renderMessage
+     * @protected.
+     */
+    _renderMessage: function(title, message_node) {
+        this.message_box.set('innerHTML', '');
+        if (message_node === undefined) {
+            this.message_box.addClass(C_HIDDEN);
+        } else {
+            this.message_box.removeClass(C_HIDDEN);
+
+            // Close button
+            var message_close_button = Y.Node.create(
+                MESSAGE_CLOSE_BUTTON_TEMPLATE);
+            message_close_button.addClass(C_MESSAGE_CLOSE);
+            message_close_button.addClass('lazr-btn');
+
+            message_close_button.on('click', function (e) {
+                this.message_box.addClass(C_HIDDEN);
+            }, this);
+
+            // Header
+            var message_header = Y.Node.create(MESSAGE_HEADER_TEMPLATE);
+            message_header.appendChild(message_close_button);
+            message_header.appendChild(Y.Node.create(title));
+            message_header.addClass(C_MESSAGE_HEADER);
+
+            // Body
+            var message_body = Y.Node.create(
+                MESSAGE_BODY_TEMPLATE);
+            message_body.appendChild(message_node);
+            message_body.addClass(C_MESSAGE_BODY);
+
+            this.message_box.appendChild(message_header);
+            this.message_box.appendChild(message_body);
+        }
+    },
+
+    /**
+     * Animate that the action occurred successfully, and overwrite the
+     * contents of the element which has the C_DATA_BOX class.
+     *
+     * @method renderSuccess
+     * @param {Node} data_node Optional parameter to update data. Normally
+     *                         this would indicate editing a field.
+     * @param {Node} message_node Optional parameter to display a message.
+     * @protected
+     */
+    renderSuccess: function(data_node, message_node) {
+        if (data_node !== undefined) {
+            this.data_box.set('innerHTML', '');
+            this.data_box.appendChild(data_node);
+        }
+        this._setStatusClass(C_SUCCESS);
+        this._renderMessage('Message', message_node);
+        var anim = Y.lazr.anim.green_flash({node: this.animation_node});
+        anim.run();
+    },
+
+    /**
+     * Animate failure.
+     *
+     * @method renderFailure
+     * @param {Node} Optional parameter to display a message.
+     * @protected.
+     */
+    renderFailure: function(message_node) {
+        this._renderMessage('Error', message_node);
+        this._setStatusClass(C_FAILURE);
+        var anim = Y.lazr.anim.red_flash({node: this.animation_node});
+        anim.run();
+    },
+
+    /**
+     * Animate cancellation.
+     *
+     * @method renderCancellation
+     * @param {Node} Optional parameter to display a message.
+     * @protected.
+     */
+    renderCancellation: function(message_node) {
+        this._renderMessage('Message', message_node);
+        this._setStatusClass(C_CANCEL);
+        var anim = Y.lazr.anim.red_flash({node: this.animation_node});
+        anim.run();
+    },
+
+    /**
+     * Indicate that the action is processing. This is normally done
+     * by configuring the C_PROCESSING class to display a spinning
+     * animated GIF.
+     *
+     * @method renderProcessing
+     * @param {Node} Optional parameter to display a message.
+     * @protected.
+     */
+    renderProcessing: function(message_node) {
+        this._renderMessage('Message', message_node);
+        this._setStatusClass(C_PROCESSING);
+    },
+
+    /**
+     * Initialize the widget.
+     *
+     * @method initializer
+     * @protected
+     */
+    initializer: function(cfg) {
+        this.publish(ACT);
+        if (cfg === undefined || cfg.contentBox === undefined) {
+            // We need the contentBox to be passed in the cfg,
+            // although the init method is the one that actually copies
+            // that cfg to the contentBox ATTR.
+            throw new Error("Missing contentBox argument for Activator.");
+        }
+        this.message_box = this.get('contentBox').one('.' + C_MESSAGE_BOX);
+        if (this.message_box === null) {
+            throw new Error("Can't find element with CSS class " +
+                C_MESSAGE_BOX + ".");
+        }
+        this.data_box = this.get('contentBox').one('.' + C_DATA_BOX);
+        if (this.data_box === null) {
+            throw new Error("Can't find element with CSS class " +
+                C_DATA_BOX + ".");
+        }
+        this.action_element = this.get('contentBox').one('.' + C_ACT);
+        if (this.action_element === null) {
+            throw new Error("Can't find element with CSS class " +
+                C_ACT + ".");
+        }
+        this.animation_node = cfg.animationNode;
+        if (this.animation_node === undefined) {
+            this.animation_node = this.get('contentBox');
+        }
+    },
+
+    /**
+     * Update the DOM structure and edit CSS classes.
+     *
+     * @method renderUI
+     * @protected
+     */
+    renderUI: function() {
+        // Just in case the user didn't assign the correct classes.
+        this.action_element.removeClass(C_HIDDEN);
+        // Use &thinsp; character to prevent IE7 from hiding the
+        // yui3-activator-act button, when it just has a background-image
+        // and no content in it or in the data_box.
+        this.get('contentBox').prepend('&thinsp;');
+    },
+
+    /**
+     * Set the event handler for the actor element.
+     *
+     * @method bindUI
+     * @protected
+     */
+    bindUI: function() {
+        var activator = this;
+        Y.on('click', function(e) {
+            activator.fire(ACT);
+            e.preventDefault();
+        }, this.action_element);
+    },
+
+    /**
+     * UI syncing should all be handled by the status events.
+     *
+     * @method syncUI
+     * @protected
+     */
+    syncUI: function() {
+    }
+});
+
+Y.lazr.ui.disableTabIndex(Activator);
+
+Y.namespace('lazr.activator');
+Y.lazr.activator.Activator = Activator;
+
+
+}, "0.1", {"skinnable": true,
+           "requires": ["oop", "event", "node", "widget",
+                        "lazr.anim", "lazr.base"]});

=== added directory 'lib/lp/app/javascript/lazr/activator/assets'
=== added file 'lib/lp/app/javascript/lazr/activator/assets/activator-core.css'
--- lib/lp/app/javascript/lazr/activator/assets/activator-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/activator/assets/activator-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,5 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+.yui3-activator-hidden {
+    display: none;
+}

=== added directory 'lib/lp/app/javascript/lazr/activator/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/activator/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/activator/assets/skins/sam/activator-skin.css'
--- lib/lp/app/javascript/lazr/activator/assets/skins/sam/activator-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/activator/assets/skins/sam/activator-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,74 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+.yui3-skin-sam button.yui3-activator-act {
+    background: url('edit.png') 0 0 no-repeat;
+}
+
+.yui3-skin-sam .yui3-activator-processing button.yui3-activator-act {
+    background: url('../../../../lazr/assets/skins/sam/spinner.gif')
+                0 0 no-repeat;
+}
+
+.yui3-activator-data-box {
+}
+
+.yui3-activator-message-box {
+    position: absolute;
+    background-color: #eeeeee;
+    border: 2px solid #000000;
+    /* Center horizontally in the page. No change is made to its
+     * vertical position, which is just below the activator button.
+     */
+    left: 0;
+    right: 0;
+    margin-left: auto;
+    margin-right: auto;
+    width: 30em;
+
+    /* Center the message box on IE7. */
+    *left: 30%;
+    *width: 40%;
+}
+
+.yui3-skin-sam button.yui3-activator-message-close {
+    background: url('../../../../overlay/assets/skins/sam/images/close.gif')
+                0 0 no-repeat;
+    height: 15px;
+    float: right;
+    margin-top: 1px;
+    margin-right: 1px;
+}
+
+.yui3-activator-message-header {
+    color: white;
+    background-color: #000000;
+    font-style: bold;
+    font-size: medium;
+    padding-left: 0.3em;
+    min-height: 19px;
+}
+
+.yui3-activator-message-body {
+    padding: 0;
+    overflow: auto;
+    /* Necessary for IE7. */
+    width: 100%;
+}
+
+.yui3-activator-failure .yui3-activator-message-box {
+    background-color: #ffdddd;
+    border: 2px solid #ff0000;
+}
+
+.yui3-activator-failure .yui3-activator-message-header {
+    background-color: #ff0000;
+}
+
+.yui3-activator-success .yui3-activator-message-box {
+    background-color: #ddffdd;
+    border: 2px solid #008000;
+}
+
+.yui3-activator-success .yui3-activator-message-header {
+    background-color: #008000;
+}

=== added file 'lib/lp/app/javascript/lazr/activator/assets/skins/sam/edit.png'
Binary files lib/lp/app/javascript/lazr/activator/assets/skins/sam/edit.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/activator/assets/skins/sam/edit.png	2011-06-30 12:01:55 +0000 differ
=== added directory 'lib/lp/app/javascript/lazr/activator/tests'
=== added file 'lib/lp/app/javascript/lazr/activator/tests/activator.html'
--- lib/lp/app/javascript/lazr/activator/tests/activator.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/activator/tests/activator.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,29 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Activator</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../activator/activator.js"></script>
+  <script type="text/javascript" src="../../anim/anim.js"></script>
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="activator.js"></script>
+
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+</head>
+<body class="yui3-skin-sam">
+
+<div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/activator/tests/activator.js'
--- lib/lp/app/javascript/lazr/activator/tests/activator.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/activator/tests/activator.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,274 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.activator', 'lazr.testing.runner', 'node',
+           'event', 'event-simulate', 'console', function(Y) {
+
+var Assert = Y.Assert;  // For easy access to isTrue(), etc.
+
+/*
+ * A wrapper for the Y.Event.simulate() function.  The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(selector, evtype) {
+    var rawnode = Y.Node.getDOMNode(Y.one(selector));
+    Y.Event.simulate(rawnode, evtype);
+}
+
+/* Helper function to clean up a dynamically added widget instance. */
+function cleanup_widget(widget) {
+    // Nuke the boundingBox, but only if we've touched the DOM.
+    if (widget.get('rendered')) {
+        var bb = widget.get('boundingBox');
+        bb.get('parentNode').removeChild(bb);
+    }
+    // Kill the widget itself.
+    widget.destroy();
+}
+
+var suite = new Y.Test.Suite("Activator Tests");
+
+
+suite.add(new Y.Test.Case({
+
+    name: 'activator_basics',
+
+    setUp: function() {
+        this.workspace = Y.one('#workspace');
+        if (!this.workspace){
+            Y.one(document.body).appendChild(Y.Node.create(
+                '<div id="workspace" ' +
+                'style="border: 1px solid blue; ' +
+                'width: 20em; ' +
+                'margin: 1em; ' +
+                'padding: 1em">'+
+                '</div>'));
+            this.workspace = Y.one('#workspace');
+        }
+        this.workspace.appendChild(Y.Node.create(
+            '<div id="example-1">' +
+            '<div id="custom-animation-node"/>' +
+            '<span class="yui3-activator-data-box">' +
+            '    Original Value' +
+            '</span>' +
+            '<button ' +
+            ' class="lazr-btn yui3-activator-act yui3-activator-hidden">' +
+            '    Go' +
+            '</button>' +
+            '<div class="yui3-activator-message-box yui3-activator-hidden">' +
+            '</div>' +
+            '</div>'));
+        this.activator = new Y.lazr.activator.Activator(
+            {contentBox: Y.one('#example-1')});
+        this.action_button = this.activator.get('contentBox').one(
+            '.yui3-activator-act');
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.activator);
+        this.workspace.set('innerHTML', '');
+    },
+
+    test_correct_animation_node: function() {
+        // Check that the correct animation node is used.
+        // First check the default.
+        Assert.areEqual(this.activator.get('contentBox'),
+                    this.activator.animation_node);
+        // Now check a custom one.
+        var custom_node = Y.one('#custom-animation-node');
+        this.activator = new Y.lazr.activator.Activator(
+            {contentBox: Y.one('#example-1'), animationNode: custom_node});
+s        Assert.areEqual(custom_node, this.activator.animation_node);
+    },
+
+    test_unhiding_action_button: function() {
+        this.action_button.addClass('yui3-activator-hidden');
+        Assert.isTrue(this.action_button.hasClass('yui3-activator-hidden'));
+        this.activator.render();
+        Assert.isFalse(
+            this.action_button.hasClass('yui3-activator-hidden'),
+            "yui3-activator-hidden class wasn't removed from the " +
+            "action button");
+    },
+
+    test_simulate_click_on_action_button: function() {
+        var fired = false;
+        this.activator.render();
+        this.activator.subscribe('act', function(e) {
+            fired = true;
+        }, this);
+        simulate(this.action_button, 'click');
+        Assert.isTrue(fired, "'act' event wasn't fired.");
+    },
+
+    test_renderSuccess: function() {
+        this.activator.render();
+        var data = Y.Node.create('new value');
+        var message = Y.Node.create('success message');
+        Assert.isFalse(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-success'),
+            'The widget is not setup propertly.');
+
+        this.activator.renderSuccess(data, message);
+
+        Assert.isTrue(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-success'),
+            'renderSuccess did not add the success css class');
+
+        var data_box = this.activator.get('contentBox').one(
+            '.yui3-activator-data-box');
+        Assert.areEqual(
+            'new value',
+            data_box.get('innerHTML'),
+            'renderSuccess did not set the contents of the data-box');
+
+        var message_body = this.activator.get('contentBox').one(
+            '.yui3-activator-message-body');
+
+        Assert.areEqual(
+            'success message',
+            message_body.get('innerHTML'),
+            'renderSuccess did not set the contents of the message-body');
+    },
+
+    test_renderProcessing: function() {
+        this.activator.render();
+        var message_text = 'processing message';
+        var message = Y.Node.create('<b>' + message_text + '</b>');
+        Assert.isFalse(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-processing'),
+            'The widget is not setup propertly.');
+
+        this.activator.renderProcessing(message);
+
+        Assert.isTrue(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-processing'),
+            'renderProcessing did not add the processing css class');
+
+        var message_body = this.activator.get('contentBox').one(
+            '.yui3-activator-message-body');
+
+        // Opera uppercases all tags, Safari lowercases all tags,
+        // and IE gets an extra _yuid attribute in the <b>.
+        var added_node = message_body.one('b');
+        Assert.areEqual(
+            message_text,
+            added_node.get('innerHTML'),
+            'renderProcessing did not set the contents of the message-body');
+    },
+
+    test_renderCancellation: function() {
+        this.activator.render();
+        var message_text = 'cancel message';
+        var message = Y.Node.create('<b>' + message_text + '</b>');
+        Assert.isFalse(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-cancellation'),
+            'The widget is not setup propertly.');
+
+        this.activator.renderCancellation(message);
+
+        Assert.isTrue(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-cancellation'),
+            'renderCancellation did not add the cancel css class');
+
+        var message_body = this.activator.get('contentBox').one(
+            '.yui3-activator-message-body');
+        // Opera uppercases all tags, Safari lowercases all tags,
+        // and IE gets an extra _yuid attribute in the <b>.
+        var added_node = message_body.one('b');
+        Assert.areEqual(
+            message_text,
+            added_node.get('innerHTML'),
+            "renderCancellation didn't set the contents of the message-body");
+    },
+
+    test_renderFailure: function() {
+        this.activator.render();
+        var message = Y.Node.create('failure message');
+        Assert.isFalse(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-failure'),
+            'The widget is not setup propertly.');
+
+        this.activator.renderFailure(message);
+
+        Assert.isTrue(
+            this.activator.get('contentBox').hasClass(
+                'yui3-activator-failure'),
+            'renderFailure did not add the failure css class');
+
+        var message_body = this.activator.get('contentBox').one(
+            '.yui3-activator-message-body');
+
+        Assert.areEqual(
+            'failure message',
+            message_body.get('innerHTML'),
+            'renderFailure did not set the contents of the message-body');
+    },
+
+    test_empty_message_box: function() {
+        // If no message_node is passed to renderFailure(),
+        // the message box is hidden.
+        this.activator.render();
+        this.activator.renderFailure();
+
+        var message_box = this.activator.get('contentBox').one(
+            '.yui3-activator-message-box');
+
+        Assert.isTrue(
+            message_box.hasClass('yui3-activator-hidden'),
+            "Message box should be hidden.");
+        Assert.areEqual(
+            '',
+            message_box.get('innerHTML'),
+            'Message box contents should be empty.');
+    },
+
+    test_closing_message_box: function() {
+        this.activator.render();
+        this.activator.renderFailure(Y.Node.create('short message'));
+
+        var message_box = this.activator.get('contentBox').one(
+            '.yui3-activator-message-box');
+        var message_body = this.activator.get('contentBox').one(
+            '.yui3-activator-message-body');
+        var message_close_button = this.activator.get('contentBox').one(
+            '.yui3-activator-message-close');
+        simulate(message_close_button, 'click');
+        Assert.isTrue(
+            message_box.hasClass('yui3-activator-hidden'),
+            "Message box should be hidden.");
+        Assert.areEqual(
+            'short message',
+            message_body.get('innerHTML'),
+            'Message body contents should still be there.');
+    },
+
+    test_widget_has_a_disabled_tabindex_when_focused: function() {
+        // The tabindex attribute appears when the widget is focused.
+        this.activator.render();
+        this.activator.focus();
+
+        // Be aware that in IE when the tabIndex is set to -1,
+        // get('tabIndex') returns -1 as expected but getAttribute('tabIndex')
+        // returns 65535. This is due to YUI's getAttribute() calling
+        // dom_node.getAttribute('tabIndex', 2), which is an IE extension
+        // that happens to treat this attribute as an unsigned integer instead
+        // of as a signed integer.
+        // http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx
+        Assert.areEqual(
+            -1,
+            this.activator.get('boundingBox').get('tabIndex'),
+            "The widget should have a tabindex of -1 (disabled).");
+    }
+}));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added directory 'lib/lp/app/javascript/lazr/anim'
=== added file 'lib/lp/app/javascript/lazr/anim/anim.js'
--- lib/lp/app/javascript/lazr/anim/anim.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/anim/anim.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,148 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.anim', function(Y) {
+
+Y.namespace('lazr.anim');
+
+/**
+ * @function flash_in
+ * @description Create a flash-in animation object.  Dynamically checks
+ * the 'to' property to see that the node's color isn't "transparent".
+ * @param cfg Additional Y.Anim configuration.
+ * @return Y.Anim instance
+ */
+Y.lazr.anim.flash_in = function(cfg) {
+    var acfg = Y.merge(Y.lazr.anim.flash_in.defaults, cfg);
+    var anim = new Y.lazr.anim.Anim(acfg);
+
+    return anim;
+};
+
+Y.lazr.anim.flash_in.defaults = {
+    duration: 1,
+    easing: Y.Easing.easeIn,
+    from: { backgroundColor: '#FFFF00' },
+    to: { backgroundColor: '#FFFFFF' }
+};
+
+
+
+/**
+ * @function green_flash
+ * @description A green flash and fade, used to indicate new page data.
+ * @param cfg Additional Y.Anim configuration.
+ * @return Y.Anim instance
+ */
+Y.lazr.anim.green_flash = function(cfg) {
+    return Y.lazr.anim.flash_in(
+        Y.merge(Y.lazr.anim.green_flash.defaults, cfg));
+};
+
+Y.lazr.anim.green_flash.defaults = {
+    from: { backgroundColor: '#90EE90' }
+};
+
+
+/**
+ * @function red_flash
+ * @description A red flash and fade, used to indicate errors.
+ * @param cfg Additional Y.Anim configuration.
+ * @return Y.Anim instance
+ */
+Y.lazr.anim.red_flash = function(cfg) {
+    return Y.lazr.anim.flash_in(
+        Y.merge(Y.lazr.anim.red_flash.defaults, cfg));
+};
+
+Y.lazr.anim.red_flash.defaults = {
+    from: { backgroundColor: '#FF6666' }
+};
+
+var resolveNodeListFrom = function(protonode) {
+    if (typeof protonode === 'string') {
+        // selector
+        return Y.all(protonode);
+    } else if (protonode._node !== undefined) {
+        // Node
+        return new Y.NodeList([protonode]);
+    } else if (protonode._nodes !== undefined) {
+        // NodeList
+        return protonode;
+    }
+
+    throw('Not a selector, Node, or NodeList');
+};
+
+/*
+ * The Anim widget similar to Y.anim.Anim, but supports operating on a NodeList
+ *
+ * @class Anim
+ */
+Anim = function(cfg) {
+   var nodelist = resolveNodeListFrom(cfg.node);
+   this._anims = [];
+   var self = this;
+   var config = cfg;
+   Y.each(nodelist,
+          function(n) {
+              var ncfg = Y.merge(config, {node: n});
+              var anim = new Y.Anim(ncfg);
+              // We need to validate the config
+              // afterwards because some of the
+              // properties may be dynamic.
+              var to = ncfg.to;
+
+              // Check the background color to make sure
+              // it isn't 'transparent'.
+              if (to && typeof to.backgroundColor === 'function') {
+                  var bg = to.backgroundColor.call(
+                      anim, anim.get('node'));
+                  if (bg == 'transparent') {
+                      Y.error("Can not animate to a 'transparent' background " +
+                             "in '" + anim + "'");
+                  }
+              }
+
+              // Reset the background color. This is
+              // normally only necessary when the
+              // original background color of the node
+              // or its parent are not white, since we
+              // normally fade to white.
+              var original_bg = null;
+              anim.on('start', function () {
+                          original_bg = anim.get('node').getStyle('backgroundColor');
+                      });
+              anim.on('end', function () {
+                          anim.get('node').setStyle('backgroundColor', original_bg);
+                      });
+
+              self._anims.push(anim);
+          }
+         );
+};
+
+Anim.prototype = {
+    run: function() {
+        // delegate all behavior back to our collection of Anims
+        Y.each(this._anims,
+               function(n) {
+                   n.run();
+               }
+              );
+    },
+
+    on: function() {
+        // delegate all behavior back to our collection of Anims
+        var args = arguments;
+        Y.each(this._anims,
+               function(n) {
+                   n.on.apply(n, args);
+               }
+              );
+    }
+};
+
+Y.lazr.anim.Anim = Anim;
+Y.lazr.anim.resolveNodeListFrom = resolveNodeListFrom;
+
+}, "0.1", {"requires":["base", "node", "anim"]});

=== added directory 'lib/lp/app/javascript/lazr/anim/tests'
=== added file 'lib/lp/app/javascript/lazr/anim/tests/anim.html'
--- lib/lp/app/javascript/lazr/anim/tests/anim.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/anim/tests/anim.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,28 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Anim</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../anim/anim.js"></script>
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="anim.js"></script>
+
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+</head>
+<body class="yui3-skin-sam">
+
+<div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/anim/tests/anim.js'
--- lib/lp/app/javascript/lazr/anim/tests/anim.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/anim/tests/anim.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,187 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.anim', 'lazr.testing.runner', 'node',
+          'event', 'event-simulate', 'console', function(Y) {
+
+var Assert = Y.Assert;  // For easy access to isTrue(), etc.
+
+var suite = new Y.Test.Suite("Anim Tests");
+
+suite.add(new Y.Test.Case({
+
+    name: 'anim_basics',
+
+    setUp: function() {
+        this.workspace = Y.one('#workspace');
+        if (!this.workspace){
+            Y.one(document.body).appendChild(Y.Node.create(
+                '<div id="workspace">'
+                + '<table id="anim-table">'
+                + '<tr id="anim-table-tr">'
+                + '<td id="anim-table-td1" style="background: #eeeeee">foo</td>'
+                + '<td id="anim-table-td2" style="background: #eeeeee">bar</td>'
+                + '</tr></table></div>'));
+            this.workspace = Y.one('#workspace');
+        }
+    },
+
+    tearDown: function() {
+        this.workspace.get('parentNode').removeChild(this.workspace);
+    },
+
+    test_resolveNodeListFrom_selector: function() {
+        var nodelist = Y.lazr.anim.resolveNodeListFrom('#anim-table-td1');
+        var nodelist_nodes = (nodelist._nodes !== undefined);
+        Assert.isTrue(nodelist_nodes, 'Not a nodelist from a selector');
+    },
+
+    test_resolveNodeListFrom_node: function() {
+        var node = Y.one('#anim-table-td1');
+        var nodelist = Y.lazr.anim.resolveNodeListFrom(node);
+        var nodelist_nodes = (nodelist._nodes !== undefined);
+        Assert.isTrue(nodelist_nodes, 'Not a nodelist from a Node');
+    },
+
+    test_resolveNodeListFrom_node_list: function() {
+        var nodelist = Y.all('#anim-table td');
+        var nodelist = Y.lazr.anim.resolveNodeListFrom(nodelist);
+        var nodelist_nodes = (nodelist._nodes !== undefined);
+        Assert.isTrue(nodelist_nodes, 'Not a nodelist from a NodeList');
+    },
+
+    test_resolveNodeListFrom_anythine_else: function() {
+        var succeed = true;
+        try {
+            var nodelist = Y.lazr.anim.resolveNodeListFrom(
+                {crazy: true, broken: 'definitely'});
+        } catch(e) {
+            succeed = false;
+        }
+        Assert.isFalse(succeed, "Somehow, we're cleverer than we thought.");
+    },
+
+    test_green_flash_td1: function() {
+        // works as expected on a single node,
+        // without coercion into a NodeList here
+        var node = Y.one('#anim-table-td1');
+        var bgcolor = node.getStyle('backgroundColor');
+        var anim = Y.lazr.anim.green_flash(
+            {node: node,
+             to: {backgroundColor: bgcolor},
+             duration: 0.2}
+        );
+        anim.run();
+        this.wait(function() {
+            Assert.areEqual(
+                bgcolor,
+                node.getStyle('backgroundColor'),
+                'background colors do not match'
+                );
+            }, 500
+        );
+    },
+
+    test_green_flash_td1_by_selector: function() {
+        // works as expected on a single node selector,
+        // without coercion into a NodeList here
+        var node = Y.one('#anim-table-td1');
+        var bgcolor = node.getStyle('backgroundColor');
+        var anim = Y.lazr.anim.green_flash(
+            {node: '#anim-table-td1',
+             to: {backgroundColor: bgcolor},
+             duration: 0.2}
+        );
+        anim.run();
+        this.wait(function() {
+            Assert.areEqual(
+                bgcolor,
+                node.getStyle('backgroundColor'),
+                'background colors do not match'
+                );
+            }, 500
+        );
+    },
+
+    test_green_flash_multi: function() {
+        // works with a native NodeList as well
+        var nodelist = Y.all('#anim-table td');
+        var red = '#ff0000';
+        var backgrounds = [];
+        Y.each(nodelist, function(n) {
+                   backgrounds.push({bg: n.getStyle('backgroundColor'), node: n});
+               });
+        var anim = Y.lazr.anim.green_flash(
+            {node: nodelist,
+             to: {backgroundColor: red},
+             duration: 5}
+        );
+        anim.run();
+        this.wait(function() {
+                Assert.areNotEqual(
+                    backgrounds[0].node.getStyle('backgroundColor'),
+                    red,
+                    'background of 0 has mysteriously jumped to the end color.'
+                );
+                Assert.areNotEqual(
+                    backgrounds[1].node.getStyle('backgroundColor'),
+                    red,
+                    'background of 1 has mysteriously jumped to the end color.'
+                );
+                Assert.areNotEqual(
+                    backgrounds[0].node.getStyle('backgroundColor'),
+                    backgrounds[0].bg,
+                    'background of 0 has not changed at all.'
+                );
+                Assert.areNotEqual(
+                    backgrounds[1].node.getStyle('backgroundColor'),
+                    backgrounds[1].bg,
+                    'background of 1 has not changed at all.'
+                );
+            }, 1500
+        );
+    },
+
+    test_green_flash_multi_by_selector: function() {
+        // works with a native NodeList as well
+        var nodelist = Y.all('#anim-table td');
+        var red = '#ff0000';
+        var backgrounds = [];
+        Y.each(nodelist, function(n) {
+                   backgrounds.push({bg: n.getStyle('backgroundColor'), node: n});
+               });
+        var anim = Y.lazr.anim.green_flash(
+            {node: '#anim-table td',
+             to: {backgroundColor: red},
+             duration: 2}
+        );
+        anim.run();
+        this.wait(function() {
+                Assert.areNotEqual(
+                    backgrounds[0].node.getStyle('backgroundColor'),
+                    red,
+                    'background of 0 has mysteriously jumped to the end color.'
+                );
+                Assert.areNotEqual(
+                    backgrounds[1].node.getStyle('backgroundColor'),
+                    red,
+                    'background of 1 has mysteriously jumped to the end color.'
+                );
+                Assert.areNotEqual(
+                    backgrounds[0].node.getStyle('backgroundColor'),
+                    backgrounds[0].bg,
+                    'background of 0 has not changed at all.'
+                );
+                Assert.areNotEqual(
+                    backgrounds[1].node.getStyle('backgroundColor'),
+                    backgrounds[1].bg,
+                    'background of 1 has not changed at all.'
+                );
+            }, 500
+        );
+    }
+    }));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added directory 'lib/lp/app/javascript/lazr/autocomplete'
=== added directory 'lib/lp/app/javascript/lazr/autocomplete/assets'
=== added file 'lib/lp/app/javascript/lazr/autocomplete/assets/autocomplete-core.css'
--- lib/lp/app/javascript/lazr/autocomplete/assets/autocomplete-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/autocomplete/assets/autocomplete-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,193 @@
+/*
+Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+Licensed under the GNU Affero General Public License:
+http://www.gnu.org/licenses/agpl.txt
+*/
+
+/*
+ * Make the z-index a bit higher than the lazr.Overlay, which is at
+ * z-index 999, so we can use autocomplete widgets inside overlays without
+ * hassle.
+ */
+.yui3-autocomplete { position: absolute; z-index: 1050; }
+.yui3-autocomplete-hidden { display: none; }
+
+
+/*
+* Bring in the NodeMenuNav core CSS from YUI 3.0.0pr2.
+*
+* This saves us from jumping through hoops to pull the individual files
+* from YUI itself.
+*
+* This will have to change once the YUI Loader starts bringing in the CSS
+* file dependencies.
+*/
+.yui3-menu .yui3-menu {
+
+    position: absolute;
+    z-index: 1;
+
+}
+
+
+.yui3-menu .yui3-shim {
+
+    /*
+        Styles for the <iframe> shim used to prevent <select> elements from poking through
+        submenus in IE < 7.  Note: For peformance, creation of the <iframe> shim for each submenu
+        is deferred until it is initially made visible by the user.
+    */
+
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: -1;
+    opacity: 0;
+    filter: alpha(opacity=0);  /* For IE since it doesn't implement the CSS3 "opacity" property. */
+    border: none;
+    margin: 0;
+    padding: 0;
+    height: 100%;
+    width: 100%;
+
+}
+
+.yui3-menu-hidden {
+
+    /*
+        Position hidden menus outside the viewport boundaries to prevent them from
+        triggering scrollbars on the viewport.
+    */
+
+    top: -10000px;
+    left: -10000px;
+
+    /*
+        Using "visibility:hidden" over "display" none because:
+
+        1)  As the "position" property for submenus is set to "absolute", they are out of
+            the document flow and take up no space.  Therefore, from that perspective use of
+            "display:none" is redundant.
+
+        2)  According to MSDN use of "display:none" is more expensive:
+            "Display is the more expensive of the two CSS properties, so if you are
+            making elements appear and disappear often, visibility will be faster."
+            (See http://msdn.microsoft.com/en-us/library/bb264005(VS.85).aspx)
+    */
+
+    visibility: hidden;
+
+}
+
+.yui3-menu li {
+
+    list-style-type: none;
+
+}
+
+.yui3-menu ul,
+.yui3-menu li {
+
+    margin: 0;
+    padding: 0;
+
+}
+
+.yui3-menu-label,
+.yui3-menuitem-content {
+
+    text-align: left;
+    white-space: nowrap;
+    display: block;
+
+}
+
+.yui3-menu-horizontal li {
+
+    float: left;
+    width: auto;
+
+}
+
+.yui3-menu-horizontal li li {
+
+    float: none;
+
+}
+
+.yui3-menu-horizontal ul {
+
+    /*
+        Use of "zoom" sets the "hasLayout" property to "true" in IE (< 8).  When "hasLayout" is
+        set to "true", an element can clear its floated descendents.  For more:
+        http://msdn.microsoft.com/en-gb/library/ms533776(VS.85).aspx
+    */
+
+    *zoom: 1;
+
+}
+
+.yui3-menu-horizontal ul ul {
+
+    /*
+        No need to clear <ul>s of submenus of horizontal menus since <li>s of submenus
+        aren't floated.
+    */
+
+    *zoom: normal;
+
+}
+
+.yui3-menu-horizontal>.yui3-menu-content>ul:after {
+
+    /*  Self-clearing solution for Opera, Webkit, Gecko and IE > 7  */
+
+    content: "";
+    display: block;
+    clear: both;
+    line-height: 0;
+    font-size: 0;
+    visibility: hidden;
+
+}
+
+
+/*
+    The following two rules are for IE 7.  Triggering "hasLayout" (via use of "zoom") prevents
+    first-tier submenus from hiding when the mouse is moving from an menu label in a root menu to
+    its corresponding submenu.
+*/
+
+.yui3-menu-content {
+
+    *zoom: 1;
+
+}
+
+
+.yui3-menu-hidden .yui3-menu-content {
+
+    *zoom: normal;
+
+}
+
+
+/*
+    The following two rules are for IE 6 (Standards Mode and Quirks Mode) and IE 7 (Quirks Mode
+    only).  Triggering "hasLayout" (via use of "zoom") fixes a bug in IE where mousing mousing off
+    the text node of menuitem or menu label will incorrectly trigger the mouseout event.
+*/
+
+.yui3-menuitem-content,
+.yui3-menu-label {
+
+    _zoom: 1;
+
+}
+
+.yui3-menu-hiden .yui3-menuitem-content,
+.yui3-menu-hiden .yui3-menu-label {
+
+    _zoom: normal;
+
+}

=== added directory 'lib/lp/app/javascript/lazr/autocomplete/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/autocomplete/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/autocomplete/assets/skins/sam/autocomplete-skin.css'
--- lib/lp/app/javascript/lazr/autocomplete/assets/skins/sam/autocomplete-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/autocomplete/assets/skins/sam/autocomplete-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,286 @@
+/*
+Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+Licensed under the GNU Affero General Public License:
+http://www.gnu.org/licenses/agpl.txt
+*/
+
+.yui3-skin-sam .yui3-autocomplete-content { background-color: #fff; }
+.yui3-skin-sam .yui3-autocomplete-list {
+    margin: 0;
+    padding: 0 0.3em 0 0.3em;
+    border: 1px solid black;
+}
+.yui3-skin-sam .yui3-autocomplete-list .item { list-style-type: none; }
+.yui3-skin-sam .yui3-autocomplete-list .item .matching-text { font-weight: bold; }
+.yui3-skin-sam .yui3-autocomplete-list .yui3-menuitem-content { padding: 0 0.3em; }
+
+
+/*
+* Bring in the NodeMenuNav skin CSS from YUI 3.0.0pr2.
+*
+* This saves us from jumping through hoops to pull the individual files
+* from YUI itself.
+*
+* This will have to change once the YUI Loader starts bringing in the CSS
+* file dependencies.
+*/
+.yui3-skin-sam .yui3-menu-content,
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menu-content {
+
+    font-size: 93%;  /* 12px */
+    line-height: 1.5;  /* 18px */
+    *line-height: 1.45; /* For IE */
+    border: solid 1px #808080;
+    background: #fff;
+    padding: 3px 0;
+
+}
+
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menu-content {
+
+    font-size: 100%;
+
+}
+
+/* Horizontal menus */
+
+.yui3-skin-sam .yui3-menu-horizontal .yui3-menu-content {
+
+    line-height: 2;  /* ~24px */
+    *line-height: 1.9; /* For IE */
+    background: url(../../../../assets/skins/sam/sprite.png) repeat-x 0 0;
+    padding: 0;
+
+}
+
+
+.yui3-skin-sam .yui3-menu ul,
+.yui3-skin-sam .yui3-menu ul ul {
+
+    margin-top: 3px;
+    padding-top: 3px;
+    border-top: solid 1px #ccc;
+
+}
+
+.yui3-skin-sam .yui3-menu ul.first-of-type {
+
+    border: 0;
+    margin: 0;
+    padding: 0;
+
+}
+
+.yui3-skin-sam .yui3-menu-horizontal ul {
+
+    padding: 0;
+    margin: 0;
+    border: 0;
+
+}
+
+
+.yui3-skin-sam .yui3-menu li,
+.yui3-skin-sam .yui3-menu .yui3-menu li {
+
+    /*
+        For and IE 6 (Strict Mode and Quirks Mode) and IE 7 (Quirks Mode only): Used to collapse
+        superfluous white space between <li> elements that is triggered by the "display" property
+        of the <a> elements being set to "block" by node-menunav-core.css file.
+    */
+
+    _border-bottom: solid 1px #fff;
+
+}
+
+.yui3-skin-sam .yui3-menu-horizontal li {
+
+    _border-bottom: 0;
+
+}
+
+.yui3-skin-sam .yui3-menubuttonnav li {
+
+    border-right: solid 1px #ccc;
+
+}
+
+.yui3-skin-sam .yui3-splitbuttonnav li {
+
+    border-right: solid 1px #808080;
+
+}
+
+.yui3-skin-sam .yui3-menubuttonnav li li,
+.yui3-skin-sam .yui3-splitbuttonnav li li {
+
+    border-right: 0;
+
+}
+
+
+/* Menuitems and menu labels */
+
+
+.yui3-skin-sam .yui3-menu-label,
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menu-label,
+.yui3-skin-sam .yui3-menuitem-content,
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menuitem-content {
+
+    padding: 0 20px;
+    color: #000;
+    text-decoration: none;
+    cursor: default;
+
+    /*
+        Necessary specify values for border, position and margin to override values specified in
+        the selectors that follow.
+    */
+
+    float: none;
+    border: 0;
+    margin: 0;
+
+}
+
+.yui3-skin-sam .yui3-menu-horizontal .yui3-menu-label,
+.yui3-skin-sam .yui3-menu-horizontal .yui3-menuitem-content {
+
+    padding: 0 10px;
+    border-style: solid;
+    border-color: #808080;
+    border-width: 1px 0;
+    margin: -1px 0;
+
+    float: left;    /*  Ensures that menu labels clear floated descendents. Also gets negative
+                        margins working in IE 7 (Strict Mode). */
+    width: auto;
+
+}
+
+.yui3-skin-sam .yui3-menu-label,
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menu-label {
+
+    background: url(vertical-menu-submenu-indicator.png) right center no-repeat;
+
+}
+
+.yui3-skin-sam .yui3-menu-horizontal .yui3-menu-label {
+
+    background: url(../../../../assets/skins/sam/sprite.png) repeat-x 0 0;
+
+}
+
+.yui3-skin-sam .yui3-menubuttonnav .yui3-menu-label,
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label {
+
+    background-image: none;
+
+}
+
+.yui3-skin-sam .yui3-menubuttonnav .yui3-menu-label {
+
+    padding-right: 0;
+
+}
+
+.yui3-skin-sam .yui3-menubuttonnav .yui3-menu-label em {
+
+    font-style: normal;
+    padding-right: 20px;
+    display: block;
+    background: url(horizontal-menu-submenu-indicator.png) right center no-repeat;
+
+}
+
+
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label {
+
+    padding: 0;
+
+}
+
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label a {
+
+    float: left;
+    width: auto;
+    color: #000;
+    text-decoration: none;
+    cursor: default;
+    padding: 0 5px 0 10px;
+
+}
+
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label .yui3-menu-toggle {
+
+    padding: 0; /* Overide padding applied by the preceeding rule. */
+    border-left: solid 1px #ccc;
+    width: 15px;
+    overflow: hidden;
+    text-indent: -1000px;
+    background: url(horizontal-menu-submenu-indicator.png) 3px center no-repeat;
+
+}
+
+
+/* Selected menuitem */
+
+.yui3-skin-sam .yui3-menu-label-active,
+.yui3-skin-sam .yui3-menu-label-menuvisible,
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menu-label-active,
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menu-label-menuvisible {
+
+    background-color: #B3D4FF;
+
+}
+
+.yui3-skin-sam .yui3-menuitem-active .yui3-menuitem-content,
+.yui3-skin-sam .yui3-menu .yui3-menu .yui3-menuitem-active .yui3-menuitem-content {
+
+    background-image: none;
+    background-color: #B3D4FF;
+
+    /*
+        Undo values set for "border-left-width" and "margin-left" when the root menu has a class of
+        "yui3-menubuttonnav" or "yui3-splitbuttonnav" applied.
+    */
+
+    border-left-width: 0;
+    margin-left: 0;
+
+}
+
+.yui3-skin-sam .yui3-menu-horizontal .yui3-menu-label-active,
+.yui3-skin-sam .yui3-menu-horizontal .yui3-menuitem-active .yui3-menuitem-content,
+.yui3-skin-sam .yui3-menu-horizontal .yui3-menu-label-menuvisible {
+
+    border-color: #7D98B8;
+    background: url(../../../../assets/skins/sam/sprite.png) repeat-x 0 -1700px;
+
+}
+
+.yui3-skin-sam .yui3-menubuttonnav .yui3-menu-label-active,
+.yui3-skin-sam .yui3-menubuttonnav .yui3-menuitem-active .yui3-menuitem-content,
+.yui3-skin-sam .yui3-menubuttonnav .yui3-menu-label-menuvisible,
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label-active,
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menuitem-active .yui3-menuitem-content,
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label-menuvisible {
+
+    border-left-width: 1px;
+    margin-left: -1px;
+
+}
+
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label-menuvisible {
+
+    border-color: #808080;
+    background: transparent;
+
+}
+
+.yui3-skin-sam .yui3-splitbuttonnav .yui3-menu-label-menuvisible .yui3-menu-toggle {
+
+    border-color: #7D98B8;
+    background: url(horizontal-menu-submenu-toggle.png) left center no-repeat;
+
+}

=== added file 'lib/lp/app/javascript/lazr/autocomplete/autocomplete.js'
--- lib/lp/app/javascript/lazr/autocomplete/autocomplete.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/autocomplete/autocomplete.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,796 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.autocomplete', function(Y) {
+
+/**
+ * A simple autocomplete widget.
+ *
+ * @module lazr.autocomplete
+ * @namespace lazr
+ */
+
+Y.namespace('lazr');
+
+
+var AUTOCOMP     = 'autocomplete',
+    BOUNDING_BOX = 'boundingBox',
+    CONTENT_BOX  = 'contentBox',
+
+    INPUT   = 'input',
+    VALUE   = 'value',
+    QUERY   = 'query',
+    DATA    = 'data',
+    MATCHES = 'matches',
+    RENDERED = 'rendered',
+    DELIMITER = 'delimiter',
+
+    TAB = 9,
+    RETURN = 13,
+    ESCAPE = 27,
+    ARROW_DOWN = 40,
+
+    getCN = Y.ClassNameManager.getClassName,
+
+    C_LIST = getCN(AUTOCOMP, 'list');
+
+
+// We need a base class on which to build our autocomplete widget, so we will
+// make that class capable of positioning itself, too.
+var AutoCompleteBase = Y.Base.build("AutoCompleteBase", Y.Widget, [Y.WidgetStack]);
+
+
+/**
+ * A simple autocomplete widget.
+ *
+ * @class AutoComplete
+ */
+
+function AutoComplete() {
+    AutoComplete.superclass.constructor.apply(this, arguments);
+}
+
+AutoComplete.NAME = 'autocomplete';
+
+AutoComplete.LIST_TEMPLATE = '<ul></ul>';
+AutoComplete.ITEM_TEMPLATE = '<li class="item yui3-menuitem"></li>';
+AutoComplete.ITEM_CONTENT_TEMPLATE = '<a href="#" class="yui3-menuitem-content"></a>';
+
+AutoComplete.ATTRS = {
+    /**
+     * The autocomplete data that we will be filtering to find matching
+     * results.
+     *
+     * @attribute data
+     * @type Hash
+     * @default Obj
+     */
+    data: {
+        valueFn: function() { return {}; }
+    },
+
+    /**
+     * The delimiter to use when splitting the user's current input into
+     * matchable query strings.
+     *
+     * @attribute delimiter
+     * @type String
+     * @default ' '
+     */
+    delimiter: {
+        value: ' '
+    },
+
+    /**
+     * The current subset of data matching the user's query, ordered by
+     * accuracy.  Contains an Array of hash objects; see
+     * <code>filterResults</code>' return type for the details.
+     *
+     * @attribute matches
+     * @type Array
+     * @default []
+     */
+    matches: {
+        valueFn: function() { return []; }
+    },
+
+    /**
+     * The DOM element we watch for new input.  May be set with a Node,
+     * HTMLElement, or CSS selector.  Setting this aligns the widget's
+     * position.
+     *
+     * @attribute input
+     * @type Node
+     * @default null
+     */
+    input: {
+        value: null,
+        setter: function(val) {
+            return this._setInput(val);
+        }
+    },
+
+    /**
+     * The user's current query.  Contains a hash of values containing the
+     * current query text, and the query offset.  <code>null</code> if the
+     * widget doesn't contain a valid query.
+     *
+     * See the <code>parseQuery</code> method for the hash details.
+     *
+     * @attribute query
+     * @type Object
+     * @default null
+     */
+    query: {
+        value: null
+    }
+};
+
+Y.extend(AutoComplete, AutoCompleteBase, {
+
+    /**
+     * The <ul> containing the current list of completions.  May be null.
+     *
+     * @property _completions
+     * @private
+     */
+    _completions: null,
+
+    /**
+     * Flag to indicate that the user just completed a string
+     *
+     * @property _last_input_was_completed
+     * @private
+     */
+    _last_input_was_completed: false,
+
+    /**
+     * Initialize the widget.
+     *
+     * @method initializer
+     * @protected
+     */
+    initializer: function() {
+        // The widget starts out hidden.
+        this.hide();
+
+        // XXX mars 2009-03-30 bug=352022
+        // Disable the widget in Opera, as the NodeMenuNav plugin we use for
+        // the autocomplete is so slow as to be broken.
+        // As for IE, well, it's the usual story.
+        if (Y.UA.opera || Y.UA.ie) {
+            this.disable();
+            // Also disable rendering using a NOP function.
+            this.render = function() {};
+        }
+    },
+
+    /**
+     * Destroy the widget.
+     *
+     * @method destructor
+     * @protected
+     */
+    destructor: function() {
+        // Detach our keyboard input listener
+        var input = this.get('INPUT');
+        if (input && this.get(RENDERED)) {
+            input.detach('keydown', this._onInputKeydown);
+            input.detach('keyup', this._onInputKeyup);
+        }
+    },
+
+    /**
+     * Render the DOM and position the widget.
+     *
+     * @method renderUI
+     * @protected
+     */
+    renderUI: function() {
+        var input = this.get(INPUT);
+        var bounding_box = this.get(BOUNDING_BOX);
+        // Needed by the NodeMenuNav plugin
+        bounding_box.addClass("yui3-menu");
+        // Move ourself into position below the document body.  This is
+        // necessary so that the absolute widget positioning code sets
+        // the correct coordinates.
+        Y.one('body').appendChild(bounding_box);
+        this.get(CONTENT_BOX)
+            .setStyle('minWidth', input.get('offsetWidth') + "px")
+            .addClass('yui3-menu-content');
+
+        // Set the correct absolute coordinates on-screen.  Bypass the
+        // Widget.move() function, since it incorrectly positions the element
+        // relative to the viewportal scroll.
+        var iregion = input.get('region');
+        bounding_box.setStyles({
+            'left': iregion.left   + 'px',
+            'top':  iregion.bottom + 'px'
+        });
+    },
+
+    /**
+     * Render the completions list.  Swaps out the existing list if one is
+     * already present.
+     *
+     * @method _renderCompletions
+     * @param query {String} The user's current query, used for formatting.
+     * @protected
+     */
+    _renderCompletions: function(query) {
+        var matches = this.get(MATCHES);
+        if (!this.get(RENDERED) || !matches) {
+            // Skip lots of rendering work, because if there are no matches,
+            // then the autocomplete list will be hidden.
+            return;
+        }
+
+        var list = Y.Node.create(AutoComplete.LIST_TEMPLATE);
+        list.addClass(C_LIST);
+
+        var result;
+        var item;
+        var match;
+        for (var idx = 0; idx < matches.length; idx++) {
+            match  = matches[idx];
+            result = this.formatResult(match.text, query, match.offset);
+            item   = this._renderCompletion(result, idx);
+            list.appendChild(item);
+        }
+
+        var cbox = this.get(CONTENT_BOX);
+
+        this.get(BOUNDING_BOX).unplug(Y.Plugin.NodeMenuNav);
+
+        if (this._completions) {
+            cbox.replaceChild(list, this._completions);
+        } else {
+            cbox.appendChild(list);
+        }
+
+        // Re-plug the MenuNav, so it updates the menu options.
+        this.get(BOUNDING_BOX).plug(Y.Plugin.NodeMenuNav);
+
+        // Highlight the first item.
+        this._selectItem(0, false);
+
+        this._completions = list;
+    },
+
+    /**
+     * Render a completion list item.
+     *
+     * @method _renderCompletion
+     * @protected
+     * @param html_content {String} The completion's HTML text content.
+     * @param item_index {NUM} The index of this completion item in the list.
+     * @return {Node} The new list item.
+     */
+    _renderCompletion: function(html_content, item_index) {
+        var item = Y.Node.create(AutoComplete.ITEM_TEMPLATE);
+        item.setAttribute('id', this._makeItemID(item_index));
+
+        var link = Y.Node.create(AutoComplete.ITEM_CONTENT_TEMPLATE);
+        link.set('innerHTML', html_content);
+        item.appendChild(link);
+
+        return item;
+    },
+
+    /**
+     * Generate a new item identifier string for a given item index.
+     *
+     * @method _makeItemID
+     * @protected
+     * @param index {NUM} The index of the item in the matches list.
+     * @return {String} The generated ID.
+     */
+    _makeItemID: function(index) {
+        return 'item' + index;
+    },
+
+    /**
+     * Retrieve the given item node's index in the match list.
+     *
+     * @method _indexForItem
+     * @protected
+     * @param item {Node} The item node to retrieve the index from.
+     * @return {NUM} The index as an integer, null if the index couldn't
+     *   be retrieved.
+     */
+    _indexForItem: function(item) {
+        var id = parseInt(item.getAttribute('id').replace('item', ''), 10);
+        return Y.Lang.isNumber(id) ? id : null;
+    },
+
+    /**
+     * Bind the widget to the DOM.
+     *
+     * @method bindUI
+     * @protected
+     */
+    bindUI: function() {
+        // Save the handle so we can detach it later.
+        var input = this.get(INPUT);
+        input.on('keydown', this._onInputKeydown, this);
+        input.on('keyup',   this._onInputKeyup,   this);
+        this.get('contentBox').on('click', this._onListClick, this);
+    },
+
+    /**
+     * Parse the user's input, returning the specific query string to be
+     * matched.  Returns null if the query is empty (with no characters typed
+     * yet).
+     *
+     * @method parseQuery
+     * @public
+     * @param input {String} The textbox input to be parsed.
+     * @param caret_pos {NUM} Optional: the position of the caret.  Defaults
+     *   to the end of the string.
+     * @return {Object} A hash containing:
+     *   <dl>
+     *     <dt>text</dt><dd>The query text</dd>
+     *     <dt>offset</dt><dd>The starting index of the query in the input</dd>
+     *   </dl>.
+     *   Returns <code>null</code> if the query couldn't be parsed.
+     */
+    parseQuery: function(input, caret_pos) {
+        if (caret_pos <= 0) {
+            // The caret is as the start of the input field, so no query
+            // is possible.
+            return null;
+        }
+
+        if (!Y.Lang.isNumber(caret_pos) || (caret_pos > input.length)) {
+            caret_pos = input.length;
+        }
+
+        var delimiter = this.get(DELIMITER);
+
+        // Start searches at the character before the cursor in the string.
+        var start = input.lastIndexOf(delimiter, caret_pos - 1);
+        var end = input.indexOf(delimiter, caret_pos - 1);
+
+        if ((start == end) && (start != -1)) {
+            // The caret was on the delimiter itself.
+            return null;
+        }
+
+        if (start == -1) {
+            // There wasn't a delimiter between the caret and the start of the
+            // string.
+            start = 0;
+        } else {
+            // Move one character past the delimiter
+            start++;
+        }
+
+        if (end == -1) {
+            // There wasn't a delimiter between the caret and the end of the
+            // string.
+            end = input.length;
+        }
+
+        // Strip any leading whitespace.
+        while ((input[start] == ' ' || input[start] == '\t') && (start <= end))
+        {
+            start++;
+        }
+
+        if (start == end) {
+            // The whitespace stripping took us to the end of the input.
+            return null;
+        }
+
+        var query = {
+            text:   input.substring(start, end),
+            offset: start
+        };
+        return query;
+    },
+
+    /**
+     * Find inputs matching the user query and update the <em>matches<em>
+     * attribute with the result.
+     *
+     * @method findMatches
+     * @public
+     * @param query {String} The user query we want to find matches for.
+     * @return The array of matches, or an empty array if no results were
+     *     found.
+     */
+    findMatches: function(query) {
+        var matches = this.filterResults(this.get(DATA), query);
+        this.set(MATCHES, matches);
+        return matches;
+    },
+
+    /**
+     * Filter the widget's data set down to the matching results.
+     *
+     * The returned list of matches is in order of priority.
+     *
+     * The default implementation puts the matches closest to the front of the
+     * user query first.  Matches are case-insensitive.
+     *
+     * @method filterResults
+     * @public
+     * @param results {Array} The data to filter
+     * @param query {String} The user's current query
+     * @return Array of filtered and ordered match objects.  Each match object
+     *     has the following keys:
+     *     <dl>
+     *       <dt>text</dt>
+     *       <dd>The query text</dd>
+     *       <dt>offset</dt>
+     *       <dd>The starting index of the query in the input</dd>
+     *     </dl>
+     */
+    filterResults: function(data, query) {
+        // Find matches and push them into an array of arrays.  The array
+        // is indexed by the start of the match.
+
+        var midx;
+        var match_key;
+        var match_string;
+        var start_indicies = [];
+
+        var lowercase_query = query.toLowerCase();
+
+        if (data) {
+            Y.Array.each(data, function(match_key) {
+
+                match_string = match_key.toString();
+                midx = match_string.toLowerCase().indexOf(lowercase_query);
+
+                if (midx > -1) {
+                    if (!start_indicies[midx]) {
+                        start_indicies[midx] = [];
+                    }
+                    start_indicies[midx].push(match_string);
+                }
+            });
+        }
+
+        // Flatten the array of match indicies.  Matches close to the front
+        // of the user query have a higher priority, and come first in the
+        // list of matches.  Matches farther toward the end coming later.
+        var matches = [];
+        var match_set;
+        for (var index = 0; index < start_indicies.length; index++) {
+            match_set = start_indicies[index];
+            if (match_set) {
+                Y.Array.each(match_set, function(match) {
+                    matches.push({text: match, offset: index});
+                });
+            }
+        }
+
+        return matches;
+    },
+
+    /**
+     * Format a possible completion for display.
+     *
+     * The returned string will appear as a list item's contents.
+     *
+     * @method formatResult
+     * @public
+     * @param result {String} The result data to format.
+     * @param query {String} The user's current query.
+     * @param offset {NUM} The offset of the matching text in the result.
+     * @return {String} The HTML to be displayed.
+     */
+    formatResult: function(result, query, offset) {
+        return this.markMatchingText(result, query, offset);
+    },
+
+    /**
+     * Mark the portion of a result that matches the user query.
+     *
+     * @method markMatchingText
+     * @public
+     * @param text {String} The completion result text to be marked.
+     * @param query {String} The user query string.
+     * @param offset {NUM} The offset of the query in the text.
+     * @return {String} The modified text.
+     */
+    markMatchingText: function(text, query, offset) {
+        var start = offset;
+        if (start < 0 || !query) {
+            return text;
+        }
+
+        var end = start + query.length;
+
+        var before = text.substring(0, start);
+        var match  = text.substring(start, end);
+        var after  = text.substring(end);
+
+        // This is ugly, but I can't see a better way to do it at the moment.
+        match = '<span class="matching-text">' + match + '</span>';
+
+        return before + match + after;
+    },
+
+    /**
+     * Complete the user's input using the item currently selected in the
+     * completions list, or the first item if no list item was picked.
+     *
+     * @method completeInput
+     * @public
+     */
+    completeInput: function() {
+        var active_item = this.getActiveItem();
+        if (active_item) {
+            var item_index = this._indexForItem(active_item);
+            if (item_index !== null) {
+                this.completeInputUsingItem(item_index);
+            }
+        } else {
+            // Select the first item in the list
+            this.completeInputUsingItem(0);
+        }
+        this.get(INPUT).focus();
+        this._last_input_was_completed = true;
+    },
+
+    /**
+     * Completes the user's input using the specified match number.
+     *
+     * @method completeInputUsingItem
+     * @public
+     * @param match_num {NUM} The number of the match to select.
+     */
+    completeInputUsingItem: function(match_num) {
+        var matches = this.get(MATCHES);
+        if (matches.length === 0) {
+            return;
+        }
+
+        if (match_num >= matches.length) {
+            Y.fail("Failed to complete item number " + match_num +
+                " because there are only " + matches.length + " matches " +
+                "available.");
+            return;
+        }
+
+        var completion_txt = matches[match_num].text;
+        var query = this.get(QUERY);
+        var delimiter = this.get(DELIMITER);
+        var input = this.get(INPUT);
+        var input_txt = input.get('value');
+
+        // Drop the current query from the input string.
+        var query_end = query.offset + query.text.length;
+        var input_head = input_txt.substring(0, query.offset);
+        var input_tail = input_txt.substring(query_end, input_txt.length);
+        var tail_delimiter = delimiter;
+        // Add the delimiter only if it's needed.
+        if (input_tail.charAt(input_tail.length - 1) == delimiter) {
+            tail_delimiter = '';
+        }
+
+        var new_input = [
+            input_head, completion_txt, input_tail, tail_delimiter].join('');
+
+        input.set(VALUE, new_input);
+        this.hide();
+    },
+
+    /**
+     * Return the currently selected item in the completions list.
+     *
+     * @method getActiveItem
+     * @public
+     * @return {Node} The selected item node, or null if no item is active.
+     */
+    getActiveItem: function() {
+        // It is ugly to have to check protected members of the menu
+        // like this, but the 'currently selected item' should
+        // really be public, don't you think?
+        var menu = this.get(BOUNDING_BOX).menuNav;
+        if (menu) {
+            return menu._activeItem ? menu._activeItem : null;
+        }
+        return null;
+    },
+
+    /**
+     * Select the Nth item in the completions list.
+     *
+     * @method _selectItem
+     * @protected
+     * @param index {NUM} The index of the item to select.
+     * @param set_focus {Boolean} Set this to true if the selected item should
+     * also recieve the keyboard focus.
+     * @return {Node} The item that was selected, or null if it could not
+     * be found.
+     */
+    _selectItem: function(index, set_focus) {
+        var menu = this.get(BOUNDING_BOX).menuNav;
+
+        // More ugliness, looking at protected object members that should
+        // be made public.
+        var firstItem = menu._rootMenu.all('.yui3-menuitem').item(0)
+        var item = menu ? firstItem : null;
+        if (!menu || !item) {
+            return null;
+        }
+
+        for (var idx = 0; idx < index; idx++) {
+            item = item.next();
+            if (!item) {
+                return null;
+            }
+        }
+
+        if (set_focus) {
+            // We need an anchor to focus on, because some browsers (IE, ahem)
+            // don't like focusing non-anchor things.
+            var anchor = item.one('a');
+
+            menu._focusManager.set("activeDescendant", anchor);
+            menu._focusItem(item);
+
+            if (anchor) {
+                // Use a 5ms timer to give the browser rendering engine some
+                // time to catch up to the JS call, and prevent a race
+                // condition with the focus() method.
+                Y.later(5, anchor, anchor.focus);
+            }
+        }
+        menu._setActiveItem(item);
+        return item;
+    },
+
+    /**
+     * Set the autocomplete's <input> element, and align the autocomplete
+     * widget's position to it.
+     *
+     * @method _setInput
+     * @protected
+     * @param node {Node|HTMLElement|Selector} The input node.
+     * @return {Node} A Node instance, or null if the requested input node
+     * could not be found.
+     */
+    _setInput: function(elem) {
+        var node = Y.one(elem);
+        if (node === null) {
+            return null;
+        }
+
+        // We need to calculate the input area's caret position.
+        Y.augment(node, Y.lazr.NodeCaretPos);
+
+        // Align our position to the input element.
+        //~ this.set('align', {
+            //~ node: node,
+            //~ points: [Y.WidgetPositionExt.TL, Y.WidgetPositionExt.BL]
+        //~ });
+
+        return node;
+    },
+
+    /**
+     * Handle new text inputs.
+     *
+     * @method _onInputKeyup
+     * @protected
+     * @param e {Event.Custom} The event object.
+     */
+    _onInputKeyup: function(e) {
+        var input = this.get(INPUT);
+        var caret_pos = null;
+
+        if (input.getCaretPos !== undefined) {
+            caret_pos = input.getCaretPos();
+        }
+
+        var query = this.parseQuery(input.get(VALUE), caret_pos);
+        this.set(QUERY, query);
+
+        if (e.keyCode === ESCAPE ||
+            e.keyCode === RETURN ||
+            e.keyCode === TAB ||
+            e.keyCode === ARROW_DOWN) {
+            // We don't want to re-display the matches list.
+            return;
+        }
+
+        if (query === null) {
+            // No valid user input yet
+            this._last_input_was_completed = false;
+            this.hide();
+            return;
+        }
+
+        if (this.findMatches(query.text).length !== 0) {
+            this._renderCompletions(query.text);
+            this._last_input_was_completed = false;
+            this.show();
+        } else {
+            this.hide();
+        }
+    },
+
+    /**
+     * Handle presses of keys like Tab and Enter
+     *
+     * @method _onInputKeydown
+     * @protected
+     * @param e {Event.Custom} The event object.
+     */
+    _onInputKeydown: function(e) {
+        // Is this one of our completion keys; Tab, or Enter?
+        if (e.keyCode === TAB || e.keyCode === RETURN) {
+            /* Check that the last string was not completed and that there are  
+               matching queries (we don't want to try and complete the input if 
+               there are no matches). */
+            if (this.get(QUERY) !== null && !this._last_input_was_completed && this.findMatches(this.get(QUERY).text).length !== 0) {
+                // The user has an active query in the input box.
+                this.completeInput();
+                // Keep the tab key from switching focus away from the input
+                // field.
+                e.preventDefault();
+            }
+        } else if (e.keyCode === ESCAPE) {
+            // Escape closes the currently displayed results
+            this.hide();
+        } else if (e.keyCode === ARROW_DOWN) {
+            this._selectItem(1, true);
+            // Prevent the browser from scrolling the window.
+            e.preventDefault();
+        }
+    },
+
+    /**
+     * Handle clicks on the autocomplete widget list.
+     *
+     * @method _onListClick
+     * @protected
+     * @param e {Event.Custom} The event object.
+     */
+    _onListClick: function(e) {
+        this.completeInput();
+        e.preventDefault();
+    }
+});
+
+
+Y.lazr.AutoComplete = AutoComplete;
+
+
+/**
+ * A mixin class for calculating the caret position inside a Node
+ * instance.
+ *
+ * @class NodeCaretPos
+ */
+
+Y.lazr.NodeCaretPos = function() {};
+
+/**
+ * Return the offset of the caret in a text field.
+ *
+ * @method getCaretPos
+ * @public
+ * @return {NUM} The distance from the start of the field to the caret, or
+ *     null if the position couldn't be calculated.
+ */
+Y.lazr.NodeCaretPos.prototype.getCaretPos = function() {
+    var elem = Y.Node.getDOMNode(this);
+    if (Y.UA.ie) {
+        if (document.selection) {
+            var range = document.selection.createRange();
+            range.moveToElementText(elem);
+            return range.text.length;
+        }
+    } else if (typeof elem.selectionEnd != "undefined") {
+        return elem.selectionEnd;
+    }
+    return null;
+};
+
+
+}, "0.1", {"skinnable": true, "requires":["oop", "base", "event", "widget",
+                                          "widget-stack", "node-menunav"]});

=== added directory 'lib/lp/app/javascript/lazr/autocomplete/tests'
=== added file 'lib/lp/app/javascript/lazr/autocomplete/tests/autocomplete.js'
--- lib/lp/app/javascript/lazr/autocomplete/tests/autocomplete.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/autocomplete/tests/autocomplete.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,570 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.autocomplete', 'lazr.testing.runner',
+          'node', 'event', 'console', function(Y) {
+
+/*****************************
+ *
+ *  Helper methods and aliases
+ *
+ */
+var Assert = Y.Assert;
+
+/* Helper function to clean up a dynamically added widget instance. */
+function cleanup_widget(widget) {
+    // Nuke the boundingBox, but only if we've touched the DOM.
+    if (widget.get('rendered')) {
+        var bb = widget.get('boundingBox');
+        bb.get('parentNode').removeChild(bb);
+    }
+    // Kill the widget itself.
+    widget.destroy();
+}
+
+/* A helper to create a simple text input box */
+function make_input(value) {
+    var input = document.createElement('input');
+    input.setAttribute('type', 'text');
+    input.setAttribute('value', value || '');
+    Y.one('body').appendChild(input);
+    return input;
+}
+
+/* A helper to destroy a generic input: make_input()'s inverse */
+function kill_input(input) {
+    Y.one('body').removeChild(input);
+}
+
+
+/****************************
+ *
+ *  Tests
+ *
+ */
+
+var suite = new Y.Test.Suite('autocomplete Test Suite');
+
+
+suite.add(new Y.Test.Case({
+
+    name:'test widget setup',
+
+    setUp: function() {
+        this.input = make_input();
+    },
+
+    tearDown: function() {
+        kill_input(this.input);
+    },
+
+    test_widget_starts_hidden: function() {
+        var autocomp = new Y.lazr.AutoComplete({ input: this.input });
+        autocomp.render();
+        Assert.isFalse(
+            autocomp.get('visible'),
+            "The widget should start out hidden.");
+    }
+}));
+
+
+suite.add(new Y.Test.Case({
+
+    name:'test display of matching results',
+
+    setUp: function() {
+        this.input = make_input();
+        this.autocomp = new Y.lazr.AutoComplete({
+            input: this.input
+        });
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.autocomp);
+        kill_input(this.input);
+    },
+
+    /* A helper to option the completions list for a given input string. */
+    complete_input: function(value) {
+        this.input.value = value;
+        var last_charcode = value.charCodeAt(value.length - 1);
+        Y.Event.simulate(this.input, 'keyup', { keyCode: last_charcode });
+    },
+
+    /* Extract the matching text from the widget's autocompletion list. */
+    get_completions: function() {
+        if (!this.autocomp.get('rendered')) {
+            Y.fail("Tried find matches for an unrendered widget.");
+            return;
+        }
+
+        var matches = [];
+        this.autocomp
+            .get('boundingBox')
+            .all('.item')
+            .each(function(item) {
+                matches.push(item.get('text'));
+            });
+        return matches;
+    },
+
+    test_autocomplete_is_visible_if_results_match: function() {
+        this.autocomp.set('data', ['aaa']);
+        this.autocomp.render();
+
+        // We want to match the one and only data set element.
+        this.complete_input('aa');
+        Assert.isTrue(
+            this.autocomp.get('visible'),
+            "The widget should be visible if matching input was found.");
+    },
+
+    test_autocomplete_is_hidden_if_no_query_is_given: function() {
+        this.autocomp.set('data', ['aaa']);
+        this.autocomp.render();
+
+        // We want to simulate an empty input field, but some action triggers
+        // matching.
+        this.complete_input('');
+        Assert.isFalse(
+            this.autocomp.get('visible'),
+            "The widget should be hidden if the input field is empty.");
+    },
+
+    test_autocomplete_is_hidden_if_results_do_not_match: function() {
+        this.autocomp.set('data', ['bbb']);
+        this.autocomp.render();
+
+        if (this.autocomp.get('visible')) {
+            Y.fail("The autocomplete widget should start out hidden.");
+        }
+
+
+        // 'aa' shouldn't match any of the data.
+        this.complete_input('aa');
+        Assert.isFalse(
+            this.autocomp.get('visible'),
+            "The widget should be hidden if the query doesn't match any " +
+            "possible completions.");
+    },
+
+    test_display_should_contain_all_matches: function() {
+        var data = [
+            'aaa',
+            'baa'
+        ];
+
+        this.autocomp.set('data', data);
+        this.autocomp.render();
+
+        // Trigger autocompletion, should match all data items.
+        this.complete_input('aa');
+
+        // Grab the now-open menu
+        var option_list = Y.one('.yui3-autocomplete-list');
+        Assert.isObject(option_list,
+            "The list of completion options should be open.");
+
+        Y.ArrayAssert.itemsAreEqual(
+            this.get_completions(),
+            data,
+            "Every autocomplete item should be present in the available " +
+            "match keys.");
+    },
+
+    test_display_is_updated_with_new_completions: function() {
+        // Create two pieces of data, each narrower than the other.
+        this.autocomp.set('data', ['aaa', 'aab']);
+        this.autocomp.render();
+
+        // Trigger autocompletion for the loosest matches
+        this.complete_input('aa');
+        // Complete the narrower set
+        this.complete_input('aaa');
+
+        var completions = this.get_completions();
+
+        Y.ArrayAssert.itemsAreEqual(
+            ['aaa'],
+            completions,
+            "'aaa' should be the data item displayed after narrowing the " +
+            "search with the query 'aaa'.");
+    },
+
+    test_matching_text_in_item_is_marked: function() {
+        this.autocomp.set('data', ['aaa']);
+        this.autocomp.render();
+
+        // Display the matching input.
+        var query = 'aa';
+        this.complete_input(query);
+
+        // Grab the matching item
+        var matching_text = this.autocomp
+            .get('boundingBox')
+            .one('.item .matching-text');
+
+        Assert.isNotNull(matching_text,
+            "Some of the matching item's text should be marked as matching.");
+
+        Assert.areEqual(
+            query,
+            matching_text.get('text'),
+            "The matching text should be the same as the query text.");
+    },
+
+    test_escape_key_should_close_completions_list: function() {
+        this.autocomp.set('data', ['aaa']);
+        this.autocomp.render();
+
+        // Open the completions list
+        this.complete_input('aa');
+
+        // Hit the escape key to close the list
+        Y.Event.simulate(this.input, 'keydown', { keyCode: 27 });
+
+        Assert.isFalse(
+            this.autocomp.get('visible'),
+            "The list of completions should be closed after pressing the " +
+            "escape key.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name:'test result text marking method',
+
+    test_match_at_beginning_should_be_marked: function() {
+        var autocomp    = new Y.lazr.AutoComplete();
+        var marked_text = autocomp.markMatchingText('aabb', 'aa', 0);
+
+        Assert.areEqual(
+            '<span class="matching-text">aa</span>bb',
+            marked_text,
+            "The text at the beginning of the result should have been " +
+            "marked.");
+    },
+
+    test_match_in_middle_should_be_marked: function() {
+        var autocomp    = new Y.lazr.AutoComplete();
+        var marked_text = autocomp.markMatchingText('baab', 'aa', 1);
+
+        Assert.areEqual(
+            'b<span class="matching-text">aa</span>b',
+            marked_text,
+            "The text in the middle of the result should have been " +
+            "marked.");
+    },
+
+    test_match_at_end_should_be_marked: function() {
+        var autocomp    = new Y.lazr.AutoComplete();
+        var marked_text = autocomp.markMatchingText('bbaa', 'aa', 2);
+
+        Assert.areEqual(
+            'bb<span class="matching-text">aa</span>',
+            marked_text,
+            "The text at the end of the result should have been " +
+            "marked.");
+    }
+}));
+
+
+suite.add(new Y.Test.Case({
+
+    name:'test query parsing',
+
+    setUp: function() {
+        this.autocomplete = new Y.lazr.AutoComplete({
+            delimiter: ' '
+        });
+    },
+
+    test_space_for_delimiter: function() {
+        Assert.areEqual(
+            'b',
+            this.autocomplete.parseQuery('a b').text,
+            "Input should be split around the 'space' character.");
+        Assert.isNull(
+            this.autocomplete.parseQuery(' '),
+            "Space for input and delimiter should not parse.");
+    },
+
+    test_parsed_query_is_stripped_of_leading_whitespace: function() {
+        this.autocomplete.set('delimiter', ',');
+
+        Assert.areEqual(
+            'a',
+            this.autocomplete.parseQuery(' a').text,
+            "Leading whitespace at the start of the input string should " +
+            "be stripped.");
+
+        Assert.areEqual(
+            'b',
+            this.autocomplete.parseQuery('a, b').text,
+            "Leading whitespace between the last separator and the current " +
+            "query should be stripped.");
+    },
+
+    test_query_is_taken_from_middle_of_input: function() {
+        // Pick a caret position that is in the middle of the second result.
+        var input = "aaa bbb ccc";
+        var caret = 6;
+
+        Assert.areEqual(
+            'bbb',
+            this.autocomplete.parseQuery(input, caret).text,
+            "The current query should be picked out of the middle of the " +
+            "text input if the caret has been positioned there.");
+    },
+
+    test_query_is_taken_from_beginning_of_input: function() {
+        // Pick a caret position that is in the first input's query
+        var input = "aaa bbb";
+        var caret = 2;
+
+        Assert.areEqual(
+            'aaa',
+            this.autocomplete.parseQuery(input, caret).text,
+            "The first block of text should become the current query if " +
+            "the caret is positioned within it.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name:'test results matching algorithm',
+
+    /* A helper function to determine if two match result items are equal */
+    matches_are_equal: function(a, b) {
+        if (typeof a == 'undefined') {
+            Assert.fail("Match set 'a' is of type 'undefined'!");
+        }
+        if (typeof b == 'undefined') {
+            Assert.fail("Match set 'b' is of type 'undefined'!");
+        }
+        return (a.text == b.text) && (a.offset == b.offset);
+    },
+
+    test_no_matches_returns_an_empty_array: function() {
+        var autocomplete = new Y.lazr.AutoComplete({
+            data: ['ccc']
+        });
+
+        var matches = autocomplete.findMatches('aa');
+        Y.ArrayAssert.isEmpty(matches,
+            "No data should have matched the query 'aa'");
+    },
+
+    test_match_last_item: function() {
+        var autocomplete = new Y.lazr.AutoComplete({
+            data: [
+                'ccc',
+                'bbb',
+                'aaa'
+            ]
+        });
+
+        var matches = autocomplete.findMatches('aa');
+
+        Y.ArrayAssert.itemsAreEquivalent(
+            [{text: 'aaa', offset: 0}],
+            matches,
+            this.matches_are_equal,
+            "One row should have matched the query 'aa'.");
+    },
+
+    test_match_ordering: function() {
+        // Matches, in reverse order.
+        var autocomplete = new Y.lazr.AutoComplete({
+            data: [
+                'bbaa',
+                'baab',
+                'aabb'
+            ]
+        });
+
+        var matches = autocomplete.findMatches('aa');
+
+        Y.ArrayAssert.itemsAreEquivalent(
+            [{text: 'aabb', offset: 0},
+             {text: 'baab', offset: 1},
+             {text: 'bbaa', offset: 2}],
+            matches,
+            this.matches_are_equal,
+            "The match array should have all of it's keys in order.");
+    },
+
+    test_mixed_case_text_matches: function() {
+        var autocomplete = new Y.lazr.AutoComplete({
+            data: ['aBc']
+        });
+
+        var matches = autocomplete.findMatches('b');
+
+        Y.ArrayAssert.itemsAreEquivalent(
+            [{text:'aBc', offset: 1}],
+            matches,
+            this.matches_are_equal,
+            "The match algorithm should be case insensitive.");
+    },
+
+    test_mixed_case_matches_come_in_stable_order: function() {
+        // Data with the mixed-case coming first in order.
+        var autocomplete = new Y.lazr.AutoComplete({
+            data: ['aBc', 'aaa', 'abc']
+        });
+
+        var matches = autocomplete.findMatches('b');
+
+        Y.ArrayAssert.itemsAreEquivalent(
+            [{text: 'aBc', offset: 1},
+             {text: 'abc', offset: 1}],
+            matches,
+            this.matches_are_equal,
+            "Mixed-case matches should arrive in stable order.");
+    }
+}));
+
+
+suite.add(new Y.Test.Case({
+
+    name:'test selecting results',
+
+    setUp: function() {
+        this.input = make_input();
+        this.autocomp = new Y.lazr.AutoComplete({
+            input: this.input
+        });
+        this.autocomp.render();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.autocomp);
+        kill_input(this.input);
+    },
+
+    /* A helper to option the completions list for a given input string. */
+    complete_input: function(value) {
+        this.input.value = value;
+        var last_charcode = value.charCodeAt(value.length - 1);
+        Y.Event.simulate(this.input, 'keyup', { keyCode: last_charcode });
+    },
+
+    /* A helper to select the selected completion result with the Tab key. */
+    press_selection_key: function() {
+        Y.Event.simulate(this.input, "keydown", { keyCode: 9 });
+    },
+
+    test_pressing_enter_completes_current_input: function() {
+        this.autocomp.set('data', ['aaaa', 'aabb']);
+
+        // Open the completion options
+        this.complete_input('aa');
+
+        // Press 'Enter'
+        Y.Event.simulate(this.input, "keydown", { keyCode: 13 });
+
+        Assert.areEqual(
+            'aaaa ',
+            this.input.value,
+            "The first completion should have been appended to the input's " +
+            "value after pressing the 'Enter' key.");
+    },
+
+    test_pressing_tab_completes_current_input: function() {
+        this.autocomp.set('data', ['aaaa', 'aabb']);
+
+        // Open the completion options
+        this.complete_input('aa');
+
+        // Press 'Tab'
+        Y.Event.simulate(this.input, "keydown", { keyCode: 9 });
+
+        Assert.areEqual(
+            'aaaa ',
+            this.input.value,
+            "The first completion should have been appended to the input's " +
+            "value after pressing the 'Enter' key.");
+    },
+
+    test_clicking_on_first_result_completes_input: function() {
+        this.autocomp.set('data', ['aaaa', 'aabb']);
+        this.complete_input('aa');
+
+        // Click on the first displayed result
+        var options = this.autocomp.get('contentBox').all('.item');
+        var first_item = Y.Node.getDOMNode(options.item(0));
+        Y.Event.simulate(first_item, 'click');
+
+        Assert.areEqual(
+            'aaaa ',
+            this.input.value,
+            "The first completion should have been appended to the input's " +
+            "value after clicking it's list node.");
+    },
+
+    test_selecting_results_hides_completion_list: function() {
+        this.autocomp.set('data', 'aaa');
+        this.complete_input('a');
+        this.press_selection_key();
+
+        Assert.isFalse(
+            this.autocomp.get('visible'),
+            "The completion list should be hidden after a result is " +
+            "selected.");
+    },
+
+    test_completed_input_replaces_current_input: function() {
+        this.autocomp.set('data', ['abba']);
+
+        // Match the one and only result, but match the second character in
+        // it.  Throw in some pre-existing user input just to be sure things
+        // work.
+        this.complete_input('xxx b');
+        this.press_selection_key();
+
+        Assert.areEqual(
+           'xxx abba ',
+           this.input.value,
+           "The user's current query should have been replaced with the " +
+           "selected value.");
+    },
+
+    test_completed_input_has_delimiter_appended_to_it: function() {
+        var delimiter = ' ';
+        this.autocomp.set('data', ['aaaa']);
+        this.autocomp.set('delimiter', delimiter);
+
+        this.complete_input('a');
+        this.press_selection_key();
+
+        Assert.areEqual(
+            delimiter,
+            this.input.value.charAt(this.input.value.length - 1),
+            "The last character of the input should be the current " +
+            "query delimiter.");
+    },
+
+    test_down_arrow_selects_second_result_in_list: function() {
+        this.autocomp.set('data', ['first_item', 'second_item']);
+
+        // Match the first result.  It should be selected by default.
+        this.complete_input('item');
+
+        // Simulate pressing the down arrow key.
+        Y.Event.simulate(this.input, 'keydown', { keyCode: 40 });
+
+        // Now, select the second result.
+        this.press_selection_key();
+
+        Assert.areEqual(
+            'second_item ',
+            this.input.value,
+            "Pressing the down-arrow key should select the second option " +
+            "in the completions list.");
+    }
+}));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added file 'lib/lp/app/javascript/lazr/autocomplete/tests/index.html'
--- lib/lp/app/javascript/lazr/autocomplete/tests/index.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/autocomplete/tests/index.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,30 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>autocomplete unit tests</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../autocomplete.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="autocomplete.js"></script>
+
+</head>
+<body class="yui3-skin-sam">
+
+  <!-- Widget markup goes here... -->
+
+  <div id="log"></div>
+</body>
+</html>

=== added directory 'lib/lp/app/javascript/lazr/choiceedit'
=== added directory 'lib/lp/app/javascript/lazr/choiceedit/assets'
=== added file 'lib/lp/app/javascript/lazr/choiceedit/assets/choiceedit-core.css'
--- lib/lp/app/javascript/lazr/choiceedit/assets/choiceedit-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/choiceedit/assets/choiceedit-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,43 @@
+.yui3-ichoicelist span.disabled {
+  color: #ccc;
+}
+.yui3-ichoicelist span.current, .yui3-ichoicelist a:active {
+  font-weight: bold;
+  background-color: #eee;
+  color: inherit;
+}
+.yui3-ichoicelist ul, .yui3-ichoicelist #yui3-pretty-overlay-modal h2 {
+  padding: 0 10px;
+  margin: 0;
+}
+.yui3-ichoicelist ul {
+  margin-bottom: 10px;
+}
+.yui3-ichoicelist li {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  border-bottom: 1px solid #ccc;
+}
+.yui3-ichoicelist li.unstyled a {
+  text-decoration: none;
+  color: black;
+}
+.yui3-ichoicelist li a, .yui3-ichoicelist li span{
+  padding: 6px 3px;
+  display: block;
+}
+.yui3-ichoicelist li a:hover {
+  background-color: #eee;
+}
+.yui3-ichoicelist #yui3-pretty-overlay-modal h2 {
+  font-weight: bold;
+  font-size: 1.2em;
+  text-indent: 0;
+}
+.yui3-ichoicelist-hidden {
+  visibility: hidden;
+}
+.yui3-ichoicelist li a:focus {
+  outline: black 1px dotted;
+}

=== added directory 'lib/lp/app/javascript/lazr/choiceedit/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/choiceedit/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/choiceedit/assets/skins/sam/choiceedit-skin.css'
--- lib/lp/app/javascript/lazr/choiceedit/assets/skins/sam/choiceedit-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/choiceedit/assets/skins/sam/choiceedit-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,3 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+/* Placeholder for skinning of the Choice Edit Widget */
\ No newline at end of file

=== added file 'lib/lp/app/javascript/lazr/choiceedit/choiceedit.js'
--- lib/lp/app/javascript/lazr/choiceedit/choiceedit.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/choiceedit/choiceedit.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,686 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.choiceedit', function(Y) {
+
+/**
+ * This class provides the ability to allow a specific field to be
+ *  chosen from an enum, similar to a dropdown.
+ *
+ * This can be thought of as a rather pretty Ajax-enhanced dropdown menu.
+ *
+ * @module lazr.choiceedit
+ */
+
+var CHOICESOURCE       = 'ichoicesource',
+    CHOICELIST         = 'ichoicelist',
+    NULLCHOICESOURCE   = 'inullchoicesource',
+    C_EDITICON         = 'editicon',
+    C_VALUELOCATION    = 'value',
+    C_NULLTEXTLOCATION = 'nulltext',
+    C_ADDICON          = 'addicon',
+    SAVE               = 'save',
+    LEFT_MOUSE_BUTTON  = 1,
+    RENDERUI           = "renderUI",
+    BINDUI             = "bindUI",
+    SYNCUI             = "syncUI",
+    NOTHING            = new Object();
+
+/**
+ * This class provides the ability to allow a specific field to be
+ * chosen from an enum, similar to a dropdown.
+ *
+ * @class ChoiceSource
+ * @extends Widget
+ * @constructor
+ */
+
+var ChoiceSource = function() {
+    ChoiceSource.superclass.constructor.apply(this, arguments);
+    Y.after(this._bindUIChoiceSource, this, BINDUI);
+    Y.after(this._syncUIChoiceSource, this, SYNCUI);
+};
+
+ChoiceSource.NAME = CHOICESOURCE;
+
+/**
+ * Dictionary of selectors to define subparts of the widget that we care about.
+ * YUI calls ATTRS.set(foo) for each foo defined here
+ *
+ * @property InlineEditor.HTML_PARSER
+ * @type Object
+ * @static
+ */
+ChoiceSource.HTML_PARSER = {
+    value_location: '.' + C_VALUELOCATION,
+    editicon: '.' + C_EDITICON
+};
+
+ChoiceSource.ATTRS = {
+    /**
+     * Possible values of the enum that the user chooses from.
+     *
+     * @attribute items
+     * @type Array
+     */
+    items: {
+        value: []
+    },
+
+    /**
+     * Current value of enum
+     *
+     * @attribute value
+     * @type String
+     * @default null
+     */
+    value: {
+        value: null
+    },
+
+    /**
+     * List header displayed in the popup
+     *
+     * @attribute title
+     * @type String
+     * @default ""
+     */
+    title: {
+        value: ""
+    },
+
+    /**
+     * Y.Node displaying the current value of the field. Should be
+     * automatically calculated by HTML_PARSER.
+     * Setter function returns Y.one(parameter) so that you can pass
+     * either a Node (as expected) or a selector.
+     *
+     * @attribute value_location
+     * @type Node
+     */
+    value_location: {
+      value: null,
+      setter: function(v) {
+        return Y.one(v);
+      }
+    },
+
+    /**
+     * Y.Node (img) displaying the editicon, which is exchanged for a spinner
+     * while saving happens. Should be automatically calculated by HTML_PARSER.
+     * Setter function returns Y.one(parameter) so that you can pass
+     * either a Node (as expected) or a selector.
+     *
+     * @attribute value_location
+     * @type Node
+     */
+    editicon: {
+      value: null,
+      setter: function(v) {
+        return Y.one(v);
+      }
+    },
+
+    /**
+     * Y.Node display the action icon. The default implementation just returns
+     * the edit icon, but it can be customized to return other elements in
+     * subclasses.
+     * @attribute actionicon
+     * @type Node
+     */
+    actionicon: {
+      getter: function() {
+        return this.get('editicon');
+      }
+    },
+
+    elementToFlash: {
+      value: null,
+      setter: function(v) {
+        return Y.one(v);
+      }
+    },
+
+    backgroundColor: {
+      value: null
+    },
+
+    clickable_content: {
+      value: true
+    }
+};
+
+Y.extend(ChoiceSource, Y.Widget, {
+    initializer: function(cfg) {
+        /**
+         * Fires when the user selects an item
+         *
+         * @event save
+         * @preventable _saveData
+         */
+        this.publish(SAVE);
+
+        var editicon = this.get('editicon');
+        editicon.original_src = editicon.get("src");
+    },
+
+    /**
+     * bind UI events
+     * <p>
+     * This method is invoked after bindUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _bindUIChoiceSource
+     * @protected
+     */
+    _bindUIChoiceSource: function() {
+        var that = this;
+        if (this.get('clickable_content')) {
+            var clickable_element = this.get('contentBox');
+        } else {
+            var clickable_element = this.get('editicon');
+        }
+        clickable_element.on("click", this.onClick, this);
+
+        this.after("valueChange", function(e) {
+            this.syncUI();
+            this._showSucceeded();
+        });
+    },
+
+    /**
+     * Update in-page HTML with current value of the field
+     * <p>
+     * This method is invoked after syncUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _syncUIChoiceSource
+     * @protected
+     */
+    _syncUIChoiceSource: function() {
+        var items = this.get("items");
+        var value = this.get("value");
+        var node = this.get("value_location");
+        for (var i=0; i<items.length; i++) {
+            if (items[i].value == value) {
+                node.set("innerHTML", items[i].source_name || items[i].name);
+            }
+        }
+    },
+
+    _chosen_value: NOTHING,
+
+    /**
+     * Get the currently chosen value.
+     *
+     * Compatible with the Launchpad PATCH plugin.
+     *
+     * @method getInput
+     */
+    getInput: function() {
+        if (this._chosen_value !== NOTHING) {
+          return this._chosen_value;
+        } else {
+          return this.get("value");
+        }
+    },
+
+    /**
+     * Handle click and create the ChoiceList to allow user to
+     * select an item
+     *
+     * @method onClick
+     * @private
+     */
+    onClick: function(e) {
+
+        // Only continue if the down button is the left one.
+        if (e.button != LEFT_MOUSE_BUTTON) {
+            return;
+        }
+
+        this._choice_list = new Y.ChoiceList({
+            value:          this.get("value"),
+            title:          this.get("title"),
+            items:          this.get("items"),
+            value_location: this.get("value_location"),
+            progressbar:    false
+        });
+
+        var that = this;
+        this._choice_list.on("valueChosen", function(e) {
+            that._chosen_value = e.details[0];
+            that._saveData(e.details[0]);
+        });
+
+        // Stuff the mouse coordinates into the list object,
+        // by the time we'll need them, they won't be available.
+        this._choice_list._mouseX = e.clientX + window.pageXOffset;
+        this._choice_list._mouseY = e.clientY + window.pageYOffset;
+
+        this._choice_list.render();
+
+        e.halt();
+    },
+
+    /**
+     * bind UI events
+     *
+     * @private
+     * @method _saveData
+     */
+    _saveData: function(newvalue) {
+        this.set("value", newvalue);
+        this.fire(SAVE);
+    },
+
+    /**
+     * Called when save has succeeded to flash the in-page HTML green.
+     *
+     * @private
+     * @method _showSucceeded
+     */
+    _showSucceeded: function() {
+        this._uiAnimateFlash(Y.lazr.anim.green_flash);
+    },
+
+    /**
+     * Called when save has failed to flash the in-page HTML red.
+     *
+     * @private
+     * @method _showFailed
+     */
+    _showFailed: function() {
+        this._uiAnimateFlash(Y.lazr.anim.red_flash);
+    },
+
+    /**
+     * Run a flash-in animation on the editable text node.
+     *
+     * @method _uiAnimateFlash
+     * @param flash_fn {Function} A lazr.anim flash-in function.
+     * @protected
+     */
+    _uiAnimateFlash: function(flash_fn) {
+        var node = this.get('elementToFlash');
+        if (node === null) {
+          node = this.get('contentBox');
+        }
+        var cfg = { node: node };
+        if (this.get('backgroundColor') !== null) {
+          cfg.to = {backgroundColor: this.get('backgroundColor')};
+        }
+        var anim = flash_fn(cfg);
+        anim.run();
+    },
+
+    /**
+     * Set the 'waiting' user-interface state.  Be sure to call
+     * _uiClearWaiting() when you are done.
+     *
+     * @method _uiSetWaiting
+     * @protected
+     */
+    _uiSetWaiting: function() {
+        var actionicon = this.get("actionicon");
+        actionicon.original_src = actionicon.get("src");
+        actionicon.set("src", "https://launchpad.net/@@/spinner";);
+    },
+
+    /**
+     * Clear the 'waiting' user-interface state.
+     *
+     * @method _uiClearWaiting
+     * @protected
+     */
+    _uiClearWaiting: function() {
+        var actionicon = this.get("actionicon");
+        actionicon.set("src", actionicon.original_src);
+    }
+
+});
+
+
+Y.ChoiceSource = ChoiceSource;
+
+var ChoiceList = function() {
+    ChoiceList.superclass.constructor.apply(this, arguments);
+};
+
+ChoiceList.NAME = CHOICELIST;
+
+ChoiceList.ATTRS = {
+    /**
+     * Possible values of the enum that the user chooses from.
+     *
+     * @attribute items
+     * @type Array
+     */
+    items: {
+        value: []
+    },
+
+    /**
+     * Current value of enum
+     *
+     * @attribute value
+     * @type String
+     * @default null
+     */
+    value: {
+        value: null
+    },
+
+    /**
+     * List header displayed in the popup
+     *
+     * @attribute title
+     * @type String
+     * @default ""
+     */
+    title: {
+        value: ""
+    },
+
+    /**
+     * Node currently containing the value, around which we need to
+     * position ourselves
+     *
+     * @attribute value_location
+     * @type Node
+     */
+     value_location: {
+       value: null
+     },
+
+    /**
+     * List of clickable enum values
+     *
+     * @attribute display_items_list
+     * @type Node
+     */
+     display_items_list: {
+       value: null
+     }
+
+};
+
+
+
+
+Y.extend(ChoiceList, Y.lazr.PrettyOverlay, {
+    initializer: function(cfg) {
+        /**
+         * Fires when the user selects an item
+         *
+         * @event valueChosen
+         */
+        this.publish("valueChosen");
+        this.after("renderedChange", this._positionCorrectly);
+        Y.after(this._renderUIChoiceList, this, RENDERUI);
+        Y.after(this._bindUIChoiceList, this, BINDUI);
+    },
+
+    /**
+     * Render the popup menu
+     * <p>
+     * This method is invoked after renderUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _renderUIChoiceList
+     * @protected
+     */
+    _renderUIChoiceList: function() {
+        this.set("align", {
+          node: this.get("value_location"),
+          points:[Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.TL]
+        });
+        this.set("headerContent", "<h2>" + this.get("title") + "</h2>");
+        this.set("display_items_list", Y.Node.create("<ul>"));
+        var display_items_list = this.get("display_items_list");
+        var items = this.get("items");
+        var value = this.get("value");
+        var li;
+        for (var i=0; i<items.length; i++) {
+            if (items[i].disabled) {
+                li = Y.Node.create('<li><span class="disabled">' +
+                    items[i].name + '</span></li>');
+            } else if (items[i].value == value) {
+                li = Y.Node.create('<li><span class="current">' +
+                    items[i].name + '</span></li>');
+            } else {
+                li = Y.Node.create('<li><a href="#' + items[i].value +
+                    '">' + items[i].name + '</a></li>');
+                li.one('a')._value = items[i].value;
+            }
+            if (items[i].css_class !== undefined) {
+                li.addClass(items[i].css_class);
+            } else {
+                li.addClass('unstyled');
+            }
+            display_items_list.appendChild(li);
+        }
+
+        this.setStdModContent(
+            Y.WidgetStdMod.BODY, display_items_list, Y.WidgetStdMod.REPLACE);
+        this.move(-10000, 0);
+    },
+
+    /**
+     * Bind UI events
+     * <p>
+     * This method is invoked after bindUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _bindUIChoiceList
+     * @protected
+     */
+    _bindUIChoiceList: function() {
+        var display_items_list = this.get("display_items_list");
+        var that = this;
+        Y.delegate("click", function(e) {
+            var target = e.currentTarget;
+            var value = target._value;
+            var items = that.get("items");
+            for (var i=0; i<items.length; i++) {
+                if (items[i].value == value) {
+                    that.fire("valueChosen", items[i].value);
+                    that.destroy();
+                    e.halt();
+                    break;
+                }
+            }
+        }, display_items_list, "li a");
+    },
+
+    /**
+     * Destroy the widget (remove its HTML from the page)
+     *
+     * @method destructor
+     */
+    destructor: function() {
+        var bb = this.get("boundingBox");
+        var parent = bb.get("parentNode");
+        if (parent) {
+            parent.removeChild(bb);
+        }
+    },
+
+    /**
+     * Calculate correct position for popup and move it there.
+     *
+     * This is needed so that we have the correct height of the overlay,
+     * with the content, when we position it. This solution is not very
+     * elegant - in the future we'd like to be able to use YUI's positioning,
+     * thought it doesn't seem to work correctly right now.
+     *
+     * @private
+     * @method _positionCorrectly
+     */
+    _positionCorrectly: function(e) {
+        var boundingBox = this.get('boundingBox');
+        var selectedListItem = boundingBox.one('span.current');
+        valueX = this._mouseX - (boundingBox.get('offsetWidth') / 2);
+        var valueY;
+        if (Y.Lang.isValue(selectedListItem)) {
+            valueY = (this._mouseY -
+                      this.get("headerContent").get('offsetHeight') -
+                      selectedListItem.get('offsetTop') -
+                      (selectedListItem.get('offsetHeight') / 2));
+        } else {
+             valueY = this._mouseY - (boundingBox.get('offsetHeight') / 2);
+        }
+        if (valueX < 0) {
+            valueX = 0;
+        }
+        if ((valueX >
+             document.body.clientWidth - boundingBox.get('offsetWidth')) &&
+            (document.body.clientWidth > boundingBox.get('offsetWidth'))) {
+            valueX = document.body.clientWidth - boundingBox.get('offsetWidth');
+        }
+        if (valueY < 0) {
+            valueY = 0;
+        }
+
+        this.move(valueX, valueY);
+
+        var bb = this.get('boundingBox');
+        bb.on('focus', function(e) {
+            bb.one('.close-button').focus();
+        });
+        bb.one('.close-button').focus();
+    },
+
+    /**
+     * Return the absolute position of any node
+     *
+     * @private
+     * @method _findPosition
+     */
+    _findPosition: function(obj) {
+        var curleft = 0,
+        curtop = 0;
+        if (obj.get("offsetParent")) {
+            do {
+                curleft += obj.get("offsetLeft");
+                curtop += obj.get("offsetTop");
+            } while ((obj = obj.get("offsetParent")));
+        }
+        return [curleft,curtop];
+    }
+
+});
+
+
+Y.augment(ChoiceList, Y.Event.Target);
+Y.ChoiceList = ChoiceList;
+
+
+/**
+ * This class provides a specialised implementation of ChoiceSource
+ * displaying a custom UI for null items.
+ *
+ * @class NullChoiceSource
+ * @extends ChoiceSource
+ * @constructor
+ */
+var NullChoiceSource = function() {
+    NullChoiceSource.superclass.constructor.apply(this, arguments);
+};
+
+NullChoiceSource.NAME = NULLCHOICESOURCE;
+
+NullChoiceSource.HTML_PARSER = {
+    value_location: '.' + C_VALUELOCATION,
+    editicon: '.' + C_EDITICON,
+    null_text_location: '.' + C_NULLTEXTLOCATION,
+    addicon: '.' + C_ADDICON
+};
+
+NullChoiceSource.ATTRS = {
+    null_text_location: {},
+    addicon: {},
+    /**
+     * Action icon returns either the add icon or the edit icon, depending
+     * on whether the currently selected value is null.
+     *
+     * @attribute actionicon
+     */
+    actionicon: {
+        getter: function() {
+            if (Y.Lang.isValue(this.get('value'))) {
+                return this.get('editicon');
+            } else {
+                return this.get('addicon');
+            }
+          }
+    },
+    /**
+     * The specialised version of the items attirbute is cloned and the name
+     * of the null value is modified to add a remove icon next to it. If the
+     * currently selected value is null, the null item is not displayed.
+     *
+     * @attribute items
+     */
+    items: {
+        value: [],
+        getter: function(v) {
+            if (!Y.Lang.isValue(this.get("value"))) {
+                v = Y.Array(v).filter(function(item) {
+                    return (Y.Lang.isValue(item.value));
+                });
+            }
+            for (var i = 0; i < v.length; i++) {
+                if (!Y.Lang.isValue(v[i].value) &&
+                    v[i].name.indexOf('<img') == -1) {
+                    // Only append the icon if the value for this item is
+                    // null, and the img tag is not already found.
+                    v[i].name = [
+                        '<img src="https://launchpad.net/@@/remove"; ',
+                        '     style="margin-right: 0.5em; border: none; ',
+                        '            vertical-align: middle" />',
+                        '<span style="text-decoration: underline; ',
+                        '             display: inline;',
+                        '             color: green;">',
+                        v[i].name,
+                        '</span>'].join('');
+                }
+            }
+            return v;
+        },
+        clone : "deep"
+    }
+};
+
+Y.extend(NullChoiceSource, ChoiceSource, {
+    initializer: function(cfg) {
+        var addicon = this.get('addicon');
+        addicon.original_src = addicon.get("src");
+        var old_uiClearWaiting = this._uiClearWaiting;
+        this._uiClearWaiting = function() {
+            old_uiClearWaiting.call(this);
+            if (Y.Lang.isValue(this.get('value'))) {
+                this.get('null_text_location').setStyle('display', 'none');
+                this.get('addicon').setStyle('display', 'none');
+                this.get('value_location').setStyle('display', 'inline');
+                this.get('editicon').setStyle('display', 'inline');
+            } else {
+                this.get('null_text_location').setStyle('display', 'inline');
+                this.get('addicon').setStyle('display', 'inline');
+                this.get('value_location').setStyle('display', 'none');
+                this.get('editicon').setStyle('display', 'none');
+            }
+        };
+    }
+});
+
+Y.NullChoiceSource = NullChoiceSource;
+
+},"0.2", {"skinnable": true,
+          "requires": ["oop", "event", "event-delegate", "node",
+                       "widget", "widget-position", "widget-stdmod",
+                       "overlay", "lazr.overlay", "lazr.anim", "lazr.base"]});
+

=== added directory 'lib/lp/app/javascript/lazr/choiceedit/tests'
=== added file 'lib/lp/app/javascript/lazr/choiceedit/tests/choiceedit.html'
--- lib/lp/app/javascript/lazr/choiceedit/tests/choiceedit.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/choiceedit/tests/choiceedit.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,29 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd";>
+<html>
+  <head>
+  <title>Status Editor</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+
+  <!-- Dependency -->
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../anim/anim.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+  <script type="text/javascript" src="../../overlay/overlay.js"></script>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../choiceedit/choiceedit.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="choiceedit.js"></script>
+</head>
+<body class="yui3-skin-sam">
+
+  <div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/choiceedit/tests/choiceedit.js'
--- lib/lp/app/javascript/lazr/choiceedit/tests/choiceedit.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/choiceedit/tests/choiceedit.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,528 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.choiceedit', 'lazr.testing.runner', 'node',
+          'event', 'event-simulate', 'widget-stack', 'console', function(Y) {
+
+// Local aliases
+var Assert = Y.Assert,
+    ArrayAssert = Y.ArrayAssert;
+
+/*
+ * A wrapper for the Y.Event.simulate() function.  The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(widget, selector, evtype, options) {
+    var rawnode = Y.Node.getDOMNode(widget.one(selector));
+    Y.Event.simulate(rawnode, evtype, options);
+}
+
+/* Helper function to clean up a dynamically added widget instance. */
+function cleanup_widget(widget) {
+    // Nuke the boundingBox, but only if we've touched the DOM.
+    if (widget.get('rendered')) {
+        var bb = widget.get('boundingBox');
+        if (Y.Node.getDOMNode(bb)) {
+            if (bb.get('parentNode')) {
+                bb.get('parentNode').removeChild(bb);
+            }
+        }
+    }
+    // Kill the widget itself.
+    widget.destroy();
+}
+
+var suite = new Y.Test.Suite("LAZR Choice Edit Tests");
+
+function setUp() {
+    // add the in-page HTML
+    var inpage = Y.Node.create([
+        '<p id="thestatus">',
+        'Status: <span class="value">Unset</span> ',
+        '<img class="editicon" src="https://bugs.edge.launchpad.net/@@/edit";>',
+        '</p>'].join(''));
+    Y.one("body").appendChild(inpage);
+    this.config = this.make_config();
+    this.choice_edit = new Y.ChoiceSource(this.config);
+    this.choice_edit.render();
+}
+
+function tearDown() {
+    if (this.choice_edit._choice_list) {
+        cleanup_widget(this.choice_edit._choice_list);
+    }
+    var status = Y.one("document").one("#thestatus");
+    if (status) {
+        status.get("parentNode").removeChild(status);
+    }
+}
+
+suite.add(new Y.Test.Case({
+
+    name: 'choice_edit_basics',
+
+    setUp: setUp,
+
+    tearDown: tearDown,
+
+    make_config: function() {
+        return {
+            contentBox:  '#thestatus',
+            value:       'incomplete',
+            title:       'Change status to',
+            items: [
+              { name: 'New', value: 'new', style: '',
+                help: '', disabled: false },
+              { name: 'Invalid', value: 'invalid', style: '',
+                help: '', disabled: true },
+              { name: 'Incomplete', value: 'incomplete', style: '',
+                help: '', disabled: false },
+              { name: 'Fix Released', value: 'fixreleased', style: '',
+                help: '', disabled: false },
+              { name: 'Fix Committed', value: 'fixcommitted', style: '',
+                help: '', disabled: true },
+              { name: 'In Progress', value: 'inprogress', style: '',
+                help: '', disabled: false },
+              { name: 'Stalled', value: 'stalled', style: '',
+                help: '', disabled: false, source_name: 'STALLED' }
+            ]
+        };
+    },
+
+    test_can_be_instantiated: function() {
+        Assert.isInstanceOf(
+            Y.ChoiceSource, this.choice_edit, "ChoiceSource not instantiated.");
+    },
+
+    test_choicesource_overrides_value_in_page: function() {
+        var st = Y.one(document).one("#thestatus");
+        // value in page should be set to the config.items.name corresponding to
+        // config.value
+        Assert.areEqual("Incomplete", st.one(".value").get("innerHTML"),
+                        "ChoiceSource is not overriding displayed value in HTML");
+    },
+
+    test_clicking_creates_choicelist: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        Assert.isNotNull(this.choice_edit._choice_list,
+          "ChoiceList object is not created");
+        Assert.isNotNull(Y.one(document).one(".yui3-ichoicelist"),
+          "ChoiceList HTML is not being added to the page");
+    },
+
+    test_right_clicking_doesnt_create_choicelist: function() {
+        simulate(this.choice_edit.get('boundingBox'),
+                 '.value', 'click', { button: 2 });
+        Assert.isNull(Y.one(document).one(".yui3-ichoicelist"),
+          "ChoiceList created when the right mouse button was clicked");
+    },
+
+    test_choicelist_has_correct_values: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var that = this;
+        Y.each(this.config.items, function(configitem) {
+            var found = false;
+            Y.each(that.choice_edit._choice_list.get("items"), function(choiceitem) {
+                if (choiceitem.name == configitem.name) {
+                    found = true;
+                }
+            });
+            Assert.isTrue(found,
+              "Item " + configitem.name + " is passed to ChoiceSource but is " +
+              "not in ChoiceList.items");
+        });
+        var choicelistcount = this.choice_edit._choice_list.get("items").length;
+        var configcount = this.config.items.length;
+        Assert.areEqual(choicelistcount, configcount,
+          "ChoiceList HTML list is a different length (" + choicelistcount +
+          ") than config items list (" + configcount + ")");
+    },
+
+    test_choicelist_html_has_correct_values: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var configcount = this.config.items.length;
+        var choicelist_lis = Y.one(document).all(".yui3-ichoicelist li");
+        Assert.areEqual(choicelist_lis.size(), configcount,
+          "ChoiceList HTML list is a different length (" + choicelist_lis.size() +
+          ") than config items list (" + configcount + ")");
+        // confirm that each LI matches with an item
+        var that = this;
+        choicelist_lis.each(function(li) {
+            var text = li.get("text");
+            var found = false;
+            for (var i=0; i<that.config.items.length; i++) {
+                if (that.config.items[i].name == text) {
+                    found = true;
+                    break;
+                }
+            }
+            Assert.isTrue(found, "Page LI '" + text +
+               "' did not come from a config item");
+        });
+    },
+
+    test_choicelist_html_has_disabled: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var configcount = this.config.items.length;
+        var choicelist_lis = Y.one(document).all(".yui3-ichoicelist li");
+        // confirm that disabled LIs are disabled
+        var that = this;
+        choicelist_lis.each(function(li) {
+            var text = li.get("text");
+            for (var i=0; i<that.config.items.length; i++) {
+                if (that.config.items[i].name == text) {
+                    if (that.config.items[i].disabled) {
+                        Assert.isNotNull(li.one("span.disabled"),
+                          "Page LI '" + text + "' was not disabled");
+                    }
+                    break;
+                }
+            }
+        });
+    },
+
+    test_choicelist_html_has_current: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var configcount = this.config.items.length;
+        var choicelist_lis = Y.one(document).all(".yui3-ichoicelist li");
+        // confirm that current value has an LI with current style
+        var that = this;
+        var asserted = false;
+        choicelist_lis.each(function(li) {
+            var text = li.get("text");
+            for (var i=0; i<that.config.items.length; i++) {
+                if (that.config.items[i].name == text) {
+                    if (that.config.items[i].value == that.config.value) {
+                        Assert.isNotNull(li.one("span.current"),
+                          "Page LI '" + text + "' was not marked as current");
+                        asserted = true;
+                    }
+                    break;
+                }
+            }
+        });
+        Assert.isTrue(asserted, "There was no current LI item");
+    },
+
+    test_clicking_choicelist_item_fires_signal: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var that = this;
+        var fired = false;
+        this.choice_edit._choice_list.on("valueChosen", function() {
+            fired = true;
+        });
+        // simulate a click on the "fix released" option, which is
+        // (a) enabled
+        // (b) not the current option
+        simulate(this.choice_edit._choice_list.get('boundingBox'),
+            'li a[href$=fixreleased]', 'click');
+        Assert.isTrue(fired, "valueChosen signal was not fired");
+    },
+
+    test_clicking_choicelist_item_does_green_flash: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var that = this;
+        var green_flash = Y.lazr.anim.green_flash;
+        var flashed = false;
+        Y.lazr.anim.green_flash = function() {
+          return {
+              run: function() {
+                  flashed = true;
+              }
+          };
+        };
+        simulate(this.choice_edit._choice_list.get('boundingBox'),
+            'li a[href$=fixreleased]', 'click');
+        Assert.isTrue(flashed, "green_flash animation was not fired");
+        Y.lazr.anim.green_flash = green_flash;
+    },
+
+    test_clicking_choicelist_item_sets_page_value: function() {
+        var st = Y.one(document).one("#thestatus");
+        // The page value is set to item.name of the selected item.
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        simulate(this.choice_edit._choice_list.get('boundingBox'),
+          'li a[href$=fixreleased]', 'click');
+        Assert.areEqual("Fix Released", st.one(".value").get("innerHTML"),
+           "Chosen choicelist item is not displayed in HTML (value is '" +
+           st.one(".value").get("innerHTML") + "')");
+    },
+
+    test_clicking_choicelist_item_sets_page_source_name: function() {
+        var st = Y.one(document).one("#thestatus");
+        // By default, the page value is set to item.name of the
+        // selected item, but this can be overridden by specifying
+        // item.source_name.
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var choice_list_bb = this.choice_edit._choice_list.get('boundingBox');
+        var stalled_in_list = choice_list_bb.one('li a[href$=stalled]');
+        Assert.areEqual(
+            "Stalled", stalled_in_list.get('innerHTML'),
+            "ChoiceList item not displayed correctly: " +
+                stalled_in_list.get('innerHTML'));
+        simulate(choice_list_bb, 'li a[href$=stalled]', 'click');
+        Assert.areEqual("STALLED", st.one(".value").get("innerHTML"),
+           "Chosen choicelist item is not displayed in HTML (value is '" +
+           st.one(".value").get("innerHTML") + "')");
+    }
+
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: 'choice_edit_non_clickable_content',
+
+    setUp: setUp,
+
+    tearDown: tearDown,
+
+    make_config: function() {
+        return {
+            contentBox:  '#thestatus',
+            value:       'incomplete',
+            title:       'Change status to',
+            items: [
+              { name: 'New', value: 'new', style: '',
+                help: '', disabled: false },
+              { name: 'Invalid', value: 'invalid', style: '',
+                help: '', disabled: true },
+              { name: 'Incomplete', value: 'incomplete', style: '',
+                help: '', disabled: false },
+              { name: 'Fix Released', value: 'fixreleased', style: '',
+                help: '', disabled: false },
+              { name: 'Fix Committed', value: 'fixcommitted', style: '',
+                help: '', disabled: true },
+              { name: 'In Progress', value: 'inprogress', style: '',
+                help: '', disabled: false },
+              { name: 'Stalled', value: 'stalled', style: '',
+                help: '', disabled: false, source_name: 'STALLED' }
+            ],
+            clickable_content: false
+        };
+    },
+
+    test_clicking_content_doesnt_create_choicelist: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        Assert.isUndefined(this.choice_edit._choice_list,
+          "ChoiceList object is created");
+        Assert.isNull(Y.one(document).one(".yui3-ichoicelist"),
+          "ChoiceList HTML is being added to the page");
+    },
+
+    test_clicking_icon_creates_choicelist: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.editicon', 'click');
+        Assert.isNotUndefined(this.choice_edit._choice_list,
+          "ChoiceList object is not being created");
+        Assert.isNotNull(Y.one(document).one(".yui3-ichoicelist"),
+          "ChoiceList HTML is not being added to the page");
+    },
+
+}));
+
+
+/**
+ * Tests what happens when config.value does not correspond to any of
+ * the items in config.items.
+ */
+suite.add(new Y.Test.Case({
+
+    name: 'choice_edit_value_item_mismatch',
+
+    setUp: setUp,
+
+    tearDown: tearDown,
+
+    make_config: function() {
+        return {
+            contentBox:  '#thestatus',
+            value:       null,
+            title:       'Change status to',
+            items: [
+                { name: 'New', value: 'new', style: '',
+                  help: '', disabled: false },
+                { name: 'Invalid', value: 'invalid', style: '',
+                  help: '', disabled: true }
+            ]
+        };
+    },
+
+    /**
+     * The value displayed in the page should be left alone if
+     * config.value does not correspond to any item in config.items.
+     */
+    test_choicesource_leaves_value_in_page: function() {
+        var st = Y.one(document).one("#thestatus");
+        Assert.areEqual(
+            "Unset", st.one(".value").get("innerHTML"),
+            "ChoiceSource is overriding displayed value in HTML");
+    },
+
+    test_choicelist_html_has_current: function() {
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        var configcount = this.config.items.length;
+        var choicelist_lis = Y.one(document).all(".yui3-ichoicelist li");
+
+        var that = this;
+        var asserted;
+        var test_li = function(li) {
+            var text = li.get("text");
+            for (var i=0; i < that.config.items.length; i++) {
+                if (that.config.items[i].name == text) {
+                    if (that.config.items[i].value == that.choice_edit.get("value")) {
+                        Assert.isNotNull(li.one("span.current"),
+                          "Page LI '" + text + "' was not marked as current");
+                        asserted = true;
+                    }
+                    break;
+                }
+            }
+        };
+        // When config.value does not correspond to any item in
+        // config.items, no LI in the choice list will be marked with
+        // the "current" style.
+        asserted = false;
+        choicelist_lis.each(test_li);
+        Assert.isFalse(asserted, "There was a current LI item");
+        // Once a choice is made, the current value is marked with the
+        // "current" class in the choice list.
+        simulate(this.choice_edit._choice_list.get('boundingBox'),
+            'li a[href$=new]', 'click');
+        simulate(this.choice_edit.get('boundingBox'), '.value', 'click');
+        asserted = false;
+        choicelist_lis.refresh();
+        choicelist_lis.each(test_li);
+        Assert.isTrue(asserted, "There was no current LI item");
+    }
+
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: 'nullable_choice_edit',
+
+    setUp: function() {
+      // add the in-page HTML
+      var inpage = Y.Node.create([
+        '<p id="nullchoiceedit" style="margin-top: 25px">',
+        '  <img class="addicon" src="https://bugs.edge.launchpad.net/@@/add";>',
+        '  <span class="nulltext">Choose something</span>',
+        '  <span class="value" style="display:none" />',
+        '  <img class="editicon" style="display:none" src="https://bugs.edge.launchpad.net/@@/edit";>',
+        '</p>'].join(''));
+      Y.one("body").appendChild(inpage);
+      this.null_choice_edit = new Y.NullChoiceSource({
+        contentBox:  '#nullchoiceedit',
+        value:       null,
+        title:       'Choose something',
+        items: [
+          { name: 'Chico', value: 'chico', style: '',
+            help: '', disabled: false },
+          { name: 'Harpo', value: 'harpo', style: '',
+            help: '', disabled: false },
+          { name: 'Groucho', value: 'groucho', style: '',
+            help: '', disabled: false },
+          { name: 'Gummo', value: 'gummo', style: '',
+            help: '', disabled: false },
+          { name: 'Zeppo', value: 'zeppo', style: '',
+            help: '', disabled: false },
+          { name: 'Not funny!', value: null, style: '',
+            help: '', disabled: false }
+        ]
+      });
+
+      this.null_choice_edit.render();
+    },
+
+    tearDown: function() {
+        if (this.null_choice_edit._choice_list) {
+            cleanup_widget(this.null_choice_edit._choice_list);
+        }
+        var nullchoiceedit = Y.one("document").one("#nullchoiceedit");
+        if (nullchoiceedit) {
+            nullchoiceedit.get("parentNode").removeChild(nullchoiceedit);
+        }
+    },
+
+    test_can_be_instantiated: function() {
+        Assert.isInstanceOf(
+            Y.NullChoiceSource, this.null_choice_edit,
+            "NullChoiceSource not instantiated.");
+    },
+
+    test_action_icon: function() {
+        var that = this;
+
+        Assert.areEqual(
+            this.null_choice_edit.get('actionicon'),
+            this.null_choice_edit.get('addicon'),
+            'Action icon is not the add icon like expected.');
+
+        Assert.areEqual(
+            this.null_choice_edit.get('addicon').getStyle('display'),
+            'inline',
+            'Add icon is not visible when it should be');
+        Assert.areEqual(
+            this.null_choice_edit.get('editicon').getStyle('display'),
+            'none',
+            "Edit icon is visible when it shouldn't be");
+
+        simulate(this.null_choice_edit.get('boundingBox'),
+                 '.value', 'click');
+        simulate(this.null_choice_edit._choice_list.get('boundingBox'),
+          'li a[href$=groucho]', 'click');
+        this.null_choice_edit._uiClearWaiting();
+
+        Assert.areEqual(
+            this.null_choice_edit.get('actionicon'),
+            this.null_choice_edit.get('editicon'),
+            'Action icon is not the add icon like expected.');
+        Assert.areEqual(
+            this.null_choice_edit.get('addicon').getStyle('display'),
+            'none',
+            "Add icon is visible when it shouldn't be");
+        Assert.areEqual(
+            this.null_choice_edit.get('editicon').getStyle('display'),
+            'inline',
+            "Edit icon is not visible when it shouldn be");
+    },
+
+    test_null_item_absent: function() {
+        Assert.areEqual(
+            this.null_choice_edit.get('value'),
+            null,
+            "Selected value isn't null");
+
+        simulate(this.null_choice_edit.get('boundingBox'),
+                 '.value', 'click');
+        var remove_action_present = false;
+        this.null_choice_edit._choice_list.get(
+            'boundingBox').all('li a').each(function(item) {
+            if (item._value == null) {
+                remove_action_present = true;
+            }
+        });
+        Assert.isFalse(
+            remove_action_present,
+            'Remove item is present even when the current value is null.');
+    },
+
+    test_get_input_for_null: function() {
+        this.null_choice_edit.set('value', 'groucho');
+        Assert.areEqual(
+            'groucho',
+            this.null_choice_edit.getInput(),
+            "getInput() did not return the current value");
+        // Simulate choosing a null value and check that getInput()
+        // returns the new value.
+        this.null_choice_edit.onClick({button: 1, halt: function(){}});
+        this.null_choice_edit._choice_list.fire('valueChosen', null);
+        Assert.areEqual(
+            null,
+            this.null_choice_edit.getInput(),
+            "getInput() did not return the current (null) value");
+    }
+}));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added directory 'lib/lp/app/javascript/lazr/effects'
=== added directory 'lib/lp/app/javascript/lazr/effects/assets'
=== added directory 'lib/lp/app/javascript/lazr/effects/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/effects/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/effects/assets/skins/sam/effects-skin.css'
--- lib/lp/app/javascript/lazr/effects/assets/skins/sam/effects-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/effects/assets/skins/sam/effects-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,8 @@
+/* Copyright (c) 2008, Canonical Ltd. */
+
+.yui3-skin-sam .lazr-io-error {
+    width: 30em;
+    color: red;
+    font-weight: bold;
+}
+.yui3-skin-sam .lazr-io-error .io-status { font-weight: normal; }

=== added file 'lib/lp/app/javascript/lazr/effects/effects-async.js'
--- lib/lp/app/javascript/lazr/effects/effects-async.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/effects/effects-async.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,198 @@
+/*
+    Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+YUI.add('lazr.effects-async', function(Y) {
+
+/**
+ * A quick and simple effect for revealing blocks of text when you click
+ * on them.  The first click fetches the content using an AJAX request,
+ * after which the widget acts like a regular sliding-reveal.
+ *
+ * @module lazr.effects
+ * @submodule async
+ * @namespace lazr.effects
+ */
+
+Y.namespace('lazr.effects');
+
+var effects = Y.lazr.effects;
+var ui      = Y.lazr.ui;
+var FOLDED  = 'lazr-folded';
+
+
+/**
+ * A quick and simple effect for revealing blocks of asynchronously loaded
+ * content when you click on them.  The first click fetches the content using
+ * an AJAX request, after which the widget acts like a regular sliding-reveal.
+ *
+ * The function tracks the state of the initial content load by setting the
+ * <code>content_loaded</code> attribute on the container object.  The
+ * attribute will be set to <code>true</code> after the initial load
+ * completes.
+ *
+ * The trigger recieves the 'lazr-trigger' class, and the content
+ * receives 'lazr-content'.
+ *
+ * Both the trigger and content nodes receive the 'lazr-folded' class whenever
+ * the content is closed.
+ *
+ * The container may also obtain the 'lazr-waiting' and 'lazr-io-error'
+ * classes during the asynchronous data fetch.
+ *
+ * @method async_slideout
+ * @public
+ * @param slider {Node} The node that will slide open and closed, and hold the
+ *  asynchronous content.
+ * @param trigger {Node} The node that we will clicked on to open and close
+*   the slider.
+ * @param uri {String} The URI to fetch the content from.
+ * @param container {Node} <i>Optional</i> A child of the sliding
+ *  container node that will hold the asynchronous content.
+ */
+Y.lazr.effects.async_slideout = function(slider, trigger, uri, container) {
+    // The slider is busted in IE 7 :(
+    if (Y.UA.ie) {
+        return;
+    }
+
+    // Prepare our object state.
+    slider = Y.one(slider);
+    if (typeof slider.content_loaded == 'undefined') {
+        slider.content_loaded = false;
+    }
+
+    if (typeof container == 'undefined' || container === null) {
+        // The user didn't give us an explict target container for the new
+        // content, so we'll reuse the sliding container node.
+        container = slider;
+    }
+
+    trigger.addClass(FOLDED);
+    trigger.addClass('lazr-trigger');
+    slider.addClass(FOLDED);
+    slider.addClass('lazr-content');
+
+    trigger.on('click', function(e) {
+        e.halt();
+
+        trigger.toggleClass(FOLDED);
+        container.toggleClass(FOLDED);
+
+        if (!container.content_loaded) {
+            fetch_and_reveal_content(slider, container, uri);
+            container.content_loaded = true;
+        } else {
+            animate_drawer(slider);
+        }
+    });
+};
+
+/*
+ * Slide the content in or out by reversing the slider.fx animation object.
+ */
+function animate_drawer(slider) {
+    slider.fx.stop();
+    slider.fx.set('reverse', !slider.fx.get("reverse"));
+    slider.fx.run();
+}
+
+/*
+ * Fetch the slide-out drawer's data asynchronously, unset the waiting state,
+ * and fill the container with either the new content or an appropriate error
+ * message.  Finally, slide the drawer to fit its new contents.
+ */
+function fetch_and_reveal_content(slider, container, uri) {
+
+    var cfg = {
+        on: {
+            complete: function() {
+                ui.clear_waiting(container);
+            },
+            success: function(id, response) {
+                container.set('innerHTML', response.responseText);
+                slider.fx.stop();
+                slider.fx = effects.slide_out(slider);
+                slider.fx.run();
+            },
+            failure: function(id, response, args) {
+                // Undo the slide animation's changes to the container style.
+                slider.setStyles({
+                    height:   'auto',
+                    overflow: 'visible'
+                });
+                show_nice_error(id, response, args, container, run_io);
+                Y.lazr.anim.red_flash({ node: slider }).run();
+
+                // If the user clicks the collapse trigger, we want to slide
+                // the drawer back in.  But doing so first reverses the
+                // animation, then runs it (because it assumes that slider.fx
+                // is a effects.slide_out() object), so we need to reverse
+                // our effects.slide_in() animation, so its state is the same
+                // as if it were an open effects.slide_out().
+                slider.fx.stop();
+                slider.fx = effects.slide_in(slider);
+                slider.fx.set('reverse', !slider.fx.get('reverse'));
+            }
+        }
+    };
+
+    // Wrap this in a closure, so we can retry it if there is an error.
+    function run_io() {
+        ui.waiting(container);
+        container.set('innerHTML', '');
+        // Slide out enough to fully show the spinner.
+        slider.fx = effects.slide_out(slider, { to: { height: '20px' } });
+        slider.fx.run();
+
+        Y.io(uri, cfg);
+    }
+    run_io();
+}
+
+/*
+ * Display a nice error message in the specified container if the asynchronous
+ * data request failed.
+ *
+ * XXX mars 2009-04-21 bug=364612
+ *
+ * Need to move this to lazr.io.
+ */
+function show_nice_error(id, response, args, message_container,
+    retry_callback) {
+    var status_msg = '<span class="io-status">' +
+        response.status + ' ' +
+        response.statusText +
+        '</span>';
+    var msg_html =
+        ['<div class="lazr-io-error">',
+         '<p>Communication with the server failed</p>',
+         '<p>The server\'s response was: ' + status_msg + '</p>',
+         '<button title="Try to contact the server again">Retry</button>',
+         '</div>'].join('');
+
+    message_container.set('innerHTML', msg_html);
+
+    // Hook up our Retry function.
+    message_container.one('button').on('click', function(e) {
+        e.halt();
+        retry_callback();
+    });
+}
+
+
+}, null, { "requires":["node", "event", "io-base", "lazr.base", "lazr.effects",
+                       "lazr.anim"]});

=== added file 'lib/lp/app/javascript/lazr/effects/effects.js'
--- lib/lp/app/javascript/lazr/effects/effects.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/effects/effects.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,187 @@
+/*
+    Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+YUI.add('lazr.effects', function(Y) {
+
+/**
+ * Visual effects built on top of the YUI Animation library.
+ *
+ * @module lazr.effects
+ * @namespace lazr.effects
+ */
+
+var namespace = Y.namespace('lazr.effects');
+
+var OPENED = 'lazr-opened';
+var CLOSED = 'lazr-closed';
+
+/* Defaults for the slide_in and slide_out effects. */
+namespace.slide_effect_defaults = {
+    easing: Y.Easing.easeOut,
+    duration: 0.4
+};
+
+
+/**
+ * Produces a simple slide-out drawer effect as a Y.Anim object.
+ *
+ * Starts by setting the container's overflow to 'hidden', display to 'block',
+ * and height to '0'.  After the animation is complete, sets the
+ * <code>drawer_closed</code> attribute on the animation object to
+ * <code>false</code>, and sets the container overflow to 'visible'.
+ *
+ * The target node obtains the 'lazr-opened' CSS class when open,
+ * 'lazr-closed' when closed.
+ *
+ * This animation is reversible.
+ *
+ * @method slide_out
+ * @public
+ * @param node {Node|HTMLElement|Selector}  The node to apply the effect to.
+ * @param user_cfg {Y.Anim config} Additional Y.Anim config parameters.
+ *     These will override the default parameters of the same name.
+ * @return {Y.Anim} A new animation instance.
+ */
+namespace.slide_out = function(node, user_cfg) {
+    var cfg = Y.merge(namespace.slide_effect_defaults, user_cfg);
+
+    if (typeof cfg.node == 'undefined') {
+        cfg.node = node;
+    }
+
+    var node = Y.one(node);
+    if (node === null) {
+        Y.fail("A valid node, HTMLElement, or CSS3 selector must be given " +
+               "for the slide_out animation.");
+        return null;
+    }
+
+    var default_to_height = function(node) {
+        return node.get('scrollHeight');
+    };
+
+    // We don't want to stomp on what the user may have given as the
+    // from.height and to.height;
+    cfg.from        = cfg.from ? cfg.from : {};
+    cfg.from.height = cfg.from.height ? cfg.from.height : 0;
+
+    cfg.to          = cfg.to ? cfg.to : {};
+    cfg.to.height   = cfg.to.height ? cfg.to.height : default_to_height;
+
+    // Set what we need to calculate the new content's scrollHeight.
+    node.setStyles({
+        height:   cfg.from.height,
+        overflow: 'hidden',
+        display:  'block'
+    });
+
+    var anim = new Y.Anim(cfg);
+
+    // Set a custom attribute so we can clearly track the slide direction.
+    // Used when reversing the slide animation.
+    anim.drawer_closed = true;
+    add_slide_state_events(anim);
+    node.addClass(CLOSED);
+
+    return anim;
+};
+
+
+/**
+ * Produces a simple slide-out drawer effect as a Y.Anim object.
+ *
+ * After the animation is complete, sets the
+ * <code>drawer_closed</code> attribute on the animation object to
+ * <code>true</code>.
+ *
+ * The target node obtains the 'lazr-opened' CSS class when open,
+ * 'lazr-closed' when closed.
+ *
+ * This animation is reversible.
+ *
+ * @method slide_in
+ * @public
+ * @param node {Node|HTMLElement|Selector}  The node to apply the effect to.
+ * @param user_cfg {Y.Anim config} Additional Y.Anim config parameters.
+ *     These will override the default parameters of the same name.
+ * @return {Y.Anim} A new animation instance.
+ */
+namespace.slide_in = function(node, user_cfg) {
+    var cfg = Y.merge(namespace.slide_effect_defaults, user_cfg);
+
+    if (typeof cfg.node == 'undefined') {
+        cfg.node = node;
+    }
+
+    var node = Y.one(node);
+    if (node === null) {
+        Y.fail("A valid node, HTMLElement, or CSS3 selector must be given " +
+               "for the slide_in animation.");
+        return null;
+    }
+
+    var default_from_height = node.get('clientHeight');
+
+    // We don't want to stomp on what the user may have given as the
+    // from.height and to.height;
+    cfg.from        = cfg.from ? cfg.from : {};
+    cfg.from.height = cfg.from.height ? cfg.from.height : default_from_height;
+
+    cfg.to          = cfg.to ? cfg.to : {};
+    cfg.to.height   = cfg.to.height ? cfg.to.height : 0;
+
+    var anim = new Y.Anim(cfg);
+
+    // Set a custom attribute so we can clearly track the slide direction.
+    // Used when reversing the slide animation.
+    anim.drawer_closed = false;
+    add_slide_state_events(anim);
+    node.addClass(OPENED);
+
+    return anim;
+};
+
+/*
+ * Events designed to handle a sliding animation's opening and closing state.
+ */
+function add_slide_state_events(anim) {
+    var node = anim.get('node');
+    anim.on('start', function() {
+        if (!this.drawer_closed) {
+            // We're closing the draw, so hide the overflow.
+            node.setStyle('overflow', 'hidden');
+        }
+    });
+
+    anim.on('end', function() {
+        if (this.drawer_closed) {
+            // We've finished opening the drawer, so show the overflow, just
+            // to be safe.
+            this.drawer_closed = false;
+            node.setStyle('overflow', 'visible')
+                .addClass(OPENED)
+                .removeClass(CLOSED);
+        } else {
+            this.drawer_closed = true;
+            node.addClass(CLOSED).removeClass(OPENED);
+        }
+    });
+}
+
+
+}, null, {"skinnable": true,
+          "requires":["anim", "node"]});

=== added directory 'lib/lp/app/javascript/lazr/error'
=== added directory 'lib/lp/app/javascript/lazr/error/assets'
=== added file 'lib/lp/app/javascript/lazr/error/assets/error-core.css'
--- lib/lp/app/javascript/lazr/error/assets/error-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/error/assets/error-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,8 @@
+/*
+Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+Licensed under the GNU Affero General Public License:
+http://www.gnu.org/licenses/agpl.txt
+*/
+.yui3-lazr-basic-error-widget-hidden, .yui3-lazr-minimal-error-widget-hidden {
+    visibility: hidden;
+}

=== added directory 'lib/lp/app/javascript/lazr/error/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/error/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/error/assets/skins/sam/error-skin.css'
--- lib/lp/app/javascript/lazr/error/assets/skins/sam/error-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/error/assets/skins/sam/error-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,3 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+/* Placeholder for skinning of the Error Widget */
\ No newline at end of file

=== added file 'lib/lp/app/javascript/lazr/error/assets/skins/sam/minimal-error-widget-skin.css'
--- lib/lp/app/javascript/lazr/error/assets/skins/sam/minimal-error-widget-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/error/assets/skins/sam/minimal-error-widget-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,32 @@
+/*
+Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+Licensed under the GNU Affero General Public License:
+http://www.gnu.org/licenses/agpl.txt
+*/
+.yui3-lazr-minimal-error-widget {
+    position: fixed;
+    width: 80%;
+    margin: 0 10%;
+    bottom: 0;
+    background-color: #f0f0f0;
+    border-top: dashed 1px black;
+    border-left: dashed 1px black;
+    border-right: dashed 1px black;
+}
+
+.yui3-lazr-minimal-error-widget div.error-controls {
+    float: right;
+    margin: 0 1em;
+}
+
+.yui3-lazr-minimal-error-widget .error-info li {
+    display: none;
+}
+
+.yui3-lazr-minimal-error-widget .error-info li.current {
+    display: block;
+}
+
+.yui3-lazr-minimal-error-widget button.close {
+    background-image: url('images/close.gif');
+}

=== added file 'lib/lp/app/javascript/lazr/error/error-widget-minimal.js'
--- lib/lp/app/javascript/lazr/error/error-widget-minimal.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/error/error-widget-minimal.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,293 @@
+/*
+    Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+/*
+ * Widgets for displaying errors.
+ *
+ * @module lazr.error
+ * @namespace lazr.error
+ */
+YUI.add('lazr.error.minimal-error-widget', function(Y) {
+Y.namespace('lazr.error.minimal_error_widget');
+
+/**
+ * This class provides a minimal display of lazr errors, enabling
+ * viewing of one error at a time.
+ *
+ * @class MinimalErrorWidget
+ * @extends Widget
+ * @constructor
+ */
+var CLICK = 'click',
+    CONTENT_BOX = 'contentBox',
+    CURRENT = 'current',
+    CURRENT_ERROR_INDEX = 'current_error_index',
+    DISMISS_ALL = 'dismiss_all',
+    NEXT = 'next',
+    PREV = 'prev';
+
+var MinimalErrorWidget = function() {
+    MinimalErrorWidget.superclass.constructor.apply(this, arguments);
+};
+
+MinimalErrorWidget.NAME = 'lazr-minimal-error-widget';
+
+/**
+ * Static html template to use for creating the list of errors.
+ *
+ * @property InlineEditor.SUBMIT_TEMPLATE
+ * @type string
+ * @static
+ */
+MinimalErrorWidget.ERROR_LIST_TEMPLATE = '<ul class="errors" />';
+
+MinimalErrorWidget.ATTRS = {
+
+    /**
+     * Records the index of the currently displayed error.
+     *
+     * @attribute current_error_index
+     * @type Integer
+     * @default 0
+     */
+    current_error_index: {
+        value: 0
+    }
+};
+
+Y.extend(MinimalErrorWidget, Y.Widget, {
+
+    initializer: function() {
+        this.error_list = [];
+
+        /**
+        * Fires when the user presses the 'Dismiss' button.
+        *
+        * We want to ensure that the error list is cleared
+        * when the basic error widget is dismissed.
+        * @event dismiss_all
+        */
+        this.publish(DISMISS_ALL, {
+            defaultFn: function() {
+                this.error_list = [];
+                this.set(CURRENT_ERROR_INDEX, 0);
+                this.hide();
+            }
+        });
+
+        /**
+        * Fires when the user presses the 'next' link.
+        *
+        * We shift the display of errors by +1 to display the next error.
+        * @event next
+        */
+        this.publish(NEXT, {
+            defaultFn: function() {
+                // We simply move the current class to the next
+                // error.
+                this._shift_current_error(1);
+            }
+        });
+
+        /**
+        * Fires when the user presses the 'prev' link.
+        *
+        * We shift the display of errors by -1 to display the next error.
+        * @event prev
+        */
+        this.publish(PREV, {
+            defaultFn: function() {
+                // We move the current class to the prev
+                // error.
+                this._shift_current_error(-1);
+            }
+        });
+    },
+
+    /**
+     * A convenience method to update navigation display 'Viewing 1 of ..'
+     *
+     * @method _update_navigation
+     */
+    _update_navigation: function() {
+        var content_box = this.get(CONTENT_BOX);
+        content_box.one('span.error-num').set(
+            'innerHTML', this.get(CURRENT_ERROR_INDEX) + 1);
+        content_box.one('span.error-count').set(
+            'innerHTML', this.error_list.length);
+    },
+
+    /**
+     * A convenience method for shifting the current error displayed.
+     *
+     * @method _shift_current_error
+     * @param amount {Integer} The distance to shift from the current error.
+     */
+    _shift_current_error: function(amount) {
+        // Wrap back to the start if necessary.
+        var new_index = this._wrap_index(
+            this.get(CURRENT_ERROR_INDEX) + amount);
+        this.set(CURRENT_ERROR_INDEX, new_index);
+    },
+
+
+    /**
+     * A convenience method for wrapping the current error index so
+     * we don't have to worry about disabling next/prev links.
+     *
+     * @method _wrap_index
+     */
+    _wrap_index: function(new_index) {
+        if (new_index >= this.error_list.length) {
+            return 0;
+        }
+        if (new_index < 0) {
+            return this.error_list.length - 1;
+        }
+        return new_index;
+    },
+
+     /**
+     * A convenience method for setting the current error so that
+     * it is displayed in the UI.
+     *
+     * @method _update_displayed_error
+     */
+    _update_displayed_error: function() {
+        var content_box = this.get(CONTENT_BOX);
+        var error_nodes = content_box.one('div.error-info ul.errors').get(
+            'children');
+
+        error_nodes.removeClass(CURRENT);
+        var error_index = 0;
+        var this_widget = this;
+        var current_error_index = this_widget.get(CURRENT_ERROR_INDEX);
+        error_nodes.each(function(error_node) {
+            if (error_index == current_error_index) {
+                error_node.addClass(CURRENT);
+            }
+            error_index += 1;
+        });
+    },
+
+    /**
+     * Bind the widget's DOM elements to their event handlers.
+     *
+     * @method bindUI
+     * @protected
+     */
+    bindUI: function() {
+        // Ensure that the ui is updated after the current error index
+        // changes.
+        this.after(CURRENT_ERROR_INDEX + 'Change', function() {
+            this._update_displayed_error();
+
+            // Update the 'Viewing 1 of 3'.
+            this._update_navigation();
+        });
+
+        var self = this;
+        var contentBox = this.get(CONTENT_BOX);
+        contentBox.one('button.dismiss_all').on(CLICK, function(e) {
+            e.halt();
+            self.fire(DISMISS_ALL);
+        });
+
+        contentBox.one('div.error-controls a.next').on(CLICK, function(e) {
+            e.halt();
+            self.fire(NEXT);
+        });
+
+        contentBox.one('div.error-controls a.prev').on(CLICK, function(e) {
+            e.halt();
+            self.fire(PREV);
+        });
+    },
+
+    /**
+     * Sync the UI with the widget's current state.
+     *
+     * @method syncUI
+     * @protected
+     */
+    syncUI: function() {
+        // Create a new list of error nodes based on the current errors.
+        var new_error_list_node = Y.Node.create(
+            MinimalErrorWidget.ERROR_LIST_TEMPLATE);
+
+        var error_index = 0;
+        var this_widget = this;
+        Y.each(this.error_list, function(error_msg){
+            var error_list_item = Y.Node.create("<li />");
+            error_list_item.appendChild(document.createTextNode(error_msg));
+
+            if (error_index == this_widget.get(CURRENT_ERROR_INDEX)) {
+                error_list_item.addClass(CURRENT);
+            }
+            new_error_list_node.appendChild(error_list_item);
+
+            error_index += 1;
+        });
+
+        // Swap the new error list in.
+        var content_box = this.get(CONTENT_BOX);
+
+        var error_info = content_box.one('div.error-info');
+        var old_error_list_node = error_info.one('ul.errors');
+        error_info.replaceChild(new_error_list_node, old_error_list_node);
+
+        // Set the 'Viewing 1 of 3' state.
+        this._update_navigation();
+    },
+
+    /**
+     * Add an error to this error widget.
+     *
+     * @method showError
+     * @param error_msg The error to add to the error widget.
+     */
+    showError: function(error_msg) {
+        this.error_list.push(error_msg);
+        this.syncUI();
+        this.show();
+    }
+});
+
+
+/**
+* The HTML representation of this error widget.
+*
+* @property CONTENT_TEMPLATE
+*/
+MinimalErrorWidget.prototype.CONTENT_TEMPLATE = [
+    '<div>',
+    '  <div class="error-controls">',
+    '    Viewing <span class="error-num">1</span> of ',
+    '    <span class="error-count">1</span>',
+    '    <a href="#" class="prev">Prev</a> ',
+    '    <a href="#" class="next">Next</a>',
+    '    <button class="dismiss_all">Dismiss</button>',
+    '  </div>',
+    '  <div class="error-info">',
+    '  ' + MinimalErrorWidget.ERROR_LIST_TEMPLATE,
+    '  </div>',
+    '</div>'].join('');
+
+Y.lazr.error.minimal_error_widget.MinimalErrorWidget = MinimalErrorWidget;
+}, "0.1", {
+    "skinnable": true,
+    "requires": ["oop", "event", "widget", "lazr.error"]});

=== added file 'lib/lp/app/javascript/lazr/error/error.js'
--- lib/lp/app/javascript/lazr/error/error.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/error/error.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,167 @@
+/*
+    Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+YUI.add('lazr.error', function(Y) {
+
+/*
+ * Module docstring goes here...
+ *
+ * @module lazr.error
+ * @namespace lazr.error
+ */
+
+Y.namespace('lazr.error');
+
+var BasicErrorWidget = function() {
+    BasicErrorWidget.superclass.constructor.apply(this, arguments);
+};
+
+// The basic error widget is just a pretty overlay - we don't
+// want to create extra styles for it.
+BasicErrorWidget.NAME = 'lazr-basic-error-widget';
+BasicErrorWidget.ERROR_LIST_TEMPLATE = '<ul class="errors" />';
+Y.extend(BasicErrorWidget, Y.lazr.PrettyOverlay, {
+
+    initializer: function() {
+        this.error_list = [];
+        this.content = null;
+
+        /**
+        * Fires when the user presses the 'Cancel' button.
+        *
+        * We want to ensure that the error list is cleared
+        * when the basic error widget is dismissed.
+        * @event cancel
+        */
+        this.publish('cancel', {
+            defaultFn: function() {
+                this.error_list = [];
+
+                // Ensure the pretty overlay's default cancel
+                // handler is also called.
+                this._defaultCancel();
+            }
+        });
+
+    },
+
+    renderUI: function() {
+        this.content = Y.Node.create(
+            '<p>The following errors were encountered:</p>');
+        var error_list_node = Y.Node.create(
+            BasicErrorWidget.ERROR_LIST_TEMPLATE);
+        this.content.appendChild(error_list_node);
+        var dismiss_button = Y.Node.create(
+            '<button class="dismiss">Dismiss</button>');
+        this.content.appendChild(dismiss_button);
+
+        this.setStdModContent(
+            Y.WidgetStdMod.BODY, this.content,
+            Y.WidgetStdMod.REPLACE);
+    },
+
+    bindUI: function() {
+        var self = this;
+        var dismiss_button = this.content.one('button.dismiss');
+        dismiss_button.on('click', function(e) {
+            e.halt();
+            self.fire('cancel');
+        });
+    },
+
+    syncUI: function() {
+        // Create a new list of error nodes based on the current errors.
+        var new_error_list_node = Y.Node.create(
+            BasicErrorWidget.ERROR_LIST_TEMPLATE);
+        Y.each(this.error_list, function(error_msg){
+            var error_list_item = Y.Node.create("<li />");
+            error_list_item.appendChild(document.createTextNode(error_msg));
+            new_error_list_node.appendChild(error_list_item);
+        });
+
+        // Swap the new error list in.
+        var old_error_list_node = this.content.one('ul.errors');
+        this.content.replaceChild(new_error_list_node, old_error_list_node);
+    },
+
+    showError: function(error_msg) {
+        this.error_list.push(error_msg);
+        this.syncUI();
+        this.show();
+    }
+});
+
+
+/*
+ * Get or create the error widget to use when encountering errors.
+ *
+ * @method get_error_widget
+*/
+var get_error_widget = function() {
+    if (Y.lazr.error.widget === undefined) {
+        Y.lazr.error.widget = new BasicErrorWidget({
+            headerContent: '<h2>Error</h2>',
+            centered: true,
+            visible: false
+        });
+
+        Y.lazr.error.widget.render();
+    }
+};
+
+/**
+ * Run a callback, optionally flashing a specified node red beforehand.
+ *
+ * If the supplied node evaluates false, the callback is invoked immediately.
+ *
+ * @method maybe_red_flash
+ * @param flash_node The node to flash red, or null for no flash.
+ * @param callback The callback to invoke.
+ */
+var maybe_red_flash = function(flash_node, callback)
+{
+    if (flash_node) {
+        var anim = Y.lazr.anim.red_flash({ node: flash_node });
+        anim.on('end', callback);
+        anim.run();
+    } else {
+        callback();
+    }
+};
+
+
+/*
+ * Take an error message and display in an error widget
+ * (creating it if necessary).
+ *
+ * @method display_error
+ * @param msg {String} The message to display.
+ * @param flash_node {Node} The node to red flash.
+*/
+var display_error = function(msg, flash_node) {
+    get_error_widget();
+    maybe_red_flash(flash_node, function(){
+        Y.lazr.error.widget.showError(msg);
+    });
+};
+
+Y.lazr.error.display_error = display_error;
+
+Y.namespace('lazr.error_widgets');
+Y.lazr.error_widgets.BasicErrorWidget = BasicErrorWidget;
+
+}, "0.1", {"skinnable": true, "requires":["lazr.overlay"]});

=== added directory 'lib/lp/app/javascript/lazr/error/tests'
=== added file 'lib/lp/app/javascript/lazr/error/tests/error.js'
--- lib/lp/app/javascript/lazr/error/tests/error.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/error/tests/error.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,106 @@
+/*
+    Copyright (c) 2009, Canonical Ltd.  All rights reserved.
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+YUI().use('lazr.error', 'lazr.error.minimal-error-widget',
+          'lazr.testing.runner', 'node', 'event', 'test',
+          'console', function(Y) {
+
+var suite = new Y.Test.Suite('Lazr-js error Test Suite');
+
+suite.add(new Y.Test.Case({
+
+    name: 'error_basics',
+
+    setUp: function() {
+        Y.lazr.error.widget = undefined;
+    },
+
+    test_display_error_creates_basic_error_widget: function() {
+        // A basic error widget will be created if the error
+        // widget is undefined.
+        Y.Assert.isUndefined(Y.lazr.error.widget);
+        Y.lazr.error.display_error("Error 1234567890");
+        Y.Assert.isInstanceOf(
+            Y.lazr.error_widgets.BasicErrorWidget , Y.lazr.error.widget);
+    },
+    test_display_error_calls_showError: function() {
+        Y.lazr.error.widget = Y.Mock();
+        var error_message = "Error 1234567890";
+        Y.Mock.expect(
+            Y.lazr.error.widget, {
+                method: "showError",
+                args: [error_message]});
+        Y.lazr.error.display_error(error_message);
+        Y.Mock.verify(Y.lazr.error.widget);
+    }
+}));
+
+
+suite.add(new Y.Test.Case({
+
+    name: 'basicerrorwidget_tests',
+
+    setUp: function() {
+        Y.lazr.error.widget = new Y.lazr.error_widgets.BasicErrorWidget();
+        Y.lazr.error.widget.render();
+    },
+
+    test_widget_adds_error: function() {
+        Y.Assert.areEqual(
+            0, Y.lazr.error.widget.error_list.length);
+        Y.lazr.error.widget.showError('Hey there.');
+        Y.Assert.areEqual(
+            1, Y.lazr.error.widget.error_list.length);
+        Y.Assert.areEqual(
+            'Hey there.', Y.lazr.error.widget.error_list[0]);
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: 'minimalerrorwidget_tests',
+
+    setUp: function() {
+        var widget_module = Y.lazr.error.minimal_error_widget;
+        Y.lazr.error.widget = new widget_module.MinimalErrorWidget();
+        Y.lazr.error.widget.render();
+    },
+
+    test_widget_adds_error: function() {
+        Y.Assert.areEqual(
+            0, Y.lazr.error.widget.error_list.length);
+        Y.lazr.error.widget.showError('Hey there.');
+        Y.Assert.areEqual(
+            1, Y.lazr.error.widget.error_list.length);
+        Y.Assert.areEqual(
+            'Hey there.', Y.lazr.error.widget.error_list[0]);
+    }
+}));
+
+
+Y.Test.Runner.add(suite);
+
+var yconsole = new Y.Console({
+    newestOnTop: false
+});
+yconsole.render('#log');
+
+Y.on('domready', function() {
+    Y.Test.Runner.run();
+});
+
+});

=== added file 'lib/lp/app/javascript/lazr/error/tests/index.html'
--- lib/lp/app/javascript/lazr/error/tests/index.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/error/tests/index.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,35 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Lazr error unit tests</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+
+  <!-- dependent modules from lazr-->
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../overlay/overlay.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../error.js"></script>
+  <script type="text/javascript" src="../error-widget-minimal.js"></script>
+  
+  <!-- The test suite -->
+  <script type="text/javascript" src="error.js"></script>
+
+</head>
+<body class="yui3-skin-sam">
+
+  <!-- Widget markup goes here... -->
+
+  <div id="log"></div>
+</body>
+</html>

=== added directory 'lib/lp/app/javascript/lazr/formoverlay'
=== added directory 'lib/lp/app/javascript/lazr/formoverlay/assets'
=== added file 'lib/lp/app/javascript/lazr/formoverlay/assets/formoverlay-core.css'
--- lib/lp/app/javascript/lazr/formoverlay/assets/formoverlay-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/formoverlay/assets/formoverlay-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,49 @@
+.yui3-lazr-formoverlay-hidden {
+    visibility: hidden;
+}
+
+.yui3-lazr-formoverlay-form th, .yui3-lazr-formoverlay-form td {
+    /* The same as the Launchpad style, so the example represents
+     * how it will look.
+     */
+    padding-bottom: 1em;
+}
+
+.yui3-lazr-formoverlay-form div.yui3-lazr-formoverlay-actions {
+    padding-top: 0;
+    padding-bottom: 0;
+    text-align: right;
+}
+
+.yui3-lazr-formoverlay-form .yui3-lazr-formoverlay-errors {
+    padding-top: 0;
+    padding-bottom: 0;
+    color: red;
+}
+
+.yui3-lazr-formoverlay-form table {
+    /* This gets rid of the 12px margin-bottom that yui specifies
+     * in its base.css.
+     */
+    margin-bottom: 0;
+}
+
+.yui3-lazr-formoverlay-form {
+    /* The display:table is necessary to make the div's width
+     * shrink to fit its contents as opposed to expanding to
+     * fill its container. It is also easier to center without
+     * affecting the form_header than display:inline-block.
+     */
+    display: table;
+    margin-bottom: 2em;
+    /* Center the table in the widget. */
+    margin-left: auto;
+    margin-right: auto;
+}
+
+.yui3-lazr-formoverlay-form-header {
+    margin-top: 1em;
+}
+.yui3-lazr-formoverlay a.close-button {
+    visibility: hidden;
+}

=== added directory 'lib/lp/app/javascript/lazr/formoverlay/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/formoverlay/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/formoverlay/assets/skins/sam/formoverlay-skin.css'
--- lib/lp/app/javascript/lazr/formoverlay/assets/skins/sam/formoverlay-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/formoverlay/assets/skins/sam/formoverlay-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,3 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+/* Placeholder for skinning of the Form Overlay Widget */
\ No newline at end of file

=== added file 'lib/lp/app/javascript/lazr/formoverlay/formoverlay.js'
--- lib/lp/app/javascript/lazr/formoverlay/formoverlay.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/formoverlay/formoverlay.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,505 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.formoverlay', function(Y) {
+
+/**
+ * Display a functioning form in a lazr.overlay.
+ *
+ * @module lazr.formoverlay
+ */
+
+
+var getCN = Y.ClassNameManager.getClassName,
+    NAME = 'lazr-formoverlay',
+    CONTENT_BOX  = 'contentBox',
+    RENDERUI = "renderUI",
+    BINDUI = "bindUI";
+
+   /**
+    * The FormOverlay class builds on the lazr.PrettyOverlay class
+    * to display form content and extract form data for the callsite.
+    *
+    * @class FormOverlay
+    * @namespace lazr
+    */
+function FormOverlay(config) {
+    FormOverlay.superclass.constructor.apply(this, arguments);
+
+    Y.after(this._renderUIFormOverlay, this, RENDERUI);
+    Y.after(this._bindUIFormOverlay, this, BINDUI);
+
+}
+
+FormOverlay.NAME = NAME;
+
+/**
+ * Static string that will be used for class of the form header.
+ *
+ * @property FormOverlay.C_FORM_HEADER
+ * @type string
+ * @static
+ */
+FormOverlay.C_FORM_HEADER = getCN(NAME, 'form-header');
+
+/**
+ * Static string that will be used for class of the form.
+ *
+ * @property FormOverlay.C_FORM
+ * @type string
+ * @static
+ */
+FormOverlay.C_FORM = getCN(NAME, 'form');
+
+/**
+ * Static string that will be used for class of the cancel button.
+ *
+ * @property FormOverlay.C_CANCEL
+ * @type string
+ * @static
+ */
+FormOverlay.C_CANCEL = getCN(NAME, 'cancel');
+
+/**
+ * Static string that will be used for class of the error container.
+ *
+ * @property FormOverlay.C_ERRORS
+ * @type string
+ * @static
+ */
+FormOverlay.C_ERRORS = getCN(NAME, 'errors');
+
+/**
+ * Static string that will be the class of the container for form buttons.
+ *
+ * @property FormOverlay.C_ACTIONS
+ * @type string
+ * @static
+ */
+FormOverlay.C_ACTIONS = getCN(NAME, 'actions');
+
+/**
+ * Static html template to contain the form header.
+ *
+ * @property FormOverlay.FORM_TEMPLATE
+ * @type string
+ * @static
+ */
+FormOverlay.FORM_HEADER_TEMPLATE = '<div class="' + FormOverlay.C_FORM_HEADER +
+    '" />';
+
+/**
+ * Static html template to use for creating the form element.
+ *
+ * @property FormOverlay.FORM_TEMPLATE
+ * @type string
+ * @static
+ */
+FormOverlay.FORM_TEMPLATE = '<form class="' + FormOverlay.C_FORM + '" />';
+
+/**
+ * Static html template to use for creating the form's default submit button.
+ *
+ * @property FormOverlay.SUBMIT_TEMPLATE
+ * @type string
+ * @static
+ */
+FormOverlay.SUBMIT_TEMPLATE = '<input type="submit" value="Submit" />';
+
+/**
+ * Static html template to use for creating the form's default cancel button.
+ *
+ * @property FormOverlay.CANCEL_TEMPLATE
+ * @type string
+ * @static
+ */
+FormOverlay.CANCEL_TEMPLATE = '<button type="button"' +
+    'class="' + FormOverlay.C_CANCEL + '">Cancel</button>';
+
+
+/**
+ * Static html template used for creating the error element.
+ *
+ * @property FormOverlay.ERROR_TEMPLATE
+ * @type string
+ * @static
+ */
+FormOverlay.ERROR_TEMPLATE =
+    '<div class="' + FormOverlay.C_ERRORS + '" />';
+
+FormOverlay.ATTRS = {
+
+    /**
+     * The innerHTML for the form header as a string.
+     *
+     * @attribute form_header
+     * @type string
+     * @default ''
+     */
+    form_header: {
+        value: ''
+    },
+
+    /**
+     * The innerHTML for the form as a string.
+     *
+     * @attribute form_content
+     * @type string
+     * @default ''
+     */
+    form_content: {
+        value: ''
+    },
+
+    /**
+     * The node representing the form's submit button.
+     *
+     * @attribute form_submit_button
+     * @type Node
+     * @default.null (Render will construct a submit button if none
+     * is provided.)
+     */
+    form_submit_button: {
+        value: null
+    },
+
+    /**
+     * The node representing the form's cancel button.
+     *
+     * @attribute form_cancel_button
+     * @type Node
+     * @default.null (Render will construct a cancel button if none
+     * is provided.)
+     */
+    form_cancel_button: {
+        value: null
+    },
+
+    /**
+     * The callback function that should be called when the form is
+     * submitted.
+     *
+     * @attribute form_submit_callback.
+     * @type function
+     * @default null.
+     */
+    form_submit_callback: {
+        value: null
+    }
+};
+
+
+Y.extend(FormOverlay, Y.lazr.PrettyOverlay, {
+
+    initializer: function() {
+        // This function is intentionally blank as it's not defined by
+        // PrettyOverlay but is required by YUI.
+    },
+    /**
+     * Create the nodes for the form and add them to the contentBody.
+     * <p>
+     * This method is invoked after renderUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _renderUIFormOverlay
+     * @protected
+     */
+    _renderUIFormOverlay: function(){
+        // Create a node that will contain the form header:
+        this.form_header_node = Y.Node.create(
+            FormOverlay.FORM_HEADER_TEMPLATE);
+
+        // Create a form node that will contain the form content:
+        this.form_node = Y.Node.create(FormOverlay.FORM_TEMPLATE);
+
+        // Create a submit button if none was provided in the
+        // configuration.
+        if (this.get("form_submit_button") === null) {
+            this.set("form_submit_button",
+                     Y.Node.create(FormOverlay.SUBMIT_TEMPLATE));
+        }
+
+        // Create a cancel button if none was provided in the
+        // configuration.
+        if (this.get("form_cancel_button") === null){
+            this.set("form_cancel_button",
+                     Y.Node.create(FormOverlay.CANCEL_TEMPLATE));
+        }
+
+        // Create node to contain any errors when when the showError()
+        // method is called.
+        this.error_node = Y.Node.create(FormOverlay.ERROR_TEMPLATE);
+
+        this._setFormContent();
+    },
+
+    /**
+     * Bind the submit button to the _onFormSubmit() method.
+     * <p>
+     * This method is invoked after bindUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method bindUI
+     * @protected
+     */
+    _bindUIFormOverlay: function(){
+        Y.on("submit",
+             Y.bind(this._onFormSubmit, this),
+             this.form_node);
+
+        // Setup the cancel button to hide the formoverlay.
+        Y.on("click",
+             Y.bind(function(e){ this.hide();}, this),
+             this.get("form_cancel_button"));
+
+        this.on("visibleChange", function(e) {
+            // If the 'centered' configuration attribute is set to true,
+            // then we should always re-center relative to the current
+            // viewport when shown:
+            if (e.newVal) {
+                if (this.get('centered')){
+                    this.centered();
+                }
+                var form_elem = Y.Node.getDOMNode(this.form_node);
+                if (form_elem.elements.length > 0) {
+                    Y.one(form_elem.elements[0]).focus();
+                }
+            }
+        });
+    },
+
+    /**
+     * Setup and add the form to the DOM.
+     *
+     * @method _setFormContent
+     * @private
+     */
+    _setFormContent: function(){
+        // Add the form header content to the form header.
+        this.form_header_node.set('innerHTML',
+            this.get('form_header'));
+
+
+        // Add the form content to the form node.
+        // The form_content can be a string of HTML (as is useful when
+        // it is obtained via AJAX) or a form node (as is useful if the
+        // form is grabbed from the current page).
+        var form_content = this.get('form_content');
+        if (form_content instanceof Y.Node) {
+            this.form_node.appendChild(form_content);
+        } else {
+            this.form_node.set("innerHTML", form_content);
+        }
+
+        // Append the error msg node at the bottom of the form.
+        this.form_node.appendChild(this.error_node);
+
+        // Create a div to wrap the submit button in, to provide
+        // more flexibility for alignment and styling etc.
+        var wrapper_div = Y.Node.create('<div/>');
+        wrapper_div.addClass(FormOverlay.C_ACTIONS);
+        wrapper_div.appendChild(this.get("form_submit_button"));
+        wrapper_div.appendChild(this.get("form_cancel_button"));
+        this.form_node.appendChild(wrapper_div);
+
+        var body_node = Y.Node.create('<div/>');
+        body_node.appendChild(this.form_header_node);
+        body_node.appendChild(this.form_node);
+
+        this.setStdModContent(Y.WidgetStdMod.BODY, body_node, Y.WidgetStdMod.REPLACE);
+    },
+
+    /**
+     * Extract the form data and pass it to the user-provided submit
+     * callback.
+     *
+     * @method _onFormSubmit
+     * @private
+     */
+    _onFormSubmit: function(e){
+        this.clearError();
+        var submit_callback = this.get("form_submit_callback");
+
+        // Prevent the event propagation only if we have a user-supplied
+        // submit callback function. Otherwise let the event go ahead
+        // with its default behavior.
+        if (submit_callback) {
+            e.halt(true);
+
+            var data = this.getFormData();
+            submit_callback(data);
+        }
+    },
+
+    /**
+    * Method to enumerate through an HTML form's elements collection
+    * and return a string comprised of key-value pairs.
+    *
+    * This method was only slightly modified from YUI's io-form.js
+    * _serialize method. (Removed encoding, returned hash, renamed vars).
+    * Not sure how to best format the long lines.
+    *
+    * @method getFormData
+    * @static
+    * @return string
+    */
+    getFormData: function() {
+        var data = {};
+
+        // A helper function for adding form data to the return dict.
+        // Note, similar to python's parse_qs, the value for each key
+        // is a list, as a form can have the same key with multiple values
+        // (for eg., a multiple select for languages - "lang=en&lang=de")
+        var addData = function(key, value){
+            if (data[key] === undefined){
+                data[key] = [value];
+            } else {
+                data[key].push(value);
+            }
+        };
+
+        // Another helper to get the value of an HTML option:
+        var getOptionValue = function(option){
+            if (option.attributes.value && option.attributes.value.specified){
+                return option.value;
+            } else {
+                return option.text;
+            }
+        };
+
+        // The following vars are used inside the for-loop below for selects.
+        var select_idx;
+        var num_options;
+        var option;
+        var option_value;
+
+        // Iterate over the form elements collection to construct the
+        // label-value pairs.
+        var form_elem = Y.Node.getDOMNode(this.form_node);
+        var elem_idx;
+        var num_elems;
+        for (elem_idx = 0,num_elems = form_elem.elements.length;
+             elem_idx < num_elems;
+             ++elem_idx) {
+
+            var elem = form_elem.elements[elem_idx];
+
+            if (elem.name && !elem.disabled) {
+
+                switch (elem.type) {
+                    // Safari, Opera, FF all default opt.value from .text if
+                    // value attribute not specified in markup
+                    case 'select-one':
+                        if (elem.selectedIndex > -1) {
+                            option = elem.options[elem.selectedIndex];
+                            addData(elem.name, getOptionValue(option));
+                        }
+                        break;
+                    case 'select-multiple':
+                        if (elem.selectedIndex > -1) {
+                            for (
+                                select_idx = elem.selectedIndex,
+                                    num_options = elem.options.length;
+                                select_idx < num_options;
+                                ++select_idx) {
+                                option = elem.options[select_idx];
+                                if (option.selected) {
+                                    addData(elem.name, getOptionValue(option));
+                                }
+                            }
+                        }
+                        break;
+                    case 'radio':
+                    case 'checkbox':
+                        if(elem.checked){
+                            addData(elem.name, elem.value);
+                        }
+                        break;
+                    case 'file':
+                        // stub case as XMLHttpRequest will only send
+                        // the file path as a string.
+                    case undefined:
+                        // stub case for fieldset element which returns
+                        // undefined.
+                    case 'reset':
+                        // stub case for input type reset button.
+                    case 'button':
+                        // stub case for input type button elements.
+                        break;
+                    case 'submit':
+                        break;
+                    default:
+                        addData(elem.name, elem.value);
+                }
+            }
+        }
+        return data;
+    },
+
+    /**
+     * Display an error message or a number of error messages.
+     *
+     * @method showError
+     */
+    showError: function(error_msgs){
+        if (typeof(error_msgs) == "string"){
+            error_msgs = [error_msgs];
+        }
+        var error_html = "The following errors were encountered: <ul>";
+        Y.each(error_msgs, function(error_msg){
+            // XXX noodles 2009-02-13 bug=342212. We need to decide on
+            // or provide our own escapeHTML() helper.
+            error_html += "<li>" + error_msg.replace(/<([^>]+)>/g,'') +
+                          "</li>";
+        });
+        error_html += "</ul>";
+        this.error_node.set('innerHTML', error_html);
+    },
+
+    /**
+     * Clear any error message text.
+     *
+     * @method clearError
+     */
+    clearError: function(){
+        this.error_node.set('innerHTML', '');
+    },
+
+    /**
+     * Load the form content from a URL. When the form content has been
+     * fixed it will be rendered in the overlay.
+     *
+     * @method loadFormContentAndRender
+     * @param url {String} The URL from where to load the form content.
+     * @param io_provider {Object} An object providing an .io method.
+     *      This is only used for tests where we can't make an actual
+     *      XHR. If this parameter isn't specified Y.io will be used to
+     *      do the request.
+     */
+    loadFormContentAndRender: function (url, io_provider) {
+        if (io_provider === undefined) {
+             io_provider = Y;
+        }
+        function on_success(id, response, overlay) {
+            overlay.set('form_content', response.responseText);
+            overlay.renderUI();
+            overlay.bindUI();
+        }
+        function on_failure(id, response, overlay) {
+            overlay.set(
+                'form_content',
+                "Sorry, an error occurred while loading the form.");
+            overlay.renderUI();
+        }
+        var cfg = {
+            on: {success: on_success, failure: on_failure},
+            arguments: this
+            }
+        io_provider.io(url, cfg);
+    }
+});
+
+Y.lazr.FormOverlay = FormOverlay;
+
+}, "0.1", {"skinnable": true, "requires": ["lazr.overlay"]});

=== added directory 'lib/lp/app/javascript/lazr/formoverlay/tests'
=== added file 'lib/lp/app/javascript/lazr/formoverlay/tests/formoverlay.html'
--- lib/lp/app/javascript/lazr/formoverlay/tests/formoverlay.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/formoverlay/tests/formoverlay.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,34 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd";>
+<html>
+  <head>
+  <title>Form Overlay</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+
+  <!-- dependent modules from lazr-->
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../overlay/overlay.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../formoverlay/formoverlay.js"></script>
+
+  <!-- Testing helpers -->
+  <script type="text/javascript" src="../../testing/mockio.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="formoverlay.js"></script>
+
+</head>
+<body class="yui3-skin-sam">
+  <div id="form_overlay_example">
+  </div>
+  <div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/formoverlay/tests/formoverlay.js'
--- lib/lp/app/javascript/lazr/formoverlay/tests/formoverlay.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/formoverlay/tests/formoverlay.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,504 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.formoverlay', 'lazr.testing.runner',
+          'lazr.testing.mockio', 'node', 'event', 'event-simulate',
+          'dump', 'console', function(Y) {
+
+var Assert = Y.Assert;  // For easy access to isTrue(), etc.
+
+
+/*
+ * A wrapper for the Y.Event.simulate() function.  The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(widget, selector, evtype, options) {
+    var rawnode = Y.Node.getDOMNode(widget.one(selector));
+    Y.Event.simulate(rawnode, evtype, options);
+}
+
+/* Helper function to cleanup and destroy a form overlay instance */
+function cleanup_form_overlay(form_overlay) {
+    if (form_overlay.get('rendered')) {
+        var bb = form_overlay.get('boundingBox');
+        if (Y.Node.getDOMNode(bb)){
+            bb.get('parentNode').removeChild(bb);
+        }
+    }
+
+    // Kill the widget itself.
+    form_overlay.destroy();
+}
+
+/* Helper function that creates a new form overlay instance. */
+function make_form_overlay(cfg) {
+    var form_overlay = new Y.lazr.FormOverlay(cfg);
+    form_overlay.render();
+    return form_overlay;
+}
+
+var suite = new Y.Test.Suite("Form Overlay Tests");
+
+suite.add(new Y.Test.Case({
+
+    name: 'form_overlay_basics',
+
+    setUp: function() {
+        this.form_overlay = make_form_overlay({
+            headerContent: 'Form for testing',
+            form_content: [
+                'Here is an input: ',
+                '<input type="text" name="field1" id="field1" />',
+                'Here is another input: ',
+                '<input type="text" name="field2" id="field2" />'].join(""),
+            xy: [0, 0]
+        });
+
+        // Ensure window size is constant for tests
+        this.width = window.top.outerWidth;
+        this.height = window.top.outerHeight;
+        window.top.resizeTo(800, 600);
+    },
+
+    tearDown: function() {
+        window.top.resizeTo(this.width, this.height);
+        cleanup_form_overlay(this.form_overlay);
+    },
+
+    test_form_overlay_can_be_instantiated: function() {
+        var overlay = new Y.lazr.FormOverlay();
+        Assert.isInstanceOf(
+            Y.lazr.FormOverlay,
+            overlay,
+            "Form overlay could not be instantiated.");
+        cleanup_form_overlay(overlay);
+    },
+
+    test_body_content_is_single_node: function() {
+        Assert.areEqual(
+            1,
+            new Y.NodeList(this.form_overlay.getStdModNode("body")).size(),
+            "The body content should be a single node, not a node list.");
+    },
+
+    test_form_content_in_body_content: function() {
+        // The form_content should be included in the body of the
+        // overlay during initialization.
+        var body_content = this.form_overlay.getStdModNode("body");
+
+        // Ensure the body_content contains our form node.
+        Assert.isTrue(
+            body_content.contains(this.form_overlay.form_node),
+            "The form node is part of the body content.");
+
+        // And then make sure that the user-supplied form_content is
+        // included in the form node:
+        Assert.areNotEqual(
+            body_content.get("innerHTML").search(
+                this.form_overlay.get("form_content")));
+    },
+
+    test_first_input_has_focus: function() {
+        // The first input element in the form content should have
+        // focus.
+        var first_input = this.form_overlay.form_node.one('#field1');
+
+        // Hide the overlay and ensure that the first input does not
+        // have the focus.
+        this.form_overlay.hide();
+        first_input.blur();
+
+        var test = this;
+        var focused = false;
+
+        var onFocus = function(e) {
+            focused = true;
+        };
+
+        first_input.on('focus', onFocus);
+
+        this.form_overlay.show();
+        Assert.isTrue(focused,
+            "The form overlay's first input field receives focus " +
+            "when the overlay is shown.");
+    },
+
+    test_form_submit_in_body_content: function() {
+        // The body content should include the submit button.
+        var body_content = this.form_overlay.getStdModNode("body");
+        Assert.isTrue(
+            body_content.contains(
+                this.form_overlay.get("form_submit_button")),
+            "The body content includes the form_submit_button.");
+    },
+
+    test_users_submit_button_in_body_content: function() {
+        // If a user supplies a custom submit button, it should be included
+        // in the form instead of the default one.
+        var submit_button = Y.Node.create(
+            '<input type="submit" value="Hit me!" />');
+        var form_overlay = new Y.lazr.FormOverlay({
+            form_content: 'Here is an input: ' +
+                          '<input type="text" name="field1" id="field1" />',
+            form_submit_button: submit_button
+        });
+        form_overlay.render();
+
+        // Ensure the button has been used in the form:
+        Assert.isTrue(
+            form_overlay.form_node.contains(submit_button),
+            "The form should include the users submit button.");
+
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_form_cancel_in_body_content: function() {
+        // The body content should include the cancel button.
+        var body_content = this.form_overlay.getStdModNode("body");
+        Assert.isTrue(
+            body_content.contains(
+                this.form_overlay.get("form_cancel_button")),
+            "The body content includes the form_cancel_button.");
+    },
+
+    test_users_cancel_button_in_body_content: function() {
+        // If a user supplies a custom cancel button, it should be included
+        // in the form instead of the default one.
+        var cancel_button = Y.Node.create(
+            '<button type="" value="cancel" />');
+        var form_overlay = new Y.lazr.FormOverlay({
+            form_content: 'Here is an input: ' +
+                          '<input type="text" name="field1" id="field1" />',
+            form_cancel_button: cancel_button
+        });
+        form_overlay.render();
+
+        // Ensure the button has been used in the form:
+        Assert.isTrue(
+            form_overlay.form_node.contains(cancel_button),
+            "The form should include the users cancel button.");
+
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_hide_when_cancel_clicked: function() {
+        // The form overlay should hide when the cancel button is clicked.
+
+        var bounding_box = this.form_overlay.get('boundingBox');
+        Assert.isFalse(
+            bounding_box.hasClass('yui3-lazr-formoverlay-hidden'),
+            "The form is not hidden initially.");
+
+        simulate(
+            this.form_overlay.form_node,
+            "button[type=button]",
+            'click');
+
+        Assert.isTrue(
+            bounding_box.hasClass('yui3-lazr-formoverlay-hidden'),
+            "The form is hidden after cancel is clicked.");
+    },
+
+    test_error_displayed_on_showError: function() {
+        // The error message should be in the body content.
+
+        this.form_overlay.showError("My special error");
+
+        var body_content = this.form_overlay.getStdModNode("body");
+        Assert.areNotEqual(
+            body_content.get("innerHTML").search("My special error"),
+            -1,
+            "The error text was included in the body content.");
+    },
+
+    test_tags_stripped_from_errors: function() {
+        // Any tags in error messages will be stripped out.
+        // That is, as long as they begin and end with ascii '<' and '>'
+        // chars. Not sure what to do about unicode, for eg.
+        this.form_overlay.showError("<h2>My special error</h2>");
+
+        var body_content = this.form_overlay.getStdModNode("body");
+        Assert.areEqual(
+            -1,
+            body_content.get("innerHTML").search("<h2>"),
+            "The tags were stripped from the error message.");
+    },
+
+    test_error_cleared_on_clearError: function() {
+        // The error message should be cleared from the body content.
+        this.form_overlay.showError("My special error");
+        this.form_overlay.clearError();
+        var body_content = this.form_overlay.getStdModNode("body");
+        Assert.areEqual(
+            body_content.get("innerHTML").search("My special error"),
+            -1,
+            "The error text is cleared from the body content.");
+    },
+
+    test_form_overlay_centered_when_shown: function() {
+        // If the 'centered' attribute is set, the overlay should be
+        // centered in the viewport when shown.
+        Assert.areEqual('[0, 0]', Y.dump(this.form_overlay.get('xy')),
+                        "Position is initially 0,0.");
+        this.form_overlay.show();
+        Assert.areEqual('[0, 0]', Y.dump(this.form_overlay.get('xy')),
+                        "Position is not updated if widget not centered.");
+        this.form_overlay.hide();
+
+        this.form_overlay.set('centered', true);
+        this.form_overlay.show();
+        var centered_pos_before_resize = this.form_overlay.get('xy');
+        Assert.areNotEqual('[0, 0]', Y.dump(centered_pos_before_resize),
+                           "Position is updated when centered attr set.");
+        this.form_overlay.hide();
+
+        var centered = false;
+        function watch_centering() {
+            centered = true;
+        }
+        Y.Do.after(watch_centering, this.form_overlay, 'centered');
+
+        // The position is updated after resizing the window and re-showing:
+        window.top.resizeTo(850, 550);
+        this.form_overlay.show();
+
+        Assert.isTrue(centered,
+            "The overlay centers itself when it is shown with the centered " +
+            "attribute set.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: 'form_overlay_data',
+
+    test_submit_callback_called_on_submit: function() {
+        // Set an expectation that the form_submit_callback will be
+        // called with the correct data:
+        var callback_called = false;
+        var submit_callback = function(ignore){
+            callback_called = true;
+        };
+        var form_overlay = make_form_overlay({
+            form_content: '<input type="text" name="field1" value="val1" />',
+            form_submit_callback: submit_callback
+        });
+        simulate(
+            form_overlay.form_node,
+            "input[type=submit]",
+            'click');
+
+        Assert.isTrue(
+            callback_called,
+            "The form_submit_callback should be called.");
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_submit_with_callback_prevents_propagation: function() {
+        // The onsubmit event is not propagated when user provides
+        // a callback.
+
+        var form_overlay = make_form_overlay({
+            form_content: '<input type="text" name="field1" value="val1" />',
+            form_submit_callback: function() {}
+        });
+
+        var event_was_propagated = false;
+        var test = this;
+        var onSubmit = function(e) {
+            event_was_propagated = true;
+            e.preventDefault();
+        };
+        Y.on('submit', onSubmit, form_overlay.form_node);
+
+        simulate(form_overlay.form_node, "input[type=submit]", 'click');
+
+        Assert.isFalse(
+            event_was_propagated,
+            "The onsubmit event should not be propagated.");
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_submit_without_callback: function() {
+        // The form should submit as a normal form if no callback
+        // was provided.
+        var form_overlay = make_form_overlay({
+            form_content: '<input type="text" name="field1" value="val1" />'
+        });
+
+        var event_was_propagated = false;
+        var test = this;
+        var onSubmit = function(e) {
+            event_was_propagated = true;
+            e.preventDefault();
+        };
+
+        Y.on('submit', onSubmit, form_overlay.form_node);
+
+        simulate(
+            form_overlay.form_node,
+            "input[type=submit]",
+            'click');
+        Assert.isTrue(event_was_propagated,
+                      "The normal form submission event is propagated as " +
+                      "normal when no callback is provided.");
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_getFormData_returns_correct_data_for_simple_inputs: function() {
+        // The getFormData method should return the values of simple
+        // inputs correctly.
+
+        var form_overlay = make_form_overlay({
+            headerContent: 'Form for testing',
+            form_content: [
+                'Here is an input: ',
+                '<input type="text" name="field1" value="val1" />',
+                '<input type="text" name="field2" value="val2" />',
+                '<input type="text" name="field3" value="val3" />'].join("")
+        });
+        Assert.areEqual(
+            '{field1 => [val1], field2 => [val2], field3 => [val3]}',
+            Y.dump(form_overlay.getFormData()),
+            "The getFormData method returns simple input data correctly.");
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_getFormData_returns_inputs_nested_several_levels: function() {
+        // The getFormData method should return the values of inputs
+        // even when they are several levels deep in the form node
+        var form_overlay = make_form_overlay({
+            headerContent: 'Form for testing',
+            form_content: [
+                'Here is an input: ',
+                '<div>',
+                '  <input type="text" name="field1" value="val1" />',
+                '  <div>',
+                '    <input type="text" name="field2" value="val2" />',
+                '    <div>',
+                '      <input type="text" name="field3" value="val3" />',
+                '    </div>',
+                '  </div>',
+                '</div>'].join("")
+        });
+
+        Assert.areEqual(
+            '{field1 => [val1], field2 => [val2], field3 => [val3]}',
+            Y.dump(form_overlay.getFormData()),
+            "The getFormData method returns simple input data correctly.");
+        cleanup_form_overlay(form_overlay);
+
+    },
+
+    test_form_content_as_node: function() {
+        // The form content can also be passed as a node, rather than
+        // a string of HTML.
+        var form_content_div = Y.Node.create("<div />");
+        var input_node = Y.Node.create(
+            '<input type="text" name="field1" value="val1" />');
+        form_content_div.appendChild(input_node);
+
+        var form_overlay = make_form_overlay({
+            headerContent: 'Form for testing',
+            form_content: form_content_div
+            });
+
+        Assert.isTrue(
+            form_overlay.form_node.contains(input_node),
+            "Failed to pass the form content as a Y.Node instance.");
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_form_content_loaded_from_url_success: function() {
+        // The form content can also be loaded from a URL, using
+        // loadFormContentAndRender().
+        var external_form_content = '<div id="loaded-content"></div>';
+
+        var form_overlay = make_form_overlay({
+            headerContent: 'Form for testing',
+            });
+        var mock_io = new Y.lazr.testing.MockIo();
+        form_overlay.loadFormContentAndRender(
+            'http://example.com/form', mock_io);
+
+        // loadFormContentAndRender calls .io() to issue an XHR. Simulate a
+        // successful response, to make sure that the form content gets
+        // set and rendered.
+        var response = Y.lazr.testing.MockIo.makeXhrSuccessResponse(
+            external_form_content);
+        mock_io.simulateXhr(response, false);
+
+        Assert.areEqual(
+            external_form_content, form_overlay.get('form_content'),
+            "The form content wasn't loaded.");
+        // Next we make sure that render was actually called by
+        // checking the form content is present in the HTML.
+        var form_node_text = form_overlay.form_node.get('innerHTML');
+        Assert.areEqual(
+            external_form_content, form_node_text.match(external_form_content),
+            "Failed to render the form.");
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_form_content_loaded_from_url_failure: function() {
+        // If something goes wrong when loading the form contents, an
+        // error message is displayed.
+        var form_overlay = make_form_overlay({
+            headerContent: 'Form for testing',
+            });
+        var mock_io = new Y.lazr.testing.MockIo();
+        form_overlay.loadFormContentAndRender(
+            'http://example.com/form', mock_io);
+
+        // loadFormContentAndRender calls .io() to issue an XHR. Simulate a
+        // failed response, to make sure that the error message gets set
+        // and rendered.
+        var response = Y.lazr.testing.MockIo.makeXhrFailureResponse(
+            'failure');
+        mock_io.simulateXhr(response, true);
+
+        var error_message = "Sorry, an error occurred while loading the form."
+        Assert.areEqual(
+            error_message, form_overlay.get('form_content'),
+            "Failure to set form content.");
+        var form_node_text = form_overlay.form_node.get('innerHTML');
+        Assert.areEqual(
+            error_message, form_node_text.match(error_message),
+            "Failed to render the error message.");
+        cleanup_form_overlay(form_overlay);
+    },
+
+    test_form_content_loaded_from_url_bind_submit: function() {
+        // After the form content is loaded, the submit button is hooked
+        // up to the supplied callback.
+        var callback_called = false;
+        var submit_callback = function(ignore){
+            callback_called = true;
+        };
+        var form_overlay = make_form_overlay({
+            headerContent: 'Form for testing',
+            form_submit_callback: submit_callback
+            });
+        var mock_io = new Y.lazr.testing.MockIo();
+        form_overlay.loadFormContentAndRender(
+            'http://example.com/form', mock_io);
+
+        // loadFormContentAndRender calls .io() to issue an XHR. Simulate a
+        // successful response, to make sure that the submit button get
+        // hooked up to the form_submit_call.
+        var external_form_content = '<div id="loaded-content"></div>';
+        var response = Y.lazr.testing.MockIo.makeXhrSuccessResponse(
+            external_form_content);
+        mock_io.simulateXhr(response, false);
+        simulate(
+            form_overlay.form_node,
+            "input[type=submit]",
+            'click');
+        Assert.isTrue(callback_called, "Submit button didn't get hooked up.");
+        cleanup_form_overlay(form_overlay);
+    }
+}));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added directory 'lib/lp/app/javascript/lazr/gallery-base-componentmgr'
=== added file 'lib/lp/app/javascript/lazr/gallery-base-componentmgr/gallery-base-componentmgr.js'
--- lib/lp/app/javascript/lazr/gallery-base-componentmgr/gallery-base-componentmgr.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/gallery-base-componentmgr/gallery-base-componentmgr.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,336 @@
+YUI.add('gallery-base-componentmgr', function(Y) {
+
+	/*!
+	 * Base Component Manager
+	 * 
+	 * Oddnut Software
+	 * Copyright (c) 2010-2011 Eric Ferraiuolo - http://eric.ferraiuolo.name
+	 * YUI BSD License - http://developer.yahoo.com/yui/license.html
+	 */
+	
+	var ComponentMgr,
+		
+		REQUIRES			= 'requires',
+		INITIALIZER			= 'initializer',
+		DESTRUCTOR			= 'destructor',
+		INSTANCE			= 'instance',
+		
+		E_INIT_COMPONENT	= 'initComponent',
+		E_INIT_COMPONENTS	= 'initComponents',
+		E_DESTROY_COMPONENT	= 'destroyComponent',
+		
+		YLang				= Y.Lang,
+		isArray				= YLang.isArray,
+		isString			= YLang.isString,
+		isObject			= YLang.isObject,
+		isFunction			= YLang.isFunction,
+		noop				= function(){};
+		
+	// *** Constructor *** //
+	
+	ComponentMgr = function () {
+		
+		this._initComponentMgr.apply(this, arguments);
+	};
+	
+	// *** Static *** //
+	
+	ComponentMgr._COMPONENT_CFG = [REQUIRES, INITIALIZER, DESTRUCTOR, INSTANCE];
+	
+	// *** Prototype *** //
+	
+	ComponentMgr.prototype = {
+		
+		// *** Instance Members *** //
+		
+		_components : null,
+		
+		// *** Lifecycle Methods *** //
+		
+		_initComponentMgr : function () {
+			
+			// Holds the goods
+			this._components = new Y.State();
+			this._initComponentHierarchy();
+			
+			/**
+			 * Fired right after init event to allow implementers to add components to be eagerly initialized.
+			 * A <code>componentsToInit</code> array is passed to subscribers whom can push on components to be initialized,
+			 * components can be referenced by string name or object reference.
+			 * 
+			 * @event initComponents
+			 * @param event {Event} The event object for initComponents; has property: componentsToInit
+			 */
+			this.publish(E_INIT_COMPONENTS, {
+				defaultFn	: this._defInitComponentsFn,
+				fireOnce	: true
+			});
+			
+			/**
+			 * Fired when a component is going to be initialized.
+			 * The <code>componentToInit</code> property is the String name of the component going to be initialized.
+			 * Developers can listen to the 'on' moment to prevent the default action of initializing the component.
+			 * Listening to the 'after' moment, a <code>component</code> property on the Event Object is the component instance.
+			 * 
+			 * @event initComponent
+			 * @param event {Event} The event object for initComponent; has properties: componentToInit, component
+			 */
+			this.publish(E_INIT_COMPONENT, { defaultFn: this._defInitComponentFn });
+			
+			/**
+			 * Fired when a component is going to be destroyed.
+			 * The <code>component</code> property is the String name of the component going to be destroyed.
+			 * Developers can listen to the 'on' moment to prevent the default action of destroying the component.
+			 * 
+			 * @event destroyComponent
+			 * @param event {Event} The event object for destoryComponent; has properties: component
+			 */
+			this.publish(E_DESTROY_COMPONENT, { defaultFn: this._defDestoryComponentFn });
+			
+			// Fire initComponents during Y.Base initialization
+			if (this.get('initialized')) {
+				this.fire(E_INIT_COMPONENTS, { componentsToInit: [] });
+			} else {
+				this.after('initializedChange', function(e){
+					this.fire(E_INIT_COMPONENTS, { componentsToInit: [] });
+				});
+			}
+			
+			Y.before(this._destroyComponents, this.constructor.prototype, 'destructor', this);
+		},
+		
+		// *** Public Methods *** //
+		
+		/**
+		 * Adds a component to the Class.
+		 * Components are added by giving an name and configuration.
+		 * The Component Manager uses the requires and initializer function to create the component instance on demand.
+		 * 
+		 * @method addComponent
+		 * @param name {String} name of the component to add
+		 * @param config {Object} defining: {Array} requires, {function} initializer
+		 * @return void
+		 */
+		addComponent : function (name, config) {
+			
+			if ( ! isString(name)) { return; }		// string name
+			if ( ! isObject(config)) { return; }	// config object
+			
+			var components	= this._components,
+				requires	= config.requires,
+				initializer	= config.initializer,
+				destructor	= config.destructor,
+				instance	= config.instance;
+				
+			initializer	= isFunction(initializer) ? initializer :
+						  isString(initializer) && isFunction(this[initializer]) ? this[initializer] : null;
+						  
+			destructor	= isFunction(destructor) ? destructor :
+						  isString(destructor) && isFunction(this[destructor]) ? this[destructor] : null;
+			
+			components.add(name, REQUIRES, requires);
+			components.add(name, INITIALIZER, initializer);
+			components.add(name, DESTRUCTOR, destructor);
+			components.add(name, INSTANCE, instance);
+		},
+				
+		/**
+		 * Retrieves component an instance by string name.
+		 * The component must have previously been initialized otherwise null is returned.
+		 * 
+		 * @method getComponent
+		 * @param component	{String} component to get instance of
+		 * @return instance	{Object|undefined} the component instance if previously initialized, otherwise undefined
+		 */
+		getComponent : function (component) {
+			
+			return this._components.get(component, INSTANCE);
+		},
+		
+		/**
+		 * Destroys a component or set of components by string name.
+		 * This will call the component???s configured destructor fn (preferred), or
+		 * if the instance has a <code>destroy</code> method that will be used by convention.
+		 * 
+		 * @method destroyComponent
+		 * @param component	{String | String... | Array} components to destroy
+		 * @return void
+		 */
+		destroyComponent : function () {
+			
+			var args		= Y.Array(arguments, 0, true),
+				components	= isArray(args[0]) ? args[0] : args;
+			
+			Y.Array.each(components, function(c){
+				if (this._components.get(c, INSTANCE)) {
+					this._destroyComponent(c);
+				}
+			}, this);
+		},
+		
+		/**
+		 * Supplies the callback with component instance(s) that were requested by string name,
+		 * any non-initialized components will be initialized.
+		 * Component instance(s) will be passed to the callback as arguments in the order requested.
+		 * 
+		 * @method useComponent
+		 * @param component* {String} 1-n components to use and/or create instances of
+		 * @param *callback {function} callback to pass component instances to
+		 * @return void
+		 */
+		useComponent : function () {
+			
+			
+			var args		= Y.Array(arguments, 0, true),
+				callback	= isFunction(args[args.length-1]) ? args[args.length-1] : noop,	// last param or noop
+				components	= callback === noop ? args : args.slice(0, -1),					// if callback is noop then all params, otherwise all but last params
+				instances	= [],
+				initialized;
+			
+			if (components.length < 1) {
+				callback.call(this);
+				return;
+			}
+			
+			initialized = Y.Array.partition(components, function(c){
+				var instance = this.getComponent(c);
+				instances.push(instance);
+				return instance;
+			}, this);
+			
+			if (initialized.rejects.length > 0) {
+				Y.use.apply(Y, this._getRequires(initialized.rejects).concat(Y.bind(function(Y){
+					var instances = [];
+					Y.Array.each(initialized.rejects, this._initComponent, this);
+					Y.Array.each(components, function(c){
+						instances.push(this.getComponent(c));
+					}, this);
+					callback.apply(this, instances);
+				}, this)));
+			} else {
+				callback.apply(this, instances);
+			}
+		},
+		
+		// *** Private Methods *** //
+		
+		_initComponentHierarchy : function () {
+			
+			var classes					= this._getClasses(),
+				components				= {},
+				componentConfigProps	= ComponentMgr._COMPONENT_CFG,
+				i, mergeComponentConfigs;
+			
+			// Loop over the Class Hierarchy, aggregating the Component configs
+				
+			mergeComponentConfigs = function (config, name) {
+				
+				if ( ! components[name]) {
+					components[name] = Y.mix({}, config, true, componentConfigProps);
+				} else {
+					Y.mix(components[name], config, true, componentConfigProps);
+				}
+			};
+			
+			for (i = classes.length-1; i >= 0; i--) {
+				Y.Object.each(classes[i].COMPONENTS, mergeComponentConfigs);
+			}
+			
+			// Add the components defined in the static COMPONENTS object
+			Y.Object.each(components, function(config, name){
+				this.addComponent(name, config);
+			}, this);
+		},
+		
+		_getRequires : function (components) {
+			
+			components = isArray(components) ? components : [components];
+			var requires = [];
+			
+			Y.Array.each(components, function(c){
+				requires = requires.concat(this._components.get(c, REQUIRES) || []);
+			}, this);
+			
+			return Y.Array.unique(requires);
+		},
+		
+		_initComponent : function (c) {
+			
+			this.fire(E_INIT_COMPONENT, { componentToInit: c });
+		},
+		
+		_destroyComponent : function (c) {
+			
+			this.fire(E_DESTROY_COMPONENT, { component: c });
+		},
+		
+		_destroyComponents : function () {
+			
+			var instances = this._components.data[INSTANCE];
+			
+			Y.each(instances, function(instance, component){
+				if (instance) {
+					this._destroyComponent(component);
+				}
+			}, this);
+		},
+		
+		_defInitComponentsFn : function (e) {
+			
+			var components	= e.componentsToInit,
+			requires	= this._getRequires(components);
+			
+			Y.use.apply(Y, requires.concat(Y.bind(function(Y){
+				Y.Array.each(components, this._initComponent, this);
+			}, this)));
+		},
+		
+		_defInitComponentFn : function (e) {
+			
+			var components	= this._components,
+				component	= e.componentToInit,
+				initializer	= components.get(component, INITIALIZER),
+				instance	= components.get(component, INSTANCE);
+			
+			if ( ! instance && isFunction(initializer)) {
+				instance = initializer.call(this);
+				// Add us as an event bubble target for the instance
+				if (instance._yuievt && isFunction(instance.addTarget)) {
+					instance.addTarget(this);
+				}
+				components.add(component, INSTANCE, instance);
+			}
+			
+			e.component = instance;
+		},
+		
+		_defDestoryComponentFn : function (e) {
+			
+			var components	= this._components,
+				component	= e.component,
+				destructor	= components.get(component, DESTRUCTOR),
+				instance	= components.get(component, INSTANCE);
+			
+			if ( ! instance ) { return; }
+			
+			// removes us as an event bubble target for the instance
+			if (instance._yuievt && isFunction(instance.removeTarget)) {
+				instance.removeTarget(this);
+			}
+			
+			// prefer the configured destructor fn, or use use destroy instance method by convention
+			if (isFunction(destructor)) {
+				destructor.call(this, instance);
+			} else if (isFunction(instance.destroy)) {
+				instance.destroy();
+			}
+			
+			components.remove(component, INSTANCE);
+		}
+		
+	};
+	
+	Y.BaseComponentMgr = ComponentMgr;
+
+
+}, 'gallery-2011.01.26-20-33' ,{"requires":["base-base", "collection"]});

=== added directory 'lib/lp/app/javascript/lazr/gallery-event-binder'
=== added file 'lib/lp/app/javascript/lazr/gallery-event-binder/gallery-event-binder.js'
--- lib/lp/app/javascript/lazr/gallery-event-binder/gallery-event-binder.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/gallery-event-binder/gallery-event-binder.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,147 @@
+YUI.add('gallery-event-binder', function(Y) {
+
+/**
+* <p>The Event Binder satisfies a very specific need. Binding user actions until a
+* particular YUI instance become ready, and the listeners defined before flushing those
+* events through a queue. This will help to catch some early user interactions due
+* the ondemand nature of YUI 3.
+*
+* <p>To use the Event Binder Module, you have to leverage YUI_config object first. More information
+* about this object visit this page: http://developer.yahoo.com/yui/3/api/config.html<p>
+* <p>There is a member of this global object that you have to set up, the member is called: "eventbinder". To get
+* more information about this object, visit this page: http://yuilibrary.com/gallery/show/event-binder</p>
+*
+* <p>
+* <code>
+* &#60;script type="text/javascript"&#62; <br>
+* <br>
+*		//	Call the "use" method, passing in "gallery-event-binder".	 Then you can<br>
+*		//	call Y.EventBinder.flush('click'); to flush all the events that might had happened<br>
+*		//	before your listeners were defined. <br>
+* <br>
+*		YUI().use("gallery-event-binder", "event", function(Y) { <br>
+* <br>
+*			Y.on('click', function(e) {<br>
+*				// do your stuff here...<br>
+*			}, '#demo');<br>
+* <br>
+*			Y.EventBinder.flush('click');<br>
+* <br>
+*		});<br>
+*});<br>
+* <br>
+* <br>
+*	&#60;/script&#62; <br>
+* </code>
+* </p>
+*
+* <p>The Event Binder has a single method called "flush". This method accept one argument to
+* identify what type of event should be flushed. The argument can be:</p>
+* <ul>
+* <li>click</li>
+* <li>dblclick</li>
+* <li>mouseover</li>
+* <li>mouseout</li>
+* <li>mousedown</li>
+* <li>mouseup</li>
+* <li>mousemove</li>
+* <li>keydown</li>
+* <li>keyup</li>
+* <li>keypress</li>
+* <li>...etc...</li>
+* </ul>
+* <p>Keep in mind that before flushing any of these events, you have to add them to the
+* monitoring system through the configuration object (YUI_config.eventbinder), otherwise
+* YUI will be unable to listen for any early user interaction.</p>
+* </p>
+*
+* @module gallery-event-binder
+*/
+
+function _modulesReady (e, modules, handler) {
+	var args = Y.Array(modules);
+
+	// stopping the module inmidiately
+	e.halt();
+
+	// adding the loading class
+	e.target.addClass('yui3-waiting');
+
+	args.push(function() {
+		// once the modules gets ready, let's remove the original binder
+		handler.detach();
+		// removing the loading class
+		e.target.removeClass('yui3-waiting');
+		// let's simulate the new event based on the original facade
+		Y.Event.simulate(e.target._node, e.type, e);
+	});
+
+	Y.use.apply (Y, args);
+
+}
+
+Y.EventBinder = {
+	/*
+	 * Filter all the events in the queue by type, and simulate those that match.
+	 * @method flush
+	 * @param type {string} The type of event to flush
+	 */
+	flush: function (type) {
+		var config = Y.config.eventbinder || {};
+
+		config.q = config.q || [];
+		type = type || 'click';
+
+		if (config.fn) {
+			// once you call flush, the original listener should be removed
+			Y.Event.detach(type, config.fn, Y.config.doc);
+		}
+		// filtering all the events in the queue by type
+		Y.each(config.q, function(o) {
+
+			if (type == o.type) {
+				// removing the loading class
+				Y.one(o.target).removeClass('yui3-waiting');
+				// let's simulate the new event based on the backup object described by "e" in the configuration
+				Y.Event.simulate(o.target, type, o);
+			}
+
+		});
+	},
+	/*
+	 * Adds an event listener. This method is an wrap for Y.on, and instead of supporting
+	 * a regular callback, it loads a set of modules and simulate the same event once those
+	 * modules become available.
+	 * @method on
+	 * @param type {string} The type of event to append
+	 * @param modules {string|array} a module or a list of modules that should be loaded when this event happens
+	 * @param el {String|HTMLElement|Array|NodeList} An id, an element reference, or a collection of ids and/or elements to assign the listener to.
+	 * @return {EventHandle} the detach handle
+	 */
+	on: function (type, modules, el) {
+		// setting the event listener
+		var handler = Y.on (type, function(e) {
+			return _modulesReady(e, modules, handler);
+		}, el);
+	},
+	/*
+	 * Adds an event listener. This method is an wrap for Y.on, and instead of supporting
+	 * a regular callback, it loads a set of modules and simulate the same event once those
+	 * modules become available.
+	 * @method delegate
+	 * @param type {string} the event type to delegate
+	 * @param modules {string|array} a module or a list of modules that should be loaded when this event happens
+	 * @param el {String|HTMLElement|Array|NodeList} An id, an element reference, or a collection of ids and/or elements representing the delegation container.
+	 * @param spec {string} a selector that must match the target of the event.
+	 * @return {EventHandle} the detach handle
+	 */
+	delegate: function (type, modules, el, spec) {
+		// setting the delegate listener
+		var handler = Y.delegate (type, function(e) {
+			return _modulesReady(e, modules, handler);
+		}, el, spec);
+	}
+};
+
+
+}, 'gallery-2010.06.23-18-37', {"requires": ["event-simulate", "event-base", "event-delegate"]});

=== added directory 'lib/lp/app/javascript/lazr/gallery-form'
=== added file 'lib/lp/app/javascript/lazr/gallery-form/gallery-form.js'
--- lib/lp/app/javascript/lazr/gallery-form/gallery-form.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/gallery-form/gallery-form.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,1941 @@
+YUI.add('gallery-form', function(Y) {
+
+/**
+ * Create a form object that can handle both client and server side validation
+ *
+ * @module form
+ */
+
+
+/**
+ * Creates an form which contains fields, and does clientside validation, as well
+ * as handling input from the server.
+ *
+ * @class Form
+ * @extends Widget
+ * @param config {Object} Configuration object
+ * @constructor
+ */
+
+Y.Form = Y.Base.create('form', Y.Widget, [Y.WidgetParent], {
+    toString: function() {
+        return this.name;
+    },
+
+    CONTENT_TEMPLATE: '<form></form>',
+
+    /**
+     * @property _ioIds
+     * @type Object
+     * @protected
+     * @description An object who's keys represent the IO request ids sent by this Y.Form instance
+     */
+    _ioIds: null,
+
+    /**
+     * @method _validateAction
+     * @private
+     * @param {String} val
+     * @description Validates the values of the 'action' attribute
+     */
+    _validateMethod: function(val) {
+        if (!Y.Lang.isString(val)) {
+            return false;
+        }
+        if (val.toLowerCase() != 'get' && val.toLowerCase() != 'post') {
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * @method _parseAction
+     * @private
+     * @param {Y.Node} contentBox
+     * @description Sets the 'action' attribute based on parsed HTML
+     */
+    _parseAction: function(contentBox) {
+        var form = contentBox.one('form');
+        if (!form) {
+            form = contentBox;
+        }
+        if (form) {
+            return form.get('action');
+        }
+    },
+
+    /**
+     * @method _parseMethod
+     * @private
+     * @param {Y.Node} contentBox
+     * @description Sets the 'method' attribute based on parsed HTML
+     */
+    _parseMethod: function(contentBox) {
+        var form = contentBox.one('form');
+        if (!form) {
+            form = contentBox;
+        }
+        if (form) {
+            return form.get('method');
+        }
+    },
+
+    /**
+     * @method _parseFields
+     * @private
+     * @param {Y.Node} contentBox
+     * @description Sets the 'fields' attribute based on parsed HTML
+     */
+    _parseFields: function(contentBox) {
+        var children = contentBox.all('*'),
+        labels = contentBox.all('label'),
+        fields = [],
+        inputMap = {
+            text: Y.TextField,
+            hidden: Y.HiddenField,
+            file: Y.FileField,
+            checkbox: Y.CheckboxField,
+            radio: Y.RadioField,
+            reset: Y.ResetButton,
+            submit: Y.SubmitButton,
+            button: (Y.Button || Y.FormButton)
+        };
+
+        children.each(function(node, index, nodeList) {
+            var nodeName = node.get('nodeName'),
+            nodeId = node.get('id'),
+            type,
+            o,
+            c = [];
+            if (nodeName == 'INPUT') {
+                type = node.get('type');
+                o = {
+                    type: (inputMap[type] ? inputMap[type] : Y.TextField),
+                    name: node.get('name'),
+                    value: node.get('value'),
+                    checked: node.get('checked')
+                };
+
+                if (o.type == inputMap.button) {
+                    o.label = node.get('value');
+                }
+            } else if (nodeName == 'BUTTON') {
+                o = {
+                    type: inputMap.button,
+                    name: node.get('name'),
+                    label: node.get('innerHTML')
+                };
+            } else if (nodeName == 'SELECT') {
+                node.all('option').each(function(optNode, optNodeIndex, optNodeList) {
+                    c.push({
+                        label: optNode.get('innerHTML'),
+                        value: optNode.get('value')
+                    });
+                });
+                o = {
+                    type: Y.SelectField,
+                    name: node.get('name'),
+                    choices: c
+                };
+            } else if (nodeName == 'TEXTAREA') {
+                o = {
+                    type: Y.TextareaField,
+                    name: node.get('name'),
+                    value: node.get('innerHTML')
+                };
+            }
+
+            if (o) {
+                if (nodeId) {
+                    o.id = nodeId;
+                    labels.some(function(labelNode, labelNodeIndex, labelNodeList) {
+                        if (labelNode.get('htmlFor') == nodeId) {
+                            o.label = labelNode.get('innerHTML');
+                        }
+                    });
+                }
+                fields.push(o);
+            }
+            node.remove();
+        });
+
+        return fields;
+    },
+
+    /**
+     * @method _syncFormAttributes
+     * @protected
+     * @description Syncs the form node action and method attributes
+     */
+    _syncFormAttributes: function() {
+        var contentBox = this.get('contentBox');
+        contentBox.setAttrs({
+            action: this.get('action'),
+            method: this.get('method')
+        });
+
+        if (this.get('encodingType') === Y.Form.MULTIPART_ENCODED) {
+            contentBox.setAttribute('enctype', 'multipart/form-data');
+        }
+    },
+
+    /**
+     * @method _runValidation
+     * @protected
+     * @description Validates the form based on each field's validator
+     */
+    _runValidation: function() {
+        var isValid = true;
+
+        this.each(function(f) {
+            if (f.validateField() === false) {
+                isValid = false;
+            }
+        });
+
+        return isValid;
+    },
+
+    _enableInlineValidation: function() {
+        this.each(function(f) {
+            f.set('validateInline', true);
+        });
+    },
+
+    _disableInlineValidation: function() {
+        this.each(function(f) {
+            f.set('validateInline', false);
+        });
+    },
+
+    /**
+     * @method _handleIOEvent
+     * @protected
+     * @param {String} eventName
+     * @param {Number} ioId
+     * @param {Object} ioResponse
+     * @description Handles the IO events of transactions instantiated by this instance
+     */
+    _handleIOEvent: function(eventName, ioId, ioResponse) {
+        if (this._ioIds[ioId] !== undefined) {
+            this.fire(eventName, {
+                response: ioResponse
+            });
+        }
+    },
+
+    /**
+     * @method reset
+     * @description Resets all form fields to their initial value 
+     */
+    reset: function() {
+        var cb = Y.Node.getDOMNode(this.get('contentBox'));
+        if (Y.Lang.isFunction(cb.reset)) {
+            cb.reset();
+        }
+        this.each(function(field) {
+            field.resetFieldNode();
+            field.set('error', null);
+        });
+    },
+
+    /**
+     * @method submit
+     * @description Submits the form using the defined method to the URL defined in the action
+     */
+    submit: function() {
+        if (this.get('skipValidationBeforeSubmit') === true || this._runValidation()) {
+            var formAction = this.get('action'),
+            formMethod = this.get('method'),
+            submitViaIO = this.get('submitViaIO'),
+            transaction,
+            cfg;
+
+            if (submitViaIO === true) {
+                cfg = {
+                    method: formMethod,
+                    form: {
+                        id: this.get('contentBox'),
+                        upload: (this.get('encodingType') === Y.Form.MULTIPART_ENCODED)
+                    }
+                };
+
+                var io = this.get("io");
+                transaction = io(formAction, cfg);
+                this._ioIds[transaction.id] = transaction;
+            } else {
+                this.get('contentBox').submit();
+            }
+        }
+    },
+
+    /**
+     * @method getField
+     * @param {String|Number} selector
+     * @description Get a form field by its name attribute or numerical index
+     */
+    getField: function(selector) {
+        var sel;
+
+        if (Y.Lang.isNumber(selector)) {
+            sel = this.item(selector);
+        } else if (Y.Lang.isString(selector)) {
+            this.each(function(f) {
+                if (f.get('name') == selector) {
+                    sel = f;
+                }
+            });
+        }
+        return sel;
+    },
+
+    initializer: function(config) {
+        this._ioIds = {};
+
+        this.publish('submit');
+        this.publish('reset');
+        this.publish('start');
+        this.publish('success');
+        this.publish('failure');
+        this.publish('complete');
+        this.publish('xdr');
+    },
+
+    destructor: function() {
+        },
+
+    renderUI: function() {
+        },
+
+    bindUI: function() {
+        this.get('contentBox').on('submit', Y.bind(function(e) {
+            e.halt();
+        },
+        this));
+
+        this.after('inlineValidationChange', Y.bind(function(e) {
+            if (e.newVal === true) {
+                this._enableInlineValidation();
+            } else {
+                this._disableInlineValidation();
+            }
+        },
+        this));
+
+        this.after('success', Y.bind(function(e) {
+            if (this.get('resetAfterSubmit') === true) {
+                this.reset();
+            }
+        },
+        this));
+
+        Y.on('io:start', Y.bind(this._handleIOEvent, this, 'start'));
+        Y.on('io:complete', Y.bind(this._handleIOEvent, this, 'complete'));
+        Y.on('io:xdr', Y.bind(this._handleIOEvent, this, 'xdr'));
+        Y.on('io:success', Y.bind(this._handleIOEvent, this, 'success'));
+        Y.on('io:failure', Y.bind(this._handleIOEvent, this, 'failure'));
+
+        this.each(Y.bind(function(f) {
+            // This should probably be performed also when children
+            // are with Form.add() after the form is rendered.
+            if (f.name == 'submit-button') {
+                f.on('click', Y.bind(this.submit, this));
+            } else if (f.name == 'reset-button') {
+                f.on('click', Y.bind(this.reset, this));
+            }
+        },
+        this));
+    },
+
+    syncUI: function() {
+        this._syncFormAttributes();
+        if (this.get('inlineValidation') === true) {
+            this._enableInlineValidation();
+        }
+    }
+},
+{
+
+    /**
+     * @property Form.ATTRS
+     * @type Object
+     * @static
+     */
+    ATTRS: {
+        defaultChildType: {
+            valueFn: function() {
+                return Y.TextField;
+            }
+        },
+
+        /**
+         * @attribute method
+         * @type String
+         * @default 'post'
+         * @description The method by which the form should be transmitted. Valid values are 'get' and 'post'
+         */
+        method: {
+            value: 'post',
+            validator: function(val) {
+                return this._validateMethod(val);
+            },
+            setter: function(val) {
+                return val.toLowerCase();
+            }
+        },
+
+        /**
+         * @attribute action
+         * @type String
+         * @default '.'
+         * @description A url to which the validated form is to be sent
+         */
+        action: {
+            value: '.',
+            validator: Y.Lang.isString
+        },
+
+        /**
+         * @attribute fields
+         * @type Array
+         * @deprecated Use "children" attribet instead
+         * @description An array of the fields to be rendered into the Y.Form. Each item in the 
+         *              array can either be a FormField instance or an object literal defining
+         *              the properties of the field to be generated. Alternatively, this value
+         *              will be parsed in from HTML
+         */
+        fields: {
+            setter: function(val) {
+                return this.set('children', val);
+            }
+        },
+
+        /**
+         * @attribute inlineValidation
+         * @type Boolean
+         * @description Set to true to validate fields "on the fly", where they will
+         *                              validate themselves any time the value attribute is changed
+         * @default false
+         */
+        inlineValidation: {
+            value: false,
+            validator: Y.Lang.isBoolean
+        },
+
+        /**
+         * @attribute resetAfterSubmit
+         * @type Boolean
+         * @description If true, the form is reset following a successful submit event 
+         * @default true
+         */
+        resetAfterSubmit: {
+            value: true,
+            validator: Y.Lang.isBoolean
+        },
+
+        /**
+         * @attribute encodingType
+         * @type Number
+         * @description Set to Form.MULTIPART_ENCODED in order to use the FileField for uploads
+         * @default Form.URL_ENCODED
+         */
+        encodingType: {
+            value: 1,
+            validator: Y.Lang.isNumber
+        },
+
+        /**
+         * @attribute skipValidationBeforeSubmit
+         * @type Boolean
+         * @description Set to true to skip the validation step when submitting
+         * @default false
+         */
+        skipValidationBeforeSubmit: {
+            value: false,
+            validator: Y.Lang.isBoolean
+        },
+
+        submitViaIO: {
+            value: true,
+            validator: Y.Lang.isBoolean
+        },
+
+        /**
+         * @attribute io
+         * @type Function
+         * @description The factory for creating IO transactions, used by tests.
+         * @default Y.io
+         */
+        io: {
+            value: Y.io
+        }
+
+    },
+
+    /**
+     * @property Form.HTML_PARSER
+     * @type Object
+     * @static
+     */
+    HTML_PARSER: {
+        action: function(contentBox) {
+            return this._parseAction(contentBox);
+        },
+        method: function(contentBox) {
+            return this._parseMethod(contentBox);
+        },
+        children: function(contentBox) {
+            return this._parseFields(contentBox);
+        }
+    },
+
+    /**
+     * @property Form.FORM_TEMPLATE
+     * @type String
+     * @static
+     * @description The HTML used to create the form Node
+     */
+    FORM_TEMPLATE: '<form></form>',
+
+    /**
+     * @property Form.URL_ENCODED
+     * @type Number
+     * @description Set the form the default text encoding
+     */
+    URL_ENCODED: 1,
+
+    /**
+     * @property Form.MULTIPART_ENCODED
+     * @type Number
+     * @description Set form to multipart/form-data encoding for file uploads
+     */
+    MULTIPART_ENCODED: 2
+});
+/**
+ * @class FormField
+ * @extends Widget
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A representation of an individual form field.
+ */
+
+Y.FormField = Y.Base.create('form-field', Y.Widget, [Y.WidgetParent, Y.WidgetChild], {
+    toString: function() {
+        return this.name;
+    },
+
+    /**
+     * @property FormField.FIELD_TEMPLATE
+     * @type String
+     * @description Template used to render the field node
+     */
+    FIELD_TEMPLATE : '<input></input>',
+
+    /**
+     * @property FormField.FIELD_CLASS
+     * @type String
+     * @description CSS class used to locate a placeholder for
+     *     the field node and style it.
+     */
+    FIELD_CLASS : 'field',
+
+    /**
+     * @property FormField.LABEL_TEMPLATE
+     * @type String
+     * @description Template used to draw a label node
+     */
+    LABEL_TEMPLATE : '<label></label>',
+
+    /**
+     * @property FormField.LABEL_CLASS
+     * @type String
+     * @description CSS class used to locate a placeholder for
+     *     the label node and style it.
+     */
+    LABEL_CLASS : 'label',
+
+    /**
+     * @property FormField.HINT_TEMPLATE
+     * @type String
+     * @description Optionally a template used to draw a hint node. Derived
+     *     classes can use it to provide additional information about the field
+     */
+    HINT_TEMPLATE : '',
+
+    /**
+     * @property FormField.HINT_CLASS
+     * @type String
+     * @description CSS class used to locate a placeholder for
+     *     the hint node and style it.
+     */
+    HINT_CLASS : 'hint',
+
+    /**
+     * @property FormField.ERROR_TEMPLATE
+     * @type String
+     * @description Template used to draw an error node
+     */
+    ERROR_TEMPLATE : '<span></span>',
+
+    /**
+     * @property FormField.ERROR_CLASS
+     * @type String
+     * @description CSS class used to locate a placeholder for
+     *     the error node and style it.
+     */
+    ERROR_CLASS : 'error',
+
+    /**
+     * @property _labelNode
+     * @protected
+     * @type Object
+     * @description The label node for this form field
+     */
+    _labelNode: null,
+
+     /**
+     * @property _hintNode
+     * @protected
+     * @type Object
+     * @description The hint node with extra text describing the field
+     */    
+    _hintNode : null,
+
+    /**
+     * @property _fieldNode
+     * @protected
+     * @type Object
+     * @description The form field itself
+     */
+    _fieldNode: null,
+
+    /**
+     * @property _errorNode
+     * @protected
+     * @type Object
+     * @description If a validation error occurs, it will be displayed in this node
+     */
+    _errorNode: null,
+
+    /**
+     * @property _initialValue
+     * @private
+     * @type String
+     * @description The initial value set on this field, reset will set the value to this
+     */
+    _initialValue: null,
+
+    /**
+     * @method _validateError
+     * @protected
+     * @param val {Mixed}
+     * @description Validates the value passed to the error attribute
+     * @return {Boolean}
+     */
+    _validateError: function(val) {
+        if (Y.Lang.isString(val)) {
+            return true;
+        }
+        if (val === null || typeof val == 'undefined') {
+            return true;
+        }
+
+        return false;
+    },
+
+    /**
+     * @method _validateValidator
+     * @protected
+     * @param val {Mixed}
+     * @description Validates the input of the validator attribute
+     * @return {Boolean}
+     */
+    _validateValidator: function(val) {
+        if (Y.Lang.isString(val)) {
+            var validate = /^(email|phone|ip|date|time|postal|special)$/;
+            if (validate.test(val) === true) {
+                return true;
+            }
+        }
+        if (Y.Lang.isFunction(val)) {
+            return true;
+        }
+        return false;
+    },
+
+    /**
+     * @method _setValidator
+     * @protected
+     * @param {val} {String|Function}
+     * @description Sets the validator to the supplied method or if one of the 
+     *              convenience strings is passed, the corresponding utility
+     *              validator
+     * @return {Function}
+     */
+    _setValidator: function(val) {
+        var valMap = {
+            email: Y.FormField.VALIDATE_EMAIL_ADDRESS,
+            phone: Y.FormField.VALIDATE_PHONE_NUMBER,
+            ip: Y.FormField.VALIDATE_IP_ADDRESS,
+            date: Y.FormField.VALIDATE_DATE,
+            time: Y.FormField.VALIDATE_TIME,
+            postal: Y.FormField.VALIDATE_POSTAL_CODE,
+            special: Y.FormField.VALIDATE_NO_SPECIAL_CHARS
+        };
+
+        return (valMap[val] ? valMap[val] : val);
+    },
+
+    /**
+     * @method _renderNode
+     * @protected
+     * @description Helper method to render new nodes, possibly replacing
+     *     markup placeholders.
+     */
+    _renderNode : function (nodeTemplate, nodeClass, nodeBefore) {
+        if (!nodeTemplate) {
+            return null;
+        }
+        var contentBox = this.get('contentBox'),
+            node = Y.Node.create(nodeTemplate),
+            placeHolder = contentBox.one('.' + nodeClass);
+
+        node.addClass(nodeClass);
+
+        if (placeHolder) {
+            placeHolder.replace(node);
+        } else {
+            if (nodeBefore) {
+                contentBox.insertBefore(node, nodeBefore);
+            } else {
+                contentBox.appendChild(node);
+            }
+        }
+
+        return node;
+    },
+
+    /**
+     * @method _renderLabelNode
+     * @protected
+     * @description Draws the form field's label node into the contentBox
+     */
+    _renderLabelNode: function() {
+        var contentBox = this.get('contentBox'),
+        labelNode = contentBox.one('label');
+
+        if (!labelNode || labelNode.get('for') != this.get('id')) {
+            labelNode = this._renderNode(this.LABEL_TEMPLATE, this.LABEL_CLASS);
+        }
+
+        this._labelNode = labelNode;
+    },
+
+    /**
+     * @method _renderHintNode
+     * @protected
+     * @description Draws the hint node into the contentBox. If a node is
+     *     found in the contentBox with class HINT_CLASS, it will be
+     *     considered a markup placeholder and replaced with the hint node.
+     */
+    _renderHintNode : function () {
+        this._hintNode = this._renderNode(this.HINT_TEMPLATE,
+                                          this.HINT_CLASS);
+    },
+
+    /**
+     * @method _renderFieldNode
+     * @protected
+     * @description Draws the field node into the contentBox
+     */
+    _renderFieldNode: function() {
+        var contentBox = this.get('contentBox'),
+        field = contentBox.one('#' + this.get('id'));
+
+        if (!field) {
+            field = this._renderNode(this.FIELD_TEMPLATE, this.FIELD_CLASS);
+        }
+
+        this._fieldNode = field;
+    },
+
+    /**
+     * @method _syncLabelNode
+     * @protected
+     * @description Syncs the the label node and this instances attributes
+     */
+    _syncLabelNode: function() {
+        var label = this.get('label'),
+            required = this.get('required'),
+            requiredLabel = this.get('requiredLabel');
+        if (this._labelNode) {
+            this._labelNode.set("text", "");
+            if (label) {
+                this._labelNode.append("<span class='caption'>" + label + "</span>"); 
+            }
+            if (required && requiredLabel) {
+                this._labelNode.append("<span class='separator'> </span>");
+                this._labelNode.append("<span class='required'>" + requiredLabel + "</span>");
+            }
+            this._labelNode.setAttribute('for', this.get('id') + Y.FormField.FIELD_ID_SUFFIX);
+        }
+    },
+
+    /**
+     * @method _syncHintNode
+     * @protected
+     * @description Syncs the hintNode
+     */
+    _syncHintNode : function () {
+        if (this._hintNode) {
+            this._hintNode.set("text", this.get("hint"));
+        }
+    },
+
+    /**
+     * @method _syncFieldNode
+     * @protected
+     * @description Syncs the fieldNode and this instances attributes
+     */
+    _syncFieldNode: function() {
+        var nodeType = this.name.split('-')[0];
+        if (!nodeType) {
+            return;
+        }
+
+        this._fieldNode.setAttrs({
+            name: this.get('name'),
+            type: nodeType,
+            id: this.get('id') + Y.FormField.FIELD_ID_SUFFIX,
+            value: this.get('value')
+        });
+
+        this._fieldNode.setAttribute('tabindex', Y.FormField.tabIndex);
+        Y.FormField.tabIndex++;
+    },
+
+    /**
+     * @method _syncError
+     * @private
+     * @description Displays any pre-defined error message
+     */
+    _syncError: function() {
+        var err = this.get('error');
+        if (err) {
+            this._showError(err);
+        }
+    },
+
+    _syncDisabled: function(e) {
+        var dis = this.get('disabled');
+        if (dis === true) {
+            this._fieldNode.setAttribute('disabled', 'disabled');
+        } else {
+            this._fieldNode.removeAttribute('disabled');
+        }
+    },
+
+    /**
+     * @method _checkRequired
+     * @private
+     * @description if the required attribute is set to true, returns whether or not a value has been set
+     * @return {Boolean}
+     */
+    _checkRequired: function() {
+        if (this.get('required') === true && this.get('value').length === 0) {
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * @method _showError
+     * @param {String} errMsg
+     * @private
+     * @description Adds an error node with the supplied message
+     */
+    _showError: function(errMsg) {
+        var contentBox = this.get('contentBox'),
+            errorNode = this._renderNode(this.ERROR_TEMPLATE, this.ERROR_CLASS, this._labelNode);
+
+        errorNode.set("text", errMsg);
+        this._errorNode = errorNode;
+    },
+
+    /**
+     * @method _clearError
+     * @private
+     * @description Removes the error node from this field
+     */
+    _clearError: function() {
+        if (this._errorNode) {
+            this._errorNode.remove();
+            this._errorNode = null;
+        }
+    },
+
+    _enableInlineValidation: function() {
+        this.after('valueChange', this.validateField, this);
+    },
+
+    _disableInlineValidation: function() {
+        this.detach('valueChange', this.validateField, this);
+    },
+
+    /**
+     * @method validateField
+     * @description Runs the validation functions of this form field
+     * @return {Boolean}
+     */
+    validateField: function(e) {
+        var value = this.get('value'),
+        validator = this.get('validator');
+
+        this.set('error', null);
+
+        if (e && e.src != 'ui') {
+            return false;
+        }
+
+        if (!this._checkRequired()) {
+            this.set('error', Y.FormField.REQUIRED_ERROR_TEXT);
+            return false;
+        } else if (!value) {
+            return true;
+        }
+
+        return validator.call(this, value, this);
+    },
+
+    resetFieldNode: function() {
+        this.set('value', this._initialValue);
+        this._fieldNode.set('value', this._initialValue);
+        this.fire('nodeReset');
+    },
+
+    /**
+     * @method clear
+     * @description Clears the value AND the initial value of this field
+     */
+    clear: function() {
+        this.set('value', '');
+        this._fieldNode.set('value', '');
+        this._initialValue = null;
+        this.fire('clear');
+    },
+
+    initializer: function() {
+        this.publish('blur');
+        this.publish('change');
+        this.publish('focus');
+        this.publish('clear');
+        this.publish('nodeReset');
+
+        this._initialValue = this.get('value');
+    },
+
+    destructor: function(config) {
+
+    },
+
+    renderUI: function() {
+        this._renderLabelNode();
+        this._renderFieldNode();
+        this._renderHintNode();
+    },
+
+    bindUI: function() {
+        this._fieldNode.on('change', Y.bind(function(e) {
+            this.set('value', this._fieldNode.get('value'), {
+                src: 'ui'
+            });
+        },
+        this));
+
+        this.on('valueChange', Y.bind(function(e) {
+            if (e.src != 'ui') {
+                this._fieldNode.set('value', e.newVal);
+            }
+        },
+        this));
+
+        this._fieldNode.on('blur', Y.bind(function(e) {
+            this.set('value', this._fieldNode.get('value'), {
+                src: 'ui'
+            });
+        },
+        this));
+
+        this._fieldNode.on('focus', Y.bind(function(e) {
+            this.fire('focus', e);
+        },
+        this));
+
+        this.on('errorChange', Y.bind(function(e) {
+            if (e.newVal) {
+                this._showError(e.newVal);
+            } else {
+                this._clearError();
+            }
+        },
+        this));
+
+        this.on('validateInlineChange', Y.bind(function(e) {
+            if (e.newVal === true) {
+                this._enableInlineValidation();
+            } else {
+                this._disableInlineValidation();
+            }
+        },
+        this));
+
+        this.after('disabledChange', Y.bind(function(e) {
+            this._syncDisabled();
+        },
+        this));
+    },
+
+    syncUI: function() {
+        this.get('boundingBox').removeAttribute('tabindex');
+        this._syncLabelNode();
+        this._syncHintNode();
+        this._syncFieldNode();
+        this._syncError();
+        this._syncDisabled();
+
+        if (this.get('validateInline') === true) {
+            this._enableInlineValidation();
+        }
+    }
+},
+{
+    /**
+     * @property FormField.ATTRS
+     * @type Object
+     * @protected
+     * @static
+     */
+    ATTRS: {
+        /**
+         * @attribute id
+         * @type String
+         * @default Either a user defined ID or a randomly generated by Y.guid()
+         * @description A randomly generated ID that will be assigned to the field and used 
+         * in the label's for attribute
+         */
+        id: {
+            value: Y.guid(),
+            validator: Y.Lang.isString,
+            writeOnce: true
+        },
+
+        /**
+         * @attribute name
+         * @type String
+         * @default ""
+         * @writeOnce
+         * @description The name attribute to use on the field
+         */
+        name: {
+            validator: Y.Lang.isString,
+            writeOnce: true
+        },
+
+        /**
+         * @attribute value
+         * @type String
+         * @default ""
+         * @description The current value of the form field
+         */
+        value: {
+            value: '',
+            validator: Y.Lang.isString
+        },
+
+        /**
+         * @attribute label
+         * @type String
+         * @default ""
+         * @description Label of the form field
+         */
+        label: {
+            value: '',
+            validator: Y.Lang.isString
+        },
+
+        /**
+         * @attribute hint
+         * @type String
+         * @default ""
+         * @description Extra text explaining what the field is about.
+         */
+        hint : {
+            value : '',
+            validator : Y.Lang.isString
+        },
+        
+        /**
+         * @attribute validator
+         * @type Function
+         * @default "function () { return true; }"
+         * @description Used to validate this field by the Form class
+         */
+        validator: {
+            value: function(val) {
+                return true;
+            },
+            validator: function(val) {
+                return this._validateValidator(val);
+            },
+            setter: function(val) {
+                return this._setValidator(val);
+            }
+        },
+
+        /**
+         * @attribute error
+         * @type String
+         * @description An error message associated with this field. Setting this will
+         *              cause validation to fail until a new value is entered
+         */
+        error: {
+            value: false,
+            validator: function(val) {
+                return this._validateError(val);
+            }
+        },
+
+        /**
+         * @attribute required
+         * @type Boolean
+         * @default false
+         * @description Set true if this field must be filled out when submitted
+         */
+        required: {
+            value: false,
+            validator: Y.Lang.isBoolean
+        },
+
+        /**
+         * @attribute validateInline
+         * @type Boolean
+         * @default false
+         * @description Set to true to validate this field whenever it's value is changed
+         */
+        validateInline: {
+            value: false,
+            validator: Y.Lang.isBoolean
+        },
+
+        /**
+         * @attribute requiredLabel
+         * @type String
+         * @description Text to append to the labal caption for a required
+         *     field, by default nothing will be appended.
+         */
+        requiredLabel : {
+            value : '',
+            validator : Y.Lang.isString
+        }
+    },
+
+    /**
+     * @property FormField.tabIndex
+     * @type Number
+     * @description The current tab index of all Y.FormField instances
+     */
+    tabIndex: 1,
+
+    /**
+     * @method FormField.VALIDATE_EMAIL_ADDRESS
+     * @static
+     * @description Utility function to validate an email address
+     */
+    VALIDATE_EMAIL_ADDRESS: function(val, field) {
+        var filter = /^([\w]+(?:\.[\w]+)*)@((?:[\w]+\.)*\w[\w]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
+        if (filter.test(val) === false) {
+            field.set('error', Y.FormField.INVALID_EMAIL_MESSAGE);
+            return false;
+        }
+
+        return true;
+    },
+
+    /**
+     * @property FormField.INVALID_EMAIL_MESSAGE
+     * @type String
+     * @description Message to display when an invalid email address is entered
+     */
+    INVALID_EMAIL_MESSAGE: "Please enter a valid email address",
+
+    /**
+     * @method FormField.VALIDATE_PHONE_NUMBER
+     * @static
+     * @description Utility function to validate US and international phone numbers
+     */
+    VALIDATE_PHONE_NUMBER: function(val, field) {
+        var filter = /^((\+\d{1,3}(-| )?\(?\d\)?(-| )?\d{1,5})|(\(?\d{2,6}\)?))(-| )?(\d{3,4})(-| )?(\d{4})(( x| ext)\d{1,5}){0,1}$/;
+        if (filter.test(val) === false) {
+            field.set('error', Y.FormField.INVALID_PHONE_NUMBER);
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * @property FormField.INVALID_PHONE_NUMBER
+     * @type String
+     * @description Message to display when an invalid phone number is entered
+     */
+    INVALID_PHONE_NUMBER: "Please enter a valid phone number",
+
+    /**
+     * @method FormField.VALIDATE_IP_ADDRESS
+     * @static
+     * @description Utility function to validate IPv4 addresses
+     */
+    VALIDATE_IP_ADDRESS: function(val, field) {
+        var filter = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
+        arr,
+        valid = true;
+
+        if (filter.test(val) === false) {
+            valid = false;
+        }
+
+        arr = val.split(".");
+        Y.Array.each(arr,
+        function(v, i, a) {
+            var n = parseInt(v, 10);
+            if (n < 0 || n > 255) {
+                valid = false;
+            }
+        });
+
+        if (valid === false) {
+            field.set('error', Y.FormField.INVALID_IP_MESSAGE);
+        }
+
+        return valid;
+    },
+
+    /**
+     * @property FormField.INVALID_IP_MESSAGE
+     * @type String
+     * @description Message to display when an invalid IP address is entered
+     */
+    INVALID_IP_MESSAGE: "Please enter a valid IP address",
+
+    /**
+     * @method FormField.VALIDATE_DATE
+     * @static
+     * @description Utility function to validate dates
+     */
+    VALIDATE_DATE: function(val, field) {
+        var filter = /^([1-9]|1[0-2])(\-|\/)([0-2][0-9]|3[0-1])(\-|\/)(\d{4}|\d{2})$/;
+        if (filter.test(val) === false) {
+            field.set('error', Y.FormField.INVALID_DATE_MESSAGE);
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * @property FormField.INVALID_DATE_MESSAGE
+     * @type String
+     * @description Message to display when an invalid date is entered
+     */
+    INVALID_DATE_MESSAGE: "Please enter a a valid date",
+
+    /**
+     * @method FormField.VALIDATE_TIME
+     * @static
+     * @description Utility function to validate times
+     */
+    VALIDATE_TIME: function(val, field) {
+        var filter = /^([1-9]|1[0-2]):[0-5]\d(:[0-5]\d(\.\d{1,3})?)?$/;
+        if (filter.test(val) === false) {
+            field.set('error', Y.FormField.INVALID_TIME_MESSAGE);
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * @property FormField.INVALID_TIME_MESSAGE
+     * @type String
+     * @description Message to display when an invalid time is entered
+     */
+    INVALID_TIME_MESSAGE: "Please enter a valid time",
+
+    /**
+     * @method FormField.VALIDATE_POSTAL_CODE
+     * @static
+     * @description Utility function to validate US and international postal codes
+     */
+    VALIDATE_POSTAL_CODE: function(val, field) {
+        var filter,
+        valid = true;
+
+        if (val.length == 6 || val.length == 7) {
+            filter = /^[a-zA-Z]\d[a-zA-Z](-|\s)?\d[a-zA-Z]\d$/;
+        } else if (val.length == 5 || val.length == 10) {
+            filter = /^\d{5}((-|\s)\d{4})?$/;
+        } else if (val.length > 0) {
+            valid = false;
+        }
+
+        if (valid === false || (filter && filter.test(val) === false)) {
+            field.set('error', Y.FormField.INVALID_POSTAL_CODE_MESSAGE);
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * @property FormField.INVALID_POSTAL_CODE_MESSAGE
+     * @type String
+     * @description Message to display when an invalid postal code is entered
+     */
+    INVALID_POSTAL_CODE_MESSAGE: "Please enter a valid postal code",
+
+    /**
+     * @method FormField.VALIDATE_NO_SPECIAL_CHARS
+     * @static
+     * @description Utility function to validate only alphanumeric characters
+     */
+    VALIDATE_NO_SPECIAL_CHARS: function(val, field) {
+        var filter = /^[a-zA-Z0-9]*$/;
+        if (filter.test(val) === false) {
+            field.set('error', Y.FormField.INVALID_SPECIAL_CHARS);
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * @property FormField.INVALID_SPECIAL_CHARS
+     * @type String
+     * @description Message to display when invalid characters are entered
+     */
+    INVALID_SPECIAL_CHARS: "Please use only letters and numbers",
+
+    /**
+    /**
+     * @property FormField.REQUIRED_ERROR_TEXT
+     * @type String
+     * @description Error text to display for a required field
+     */
+    REQUIRED_ERROR_TEXT: 'This field is required',
+
+    /**
+     * @property FormField.FIELD_ID_SUFFIX
+     * @type String
+     */
+    FIELD_ID_SUFFIX: '-field'
+});
+/**
+ * @class TextField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A text field node
+ */
+Y.TextField = Y.Base.create('text-field', Y.FormField, [Y.WidgetChild]);
+/**
+ * @class PasswordField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A password field node
+ */
+Y.PasswordField = Y.Base.create('password-field', Y.FormField, [Y.WidgetChild]);
+/**
+ * @class CheckboxField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A checkbox field node
+ */
+
+Y.CheckboxField = Y.Base.create('checkbox-field', Y.FormField, [Y.WidgetChild], {
+    _syncChecked : function () {
+        this._fieldNode.set('checked', this.get('checked'));
+    },
+
+    initializer : function () {
+        Y.CheckboxField.superclass.initializer.apply(this, arguments);
+    },
+
+    syncUI : function () {
+        Y.CheckboxField.superclass.syncUI.apply(this, arguments);
+        this._syncChecked();
+    },
+
+    bindUI :function () {
+        Y.CheckboxField.superclass.bindUI.apply(this, arguments);
+        this.after('checkedChange', Y.bind(function(e) {
+            if (e.src != 'ui') {
+                this._fieldNode.set('checked', e.newVal);
+            }
+        }, this));
+
+        this._fieldNode.after('change', Y.bind(function (e) {
+            this.set('checked', e.currentTarget.get('checked'), {src : 'ui'});
+        }, this));
+    }
+}, {
+    ATTRS : {
+        'checked' : {
+            value : false,
+            validator : Y.Lang.isBoolean
+        }
+    }
+});
+/**
+ * @class RadioField
+ * @extends CheckboxField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A Radio field node
+ */
+Y.RadioField = Y.Base.create('radio-field', Y.FormField, [Y.WidgetChild]);
+/**
+ * @class HiddenField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A hidden field node
+ */
+Y.HiddenField = Y.Base.create('hidden-field', Y.FormField, [Y.WidgetChild], {
+    /**
+     * @property _valueDisplayNode
+     * @protected
+     * @type Y.Node
+     * @description Node used to display the value of this field
+     */
+    _valueDisplayNode: null,
+
+    _renderValueDisplayNode: function() {
+        if (this.get('displayValue') === true) {
+            var div = Y.Node.create('<div></div>'),
+            contentBox = this.get('contentBox');
+
+            contentBox.appendChild(div);
+            this._valueDisplayNode = div;
+        }
+    },
+
+    renderUI: function() {
+        Y.HiddenField.superclass.renderUI.apply(this, arguments);
+        this._renderValueDisplayNode();
+    },
+
+    bindUI: function() {
+        Y.HiddenField.superclass.bindUI.apply(this, arguments);
+
+        if (this.get('displayValue') === true) {
+            this.after('valueChange', Y.bind(function(m, e) {
+                this._valueDisplayNode.set('innerHTML', e.newVal);
+            },
+            this, true));
+        }
+    },
+
+    clear: function() {}
+},
+{
+    /**
+	 * @property HiddenField.ATTRS
+	 * @type Object
+	 * @static
+	 */
+    ATTRS: {
+        /**
+		 * @attribute displayValue
+		 * @type Boolean
+		 * @default false
+		 * @writeOnce
+		 * @description Set to true to render this field with node displaying the current value
+		 */
+        displayValue: {
+            value: false,
+            writeOnce: true,
+            validator: Y.Lang.isBoolean
+        }
+    }
+
+});
+/**
+ * @class TextareaField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A hidden field node
+ */
+Y.TextareaField = Y.Base.create('textarea-field', Y.FormField, [Y.WidgetChild], {
+
+    FIELD_TEMPLATE : '<textarea></textarea>'
+
+});
+/**
+ * @class ChoiceField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A form field which allows one or multiple values from a 
+ * selection of choices
+ */
+Y.ChoiceField = Y.Base.create('choice-field', Y.FormField, [Y.WidgetParent, Y.WidgetChild], {
+
+    LABEL_TEMPLATE: '<span></span>',
+    SINGLE_CHOICE: Y.RadioField,
+    MULTI_CHOICE: Y.CheckboxField,
+
+    /**
+     * @method _validateChoices
+     * @protected
+     * @param {Object} val
+     * @description Validates the value passe to the choices attribute
+     */
+    _validateChoices: function(val) {
+        if (!Y.Lang.isArray(val)) {
+            return false;
+        }
+
+        var i = 0,
+        len = val.length;
+
+        for (; i < len; i++) {
+            if (!Y.Lang.isObject(val[i])) {
+                delete val[i];
+                continue;
+            }
+            if (!val[i].label ||
+            !Y.Lang.isString(val[i].label) ||
+            !val[i].value ||
+            !Y.Lang.isString(val[i].value)) {
+                delete val[i];
+                continue;
+            }
+        }
+
+        if (val.length === 0) {
+            return false;
+        }
+
+        return true;
+    },
+
+    _renderFieldNode: function() {
+        var contentBox = this.get('contentBox'),
+            parent = contentBox.one("." + this.FIELD_CLASS),
+            choices = this.get('choices'),
+            multiple = this.get('multi'),
+            fieldType = (multiple === true ? this.MULTI_CHOICE: this.SINGLE_CHOICE);
+
+        if (!parent) {
+            parent = contentBox;
+        }
+        Y.Array.each(choices,
+        function(c, i, a) {
+            var cfg = {
+                value: c.value,
+                id: (this.get('id') + '_choice' + i),
+                name: this.get('name'),
+                label: c.label
+            },
+            field = new fieldType(cfg);
+
+            field.render(parent);
+        }, this);
+        this._fieldNode = parent.all('input');
+    },
+
+    _syncFieldNode: function() {
+        var choices = this.get('value').split(',');
+
+        if (choices && choices.length > 0) {
+            Y.Array.each(choices, function(choice) {
+                this._fieldNode.each(function(node, index, list) {
+                    if (Y.Lang.trim(node.get('value')) == Y.Lang.trim(choice)) {
+                        node.set('checked', true);
+                        return true;
+                    }
+                }, this);
+            }, this);
+        }
+    },
+
+    /**
+     * @method _afterChoiceChange
+     * @description When the available choices for the choice field change,
+     *     the old ones are removed and the new ones are rendered.
+     */
+    _afterChoicesChange: function(event) {
+        var contentBox = this.get("contentBox");
+        contentBox.all(".yui3-form-field").remove();
+        this._renderFieldNode();
+    },
+
+    clear: function() {
+        this._fieldNode.each(function(node, index, list) {
+            node.set('checked', false);
+        },
+        this);
+
+        this.set('value', '');
+    },
+
+    bindUI: function() {
+        this._fieldNode.on('change', Y.bind(function(e) {
+            var value = '';
+            this._fieldNode.each(function(node, index, list) {
+                if (node.get('checked') === true) {
+                    if (value.length > 0) {
+                        value += ',';
+                    }
+                    value += node.get('value');
+                }
+            }, this);
+            this.set('value', value);
+        },
+        this));
+        this.after('choicesChange', this._afterChoicesChange);
+    }
+
+},
+{
+    ATTRS: {
+        /** 
+         * @attribute choices
+         * @type Array
+         * @description The choices to render into this field
+         */
+        choices: {
+            validator: function(val) {
+                return this._validateChoices(val);
+            }
+        },
+
+        /** 
+         * @attribute multi
+         * @type Boolean
+         * @default false
+         * @description Set to true to allow multiple values to be selected
+         */
+        multi: {
+            validator: Y.Lang.isBoolean,
+            value: false
+        }
+    }
+});
+/**
+ * @class SelectField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A select field node
+ */
+Y.SelectField = Y.Base.create('select-field', Y.ChoiceField, [Y.WidgetParent, Y.WidgetChild], {
+
+    FIELD_TEMPLATE : '<select></select>',
+
+    /**
+     * @property SelectField.DEFAULT_OPTION_TEXT
+     * @type String
+     * @description The display title of the default choice in the select box
+     */
+    DEFAULT_OPTION_TEXT : 'Choose one',	
+
+    /**
+	 * @method _renderFieldNode
+	 * @protected
+	 * @description Draws the select node into the contentBox
+	 */
+    _renderFieldNode: function() {
+        Y.SelectField.superclass.constructor.superclass._renderFieldNode.apply(this, arguments);
+        this._renderOptionNodes();
+    },
+
+    /**
+	 * @method _renderOptionNodes
+	 * @protected
+	 * @description Renders the option nodes into the select node
+	 */
+    _renderOptionNodes: function() {
+        var choices = this.get('choices'),
+        elOption;
+
+        // Create the "Choose one" option
+        if (this.get('useDefaultOption') === true) {
+            elOption = Y.Node.create(Y.SelectField.OPTION_TEMPLATE);
+            this._fieldNode.appendChild(elOption);
+        }
+
+        Y.Array.each(choices,
+        function(c, i, a) {
+            elOption = Y.Node.create(Y.SelectField.OPTION_TEMPLATE);
+            this._fieldNode.appendChild(elOption);
+        },
+        this);
+    },
+
+    /**
+	 * @method _syncFieldNode
+	 * @protected
+	 * @description Syncs the select node with the instance attributes
+	 */
+    _syncFieldNode: function() {
+        Y.SelectField.superclass.constructor.superclass._syncFieldNode.apply(this, arguments);
+
+        this._fieldNode.setAttrs({
+            size : this.get('size'),
+            multiple: (this.get('multi') === true ? 'multiple': '')
+        });
+    },
+
+    /**
+	 * @method _syncOptionNodes
+	 * @protected
+	 * @description Syncs the option nodes with the choices attribute
+	 */
+    _syncOptionNodes: function() {
+        var choices = this.get('choices'),
+        contentBox = this.get('contentBox'),
+        options = contentBox.all('option'),
+        useDefaultOption = this.get('useDefaultOption'),
+        currentVal = this.get('value');
+
+        if (useDefaultOption === true) {
+            choices.unshift({
+                label : this.DEFAULT_OPTION_TEXT,
+                value: ''
+            });
+        }
+
+        options.each(function(node, index, nodeList) {
+            var label = choices[index].label,
+            val = choices[index].value;
+
+            node.setAttrs({
+                innerHTML: label,
+                value: val
+            });
+
+            if (currentVal == val) {
+                node.setAttrs({
+                    selected: true,
+                    defaultSelected: true
+                });
+            }
+        },
+        this);
+    },
+
+    /**
+     * @method _afterChoiceChange
+     * @description When the available options for the select field change,
+     *     the old ones are removed and the new ones are rendered.
+     */
+    _afterChoicesChange: function(evt) {
+        var options = this._fieldNode.all("option");
+        options.remove();
+        this._renderOptionNodes();
+        this._syncOptionNodes();
+    },
+
+    /**
+	 * @method clear
+	 * @description Restores the selected option to the default
+	 */
+    clear: function() {
+        this._fieldNode.value = '';
+    },
+
+    bindUI: function() {
+        Y.SelectField.superclass.constructor.superclass.bindUI.apply(this, arguments);
+        this.after('choicesChange', this._afterChoicesChange);
+    },
+
+    syncUI: function() {
+        Y.SelectField.superclass.syncUI.apply(this, arguments);
+        this._syncOptionNodes();
+    }
+},
+{
+    /**
+	 * @property SelectField.OPTION_TEMPLATE
+	 * @type String
+	 * @description Template used to draw an option node
+	 */
+    OPTION_TEMPLATE: '<option></option>',
+
+    ATTRS: {
+        /**
+	     * @attribute useDefaultOption
+	     * @type Boolean
+	     * @default true
+	     * @description If true, the first option will use the DEFAULT_OPTION_TEXT
+	     *              to create a blank option
+	     */
+        useDefaultOption: {
+            validator: Y.Lang.isBoolean,
+            value: true
+        },
+
+        /** 
+         * @attribute choices
+         * @type Array
+         * @description The choices to render into this field
+         */
+        choices: {
+            validator: function(val) {
+                if (this.get("useDefaultOption") &&
+                    Y.Lang.isArray(val) &&
+                    val.length === 0) {
+                    // Empty arrays are okay if useDefaultOption is 'true'
+                    return true;
+                } else {
+                    return this._validateChoices(val);
+                }
+            }
+        },
+
+        /**
+         * @attribute size
+         * @type String
+         * @default 0
+         * @description Value of 'size' attribute of the select element.
+         */
+        size : {
+            validator : Y.Lang.isString,
+            value : '0'
+        }
+    }
+});
+Y.FormButton = Y.Base.create('button-field', Y.FormField, [Y.WidgetChild], {
+
+    FIELD_TEMPLATE : '<button></button>',
+    LABEL_TEMPLATE: '',
+
+    _syncFieldNode : function () {
+        this._fieldNode.setAttrs({
+            innerHTML : this.get('label'),
+            id : this.get('id') + Y.FormField.FIELD_ID_SUFFIX
+        });
+        
+        this.get('contentBox').addClass('first-child');
+    },
+
+    _setClickHandler : function () {
+        if (!this._fieldNode) {
+            return;
+        }
+
+        Y.Event.purgeElement(this._fieldNode, true, 'click');
+        Y.on('click', Y.bind(this._promptConfirm, this), this._fieldNode);
+    },
+
+    _promptConfirm: function(event) {
+        event.preventDefault();
+        var message = this.get("message");
+        var onclick = this.get("onclick");
+
+        if (message) {
+            if (!this.get("confirm")(message)) {
+                return;
+            }
+        }
+        onclick.fn.apply(onclick.scope);
+    },
+
+    bindUI : function () {
+        this.after('onclickChange', Y.bind(this._setClickHandler, this, true));
+        this.after('disabledChange', this._syncDisabled, this);
+        this._setClickHandler();
+    }
+}, {
+    ATTRS : {
+        onclick : {
+            validator : function (val) {
+                if (Y.Lang.isObject(val) === false) {
+                    return false;
+                }
+                if (typeof val.fn == 'undefined' ||
+                    Y.Lang.isFunction(val.fn) === false) {
+                    return false;
+                }
+                return true;
+            },
+            value : {
+                fn : function (e) {
+
+                }
+            },
+            setter : function (val) {
+                val.scope = val.scope || this;
+                val.argument = val.argument || {};
+                return val;
+            }
+        },
+
+        /** 
+         * @attribute message
+         * @type String
+         * @default null
+         * @description Optional confirmation message to be passed to the
+         *     confirm function.
+         */
+        message: {
+            validator : Y.Lang.isString,
+            value: null
+        },
+
+        /** 
+         * @attribute confirm
+         * @type Function
+         * @default null
+         * @description Optional confirmation function called when the button
+         *     is clicked. It will be be passed the string set in the 'message'
+         *     attribute. If it returns 'true' the the onclick handler will be
+         *     called, otherwise it will be skipped.
+         */
+        confirm:  {
+            validator : Y.Lang.isFunction,
+            value: null
+        }
+    }
+});
+/**
+ * @class FileField
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A file field node
+ */
+
+Y.FileField = Y.Base.create('file-field', Y.FormField, [Y.WidgetChild]);
+/**
+ * @class SubmitButton
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A submit button
+ */
+Y.SubmitButton = Y.Base.create('submit-button', Y.FormField, [Y.WidgetChild], {
+    _renderLabelNode : function () {}
+});
+/**
+ * @class ResetButton
+ * @extends FormField
+ * @param config {Object} Configuration object
+ * @constructor
+ * @description A reset button
+ */
+Y.ResetButton = Y.Base.create('reset-button', Y.FormField, [Y.WidgetChild], {
+    LABEL_TEMPLATE: ''
+});
+
+
+}, 'gallery-2011.03.14-10-00' ,{"requires":["node", "widget-base", "widget-htmlparser", "io-form", "widget-parent", "widget-child", "base-build", "substitute", "io-upload-iframe"]});

=== added directory 'lib/lp/app/javascript/lazr/inlineedit'
=== added directory 'lib/lp/app/javascript/lazr/inlineedit/assets'
=== added file 'lib/lp/app/javascript/lazr/inlineedit/assets/editor-core.css'
--- lib/lp/app/javascript/lazr/inlineedit/assets/editor-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/inlineedit/assets/editor-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,37 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+.yui3-ieditor-hidden,
+.yui3-ieditor-errors-hidden,
+.yui3-editable_text-hidden,
+.yui3-editable_text-edit_mode .yui3-editable_text-trigger,
+.yui3-editable_text-edit_mode .yui3-editable_text-text
+{ display: none; }
+
+/* By default, input elements don't inherit these properties, but
+ * in an inline editing context, it makes sense to do so.
+ */
+.yui3-ieditor-input {
+    color: inherit;
+    font: inherit;
+}
+
+/* Konqueror doesn't render the multiline editor's button if there's
+ * no apparent content in it (the sprite we use is a background image).
+ * This bit of CSS is meaningless; it sets the edit link's content to
+ * a zero-width non-joiner (an invisible, odorless Unicode character).
+ *
+ * Browsers should ignore the content attribute for :link and :visited
+ * pseudo-classes, but Konqueror doesn't.  Setting non-empty text
+ * content here tricks it into rendering the button.  
+ *
+ * Other things we tried instead of this hack:
+ *  - Insert an HTML comment in the <a>.  No effect.
+ *  - Insert a &nbsp; in the <a>.  No effect.
+ *  - Insert whitespace in the <a>.  No effect.
+ *  - Insert a span or div in the <a>.  No effect.
+ *  - Use a regular <img> tag instead of a sprite.  Ugly in all browsers.
+ *  - Set the content to ".".  Ugly in Konqueror.
+ */
+.yui3-editable_text-trigger:link, .yui3-editable_text-trigger:visited {
+    content: "\200c";
+}

=== added directory 'lib/lp/app/javascript/lazr/inlineedit/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/edit.png'
Binary files lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/edit.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/edit.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/editor-skin.css'
--- lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/editor-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/editor-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,198 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+.yui3-skin-sam .yui3-ieditor-errors {
+    padding: 0.2em 0 0.5em 0.5em;
+    font-family: sans-serif;
+    color: red;
+}
+
+.yui3-skin-sam .yui3-ieditor-in-error {
+    background-color: #FFE4E4;
+    border: 4px solid red;
+    padding-right: 6px;
+    margin: -5px -10px -4px -4px;
+}
+
+.yui3-skin-sam .yui3-ieditor-in-error .bg-top-label {
+    margin-top: 1px;
+}
+
+.yui3-skin-sam .yui3-ieditor-waiting .yui3-ieditor-btns button {
+    visibility: hidden;
+}
+
+.yui3-skin-sam .yui3-ieditor-input {
+    width: 100%;
+    margin-right: 68px;
+    margin-top: .5em;
+}
+
+.yui3-skin-sam .yui3-ieditor-btns {
+    display: block;
+    right: -66px;
+    top: 30%;
+}
+
+.yui3-skin-sam .yui3-ieditor-singleline .yui3-ieditor-btns {
+    position: absolute;
+    width: 60px;
+}
+
+.yui3-skin-sam .yui3-ieditor-multiline .yui3-ieditor-btns {
+    text-align: right;
+    width: 100%;
+    clear: both;
+}
+
+.yui3-skin-sam .yui3-ieditor-multiline .yui3-ieditor-btns div {
+    /* Counteract the -4px indent of the editable text content body. */
+    margin-right: -5px;
+}
+
+.yui3-skin-sam .yui3-ieditor-multiline .yui3-ieditor-btns .bg-top-label {
+    background-position: right bottom;
+    background-image: url('label-top.png');
+    background-repeat: no-repeat;
+    height: 29px;
+    line-height: 29px;
+    width: 81px;
+    float: right;
+}
+
+.yui3-skin-sam .yui3-ieditor-multiline .yui3-ieditor-btns .bg-bottom-label {
+    background-position: right top;
+    background-image: url('label-bottom.png');
+    background-repeat: no-repeat;
+    height: 29px;
+    line-height: 29px;
+    width: 81px;
+    float: right;
+}
+
+.yui3-skin-sam .yui3-ieditor-waiting .yui3-ieditor-btns {
+    background: url("../../../../lazr/assets/skins/sam/spinner.gif") 0.2em 0em no-repeat;
+}
+
+/*
+ * Make sure the editor input appears in exactly the same place as the
+ * existing text, ideally without reflowing the page.
+ */
+.yui3-skin-sam .yui3-ieditor-content {
+    position: relative;
+    top: -4px;
+    left: -4px;
+}
+
+/* Multi-line editor styles. */
+.lazr-multiline-edit {
+    font-family: sans-serif;
+    font-size: 12px;
+    margin-bottom: 25px;
+    margin-top: -10px;
+}
+
+.lazr-multiline-edit h2 {
+    font-family: sans-serif;
+    font-size: 12px;
+    font-weight: bold;
+    color: #717171;
+    margin-left: 1.5em;
+    margin-bottom: 0;
+    padding-top: 8px;
+    position: relative;
+    bottom: 5px;
+}
+
+.lazr-multiline-edit .edit-controls {
+    background-position: right top;
+    background-image: url('label-top-white.png');
+    background-repeat: no-repeat;
+    float:right;
+    position: relative;
+    top: 1px;
+    height: 29px;
+    width: 81px;
+    line-height: 29px;
+    text-align:right;
+    margin-top: -6px;
+    z-index: 1;
+}
+
+.lazr-multiline-edit .edit-controls-hover,
+.yui3-editable_text-edit_mode .lazr-multiline-edit .edit-controls {
+    background-position: right top;
+    background-image: url('label-top.png');
+    background-repeat: no-repeat;
+}
+
+.lazr-multiline-edit .yui3-editable_text-text {
+    border-top: 1px solid #d6d6d6;
+    padding:5px 10px 3px 20px;
+}
+
+.lazr-multiline-edit .yui3-editable_text-text-hover {
+    padding:5px 9px 2px 19px;
+    background-color: #fafafa !important;
+    border: 1px solid #d6d6d6;
+}
+
+.lazr-multiline-edit p {
+    margin-bottom: 1.2em;
+}
+
+.lazr-multiline-edit .yui3-ieditor-input {
+    padding: 3px 9px 14px 17px;
+    border:0;
+    border: 1px solid #d6d6d6;
+    border-left: 2px groove #D6D6D6;
+    border-top: 2px groove #D6D6D6;
+    position: relative;
+    left: 5px;
+    top: -6px;
+    line-height: 1.2em;
+}
+
+.yui3-editable_text-edit_mode .lazr-multiline-edit .yui3-ieditor-input {
+    background-color: #fafafa;
+}
+
+.lazr-multiline-edit .yui3-ieditor-multiline {
+    border-top: 1px solid #d6d6d6;
+}
+
+.lazr-multiline-edit .yui3-ieditor-content {
+    top: -29px;
+    left: -5px;
+}
+
+.lazr-multiline-edit .yui3-ieditor-submit_button {
+    position: relative;
+    top: 1px;
+    left: -7px;
+    z-index: 2;
+}
+
+.lazr-multiline-edit .yui3-ieditor-cancel_button {
+    position: relative;
+    top: 1px;
+    left: 16px;
+    margin-right: 8px;
+    z-index: 2;
+}
+
+.lazr-multiline-edit .loading {
+    position: relative;
+    top: 4px;
+    left: -8px;
+    background: url('../../../../lazr/assets/skins/sam/spinner.gif') top left no-repeat;
+    padding: 2px 0 0 18px;
+    z-index: 2;
+}
+
+.lazr-multiline-edit .edit {
+  position: relative;
+  top: 2px;
+  right: 10px;
+  background: url('edit.png') top left no-repeat;
+  padding: 2px 0 0 18px;
+}

=== added file 'lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-bottom.png'
Binary files lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-bottom.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-bottom.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-top-white.png'
Binary files lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-top-white.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-top-white.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-top.png'
Binary files lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-top.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/inlineedit/assets/skins/sam/label-top.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/inlineedit/editor.js'
--- lib/lp/app/javascript/lazr/inlineedit/editor.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/inlineedit/editor.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,1518 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.editor', function(Y) {
+
+/**
+ * Edit any on-screen text in-place.
+ *
+ * @module lazr.editor
+ */
+
+/**
+ * This class provides the ability to turn a static HTML text field into
+ * a form and text input on-demand.
+ *
+ * @class InlineEditor
+ * @extends Widget
+ * @constructor
+ */
+
+var EDITOR = 'ieditor',
+    BOUNDING_BOX = 'boundingBox',
+    CONTENT_BOX = 'contentBox',
+
+    INPUT_EL = 'input_field',
+    ERROR_MSG = 'error_message',
+
+    HIDDEN = 'hidden',
+    VALUE = 'value',
+    INITIAL_VALUE_OVERRIDE = 'initial_value_override',
+    SIZE = 'size',
+    IN_ERROR = 'in_error',
+    RENDERED = 'rendered',
+    CLICK = 'click',
+    ACCEPT_EMPTY = 'accept_empty',
+    MULTILINE = 'multiline',
+    SCROLLBAR_LEGROOM = 30,
+    MULTILINE_MIN_LINES = 2,
+
+    TOP_BUTTONS = 'top_buttons',
+    BOTTOM_BUTTONS = 'bottom_buttons',
+    SUBMIT_BUTTON = 'submit_button',
+    CANCEL_BUTTON = 'cancel_button',
+    BUTTONS = 'buttons',
+    B_TOP = 'top',
+    B_BOTTOM = 'bottom',
+    B_BOTH = 'both',
+    LOADING = 'loading',
+
+    createNode = Y.Node.create,
+    getCN = Y.ClassNameManager.getClassName,
+
+    C_INPUT = getCN(EDITOR, 'input'),
+    C_SUBMIT = getCN(EDITOR, 'submit_button'),
+    C_CANCEL = getCN(EDITOR, 'cancel_button'),
+    C_BTNBOX = getCN(EDITOR, 'btns'),
+    C_MULTILINE = getCN(EDITOR, 'multiline'),
+    C_SINGLELINE = getCN(EDITOR, 'singleline'),
+    C_WAITING = getCN(EDITOR, 'waiting'),
+    C_ERROR = getCN(EDITOR, 'errors'),
+    C_IN_ERROR = getCN(EDITOR, 'in-error'),
+    C_ERROR_HIDDEN = getCN(EDITOR, 'errors', HIDDEN),
+
+    SAVE = 'save',
+    CANCEL = 'cancel',
+    SHRINK = 'shrink',
+    RESIZED = 'resized';
+
+// To strip the 'px' unit suffix off widget sizes.
+var strip_px = /px$/;
+
+var InlineEditor = function() {
+    InlineEditor.superclass.constructor.apply(this, arguments);
+};
+
+InlineEditor.NAME = EDITOR;
+
+/**
+ * Static object hash used to capture existing markup for progressive
+ * enhancement.
+ *
+ * @property InlineEditor.HTML_PARSER
+ * @type Object
+ * @static
+ */
+InlineEditor.HTML_PARSER = {
+    error_message: '.' + C_ERROR
+};
+
+/**
+ * Static html template to use for creating the 'Submit' button.
+ *
+ * @property InlineEditor.SUBMIT_TEMPLATE
+ * @type string
+ * @static
+ */
+InlineEditor.SUBMIT_TEMPLATE = Y.lazr.ui.OK_BUTTON;
+
+/**
+ * Static html template to use for creating the 'Cancel' button.
+ *
+ * @property InlineEditor.CANCEL_TEMPLATE
+ * @type string
+ * @static
+ */
+InlineEditor.CANCEL_TEMPLATE = Y.lazr.ui.CANCEL_BUTTON;
+
+/**
+ * Static html template to use for creating the editor's <input> field.
+ *
+ * @property InlineEditor.INPUT_TEMPLATE
+ * @type string
+ * @static
+ */
+InlineEditor.INPUT_TEMPLATE = "<textarea></textarea>";
+
+
+InlineEditor.ATTRS = {
+    /**
+     * Determines if the editor will accept the empty string as a
+     * valid value.
+     *
+     * @attribute accept_empty
+     * @type boolean
+     */
+    accept_empty: {
+        value: false
+    },
+
+    /**
+     * Determines whether the editor will accept multiple lines of input.
+     * Besides layout, this will affect what the enter key does: in
+     * single-line mode it submits, in multi-line mode it inserts a
+     * newline.
+     *
+     * @attribute multiline
+     * @type boolean
+     * @default false
+     */
+    multiline: {
+        value: false
+    },
+
+    /**
+     * Node that will serve as the user's input.
+     *
+     * @attribute input_field
+     * @type Node
+     * @default null
+     */
+    input_field: {
+        value: null
+    },
+
+    /**
+     * Y.Node representing the 'Submit' button.
+     *
+     * @attribute submit_button
+     * @type Node
+     * @default null
+     */
+    submit_button: {
+        value: null,
+        setter: function(v) { return this._setNode(v); }
+    },
+
+    /**
+     * Y.Node that will be drawn as the 'Cancel' button.
+     *
+     * @attribute cancel_button
+     * @type Node
+     * @default null
+     */
+    cancel_button: {
+        value: null,
+        setter: function(v) { return this._setNode(v); }
+    },
+
+    /**
+     * Y.Node for the bar holding the top buttons.
+     *
+     * @attribute top_buttons
+     * @type Node
+     * @default null
+     */
+    top_buttons: {
+        value: null,
+        setter: function(v) { return this._setNode(v); }
+    },
+
+    /**
+     * Y.Node for the bar holding the bottom buttons.
+     *
+     * @attribute bottom_buttons
+     * @type Node
+     * @default null
+     */
+    bottom_buttons: {
+        value: null,
+        setter: function(v) { return this._setNode(v); }
+    },
+
+    /**
+     * A node that will display any widget errors.
+     *
+     * @attribute error_message
+     * @type Node
+     */
+    error_message: {
+        value: null,
+        setter: function(v) { return this._setNode(v); }
+    },
+
+    /**
+     * The value of the widget's text input, and its value after saving.
+     *
+     * @attribute value
+     * @type String
+     * @default The empty string
+     */
+    value: {
+        value: '',
+        validator: function(v) { return v !== null; }
+    },
+
+    /**
+     * When not null, this overrides the initial value for the <input> field.
+     * Otherwise, the initial value is taken from the widget's <span>
+     * element.  The caller must provide this because when accept_empty is
+     * true, the editor widget has no way to distinguish whether a value of ''
+     * means that no value was given, or whether the empty string is a valid
+     * user supplied value.
+     */
+    initial_value_override: {
+        value: null
+    },
+
+    /**
+     * Is the control currently displaying an error?
+     *
+     * @attribute in_error
+     * @type Boolean
+     * @default false
+     */
+    in_error: {
+        value: false
+    },
+
+    /**
+     * The editor's input field's width.  Accepts positive numbers for
+     * approximate width in characters, or an HTML size specification
+     * such as "120px."  The default value, null, will use the browser's
+     * default size.
+     *
+     * CSS is generally a better way to set this, since it makes it
+     * easier to line up the button boxes correctly.  But doing it this
+     * way happens to be useful for testing.
+     *
+     * If you are creating a multi-line editor then using 'size' will lead to
+     * some strange layout results.  You are better off sepecifying the
+     * widget "width" attribute for such widgets.
+     *
+     * @attribute size
+     * @default null
+     */
+    size: {
+        value: null,
+        validator: function(v) { return this._validateSize(v); }
+    },
+
+    /**
+     * Determines which sets of buttons should be shown in multi-line mode:
+     * "top", "bottom", or "both".
+     *
+     * @attribute buttons
+     * @default "both"
+     */
+    buttons: {
+        value: B_BOTH,
+        validator: function(v) {
+            return (v == B_TOP || v == B_BOTTOM || v == B_BOTH);
+        }
+    }
+};
+
+Y.extend(InlineEditor, Y.Widget, {
+
+    /**
+     * A convenience method for retrieving a Node value from a Node
+     * instance, an HTMLElement, or a CSS string selector.
+     *
+     * @method _setNode
+     * @param v {Node|String|HTMLElement} The node element or selector
+     * @return {Node} The Node, if found.  null otherwise.
+     */
+    _setNode: function(v) {
+        return v ? Y.one(v) : null;
+    },
+
+    /**
+     * Validates a string, and displays any errors if there are problems
+     * with it.
+     *
+     * @method validate
+     * @param val {String} the input to validate
+     * @return {Boolean} true if the input is ok.
+     */
+    validate: function(val) {
+        if (!this.get(ACCEPT_EMPTY) && val === '') {
+            this.showError("Empty input is unacceptable!");
+            return false;
+        }
+        if (this.get(ACCEPT_EMPTY) && val === '') {
+            return true;
+        }
+        return !!val;
+    },
+
+    /**
+     * Save the editor's current input.  Validates the input, and, if it is
+     * valid, clears any errors before calling _saveData().
+     *
+     * @method save
+     */
+    save: function() {
+        // We don't want to save any whitespace characters.
+        var input = Y.Lang.trim(this.getInput());
+
+        if (this.validate(input)) {
+            this.clearErrors();
+            this._saveData(input);
+        }
+    },
+
+    /**
+     * The default save() operation.  Writes the input field's value to the
+     * editor's 'value' attribute.  Fires the 'save' event after everything
+     * is complete.
+     *
+     * This method will only be called if the editor's input has been
+     * validated.
+     *
+     * @method _saveData
+     * @param data {ANY} The data to be saved.
+     * @protected
+     */
+    _saveData: function(data) {
+        this.set(VALUE, data);
+        this.fire(SAVE);
+    },
+
+    /**
+     * Cancel an in-progress edit and reset the input's value by firing
+     * the 'cancel' event.
+     *
+     * @method cancel
+     */
+    cancel: function() {
+        this.fire(CANCEL);
+    },
+
+    /**
+     * Record initial input box size, if available and not already recorded.
+     *
+     * Sets initial_scroll_width and initial_scroll_height, which are used
+     * later to detect horizontal overflow and determine the right minimum
+     * input box height, respectively.
+     *
+     * @method _recordInitialSize
+     * @protected
+     */
+    _recordInitialSize: function() {
+        if (this.alter_ego && !this.initial_scroll_width) {
+            /* Get initial internal size from the alter_ego.  It needs
+             * to be empty so that we don't get the initial text's size.
+             */
+            var text = this.alter_ego.get(VALUE);
+            this.alter_ego.set(VALUE, '');
+            var width = this.alter_ego.get('scrollWidth');
+            if (width > 0) {
+                this.initial_scroll_width = width;
+                this.initial_scroll_height = this.alter_ego.get('scrollHeight');
+            }
+            this.alter_ego.set(VALUE, text);
+        }
+    },
+
+    /**
+     * Reset initial input box size.
+     *
+     * Clears the editor's notions about how much text will fit in the
+     * input box without scrolling.
+     */
+    _forgetInitialSize: function() {
+        this.initial_scroll_width = undefined;
+        this.initial_scroll_height = undefined;
+    },
+
+    /**
+     * Handle window resizes.
+     *
+     * @method _windowResize
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _windowResize: function(e) {
+        if (this.get('visible')) {
+            this._forgetInitialSize();
+            this.updateSize(false);
+        }
+    },
+
+    /**
+     * Handle events that may have changed the input text.
+     *
+     * Resizes the widget vertically to fit the text snugly.  When lines
+     * are removed, produces an animated shrinking effect.
+     *
+     * @method _onChange
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _onChange: function(e) {
+        this.updateSize(true);
+    },
+
+    /**
+     * Fire the 'resized' event.
+     *
+     * @method _resized
+     * @protected
+     */
+    _resized: function() {
+        this.fire(RESIZED);
+    },
+
+    /**
+     * Animated smooth shrinking effect when lines are removed.
+     *
+     * Fires the 'resized' effect when done.
+     *
+     * @method _shrink
+     * @protected
+     */
+    _shrink: function() {
+        var anim = new Y.Anim({
+            node: this.get(INPUT_EL),
+            to: { height: this._target_height },
+            duration: 0.1
+        });
+        anim.on('end', Y.bind(this._resized, this));
+        anim.run();
+    },
+
+    /**
+     * Refresh size immediately, without animation.
+     *
+     * @method _updateNow
+     */
+    _updateNow: function() {
+        this.updateSize(false);
+    },
+
+    /**
+     * Adjust vertical input box size to fit its contents snugly.
+     *
+     * Increases are immediate, but decreases produce an animated
+     * shrinking effect if requested.  (Doing the same while growing
+     * would probably inconvenience the user).
+     *
+     * @method updateSize
+     * @param animate {Bool} Whether shrinking should be animated.
+     */
+    updateSize: function(animate) {
+        this._recordInitialSize();
+
+        var input = this.get(INPUT_EL);
+        this.alter_ego.set(VALUE, input.get(VALUE));
+
+        var new_height = this.alter_ego.get('scrollHeight');
+        if (new_height === 0) {
+            return;
+        }
+
+        if (input.get('scrollWidth') > this.initial_scroll_width) {
+            // Text is wider than the widget.  There's probably a scroll bar.
+            new_height += SCROLLBAR_LEGROOM;
+            input.setStyle('overflow', 'auto');
+        } else {
+            // Text fits in the widget.  Suppress scrollbars.
+            input.setStyle('overflow', HIDDEN);
+        }
+
+        if (this.get(MULTILINE)) {
+            // Show deeper text area to let user know that multiple
+            // lines are allowed.
+            var min_height = this.initial_scroll_height * MULTILINE_MIN_LINES;
+            if (new_height < min_height) {
+                new_height = min_height;
+            }
+            // The multi-line editor has to dynamically resize,
+            // in case the widget is fluid.
+            var box_width = this.get(BOUNDING_BOX).get('offsetWidth');
+            var new_width = box_width - 29;
+            input.setStyle('width', new_width + 'px');
+        }
+
+        this._target_height = new_height;
+        current_height = input.getStyle('height').replace(strip_px, '');
+
+        if (animate && (new_height < current_height)) {
+            // Animate shrinking.
+            this._shrink();
+        } else if (new_height != current_height) {
+            // Resize instantly.
+            input.setStyle('height', new_height + 'px');
+            this._resized();
+        }
+    },
+
+    /**
+     * The default cancel() operation.  Resets the current input field.
+     *
+     * @method _defaultCancel
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _defaultCancel: function(e) {
+        this.reset();
+    },
+
+    /**
+     * Reset the widget's current input to the control's
+     * intial value.
+     *
+     * @method reset
+     */
+    reset: function() {
+        this.setInput(this.get(VALUE));
+        this.clearErrors();
+    },
+
+    /**
+     * Focus the editor's INPUT field.
+     *
+     * @method focus
+     */
+    focus: function() {
+        this.get(INPUT_EL).focus();
+    },
+
+    /**
+     * Display an error message.
+     *
+     * @method showError
+     * @param msg A string or HTMLElement to be displayed.
+     */
+    showError: function(msg) {
+        this.hideLoadingSpinner();
+        this.get(ERROR_MSG).set('innerHTML', msg);
+        this.set(IN_ERROR, true);
+        this.get(INPUT_EL).focus();
+    },
+
+    /**
+     * Clear the currently displayed error message.
+     *
+     * @method clearErrors
+     */
+    clearErrors: function() {
+        this.set(IN_ERROR, false);
+    },
+
+    /**
+     * Is the widget currently displaying an error?
+     *
+     * @method hasErrors
+     * @return Boolean
+     */
+    hasErrors: function() {
+        return this.get(IN_ERROR);
+    },
+
+    /**
+     * Constructor logic.
+     *
+     * @method initializer
+     * @protected
+     */
+    initializer: function(cfg) {
+        /**
+         * Fires when the user presses the 'Submit' button.
+         *
+         * @event saveEdit
+         */
+        this.publish(SAVE);
+
+        /**
+         * Fires when the user presses the 'Cancel' button.
+         *
+         * @event cancelEdit
+         * @preventable _defaultCancel
+         */
+        this.publish(CANCEL, { defaultFn: this._defaultCancel });
+
+        /**
+         * Fires after the input box has been resized vertically (which
+         * may involve asynchronous animation).
+         *
+         * @event shrink
+         */
+        this.publish(RESIZED);
+    },
+
+    _removeElement: function(content_box, element) {
+        if (element) {
+            content_box.removeChild(element);
+        }
+    },
+
+    /**
+     * Clean up object references and event listeners.
+     *
+     * @method destructor
+     * @private
+     */
+    destructor: function() {
+        var box = this.get(CONTENT_BOX);
+        this._removeElement(box, this.get(ERROR_MSG));
+        this._removeElement(box, this.get(TOP_BUTTONS));
+        this._removeElement(box, this.get(BOTTOM_BUTTONS));
+    },
+
+    /**
+     * Create a box to hold the OK and Cancel buttons in single-line edit
+     * mode.
+     *
+     * @method _renderSingleLineButtons
+     * @protected
+     * @param parent {Node} The parent node that will hold the buttons.
+     */
+    _renderSingleLineButtons: function(parent) {
+        var button_box = createNode('<span></span>')
+            .addClass(C_BTNBOX);
+        this._renderOKCancel(button_box);
+        parent.appendChild(button_box);
+        this.set(BOTTOM_BUTTONS, button_box);
+    },
+
+    /**
+     * Create a box to hold the OK and Cancel buttons around the top of a
+     * multi-line editor.
+     *
+     * @method _renderTopButtons
+     * @protected
+     * @param parent {Node} The parent node that will hold the buttons.
+     */
+    _renderTopButtons: function(parent) {
+        var button_bar = createNode('<div></div>')
+            .addClass(C_BTNBOX);
+
+        // Firefox needs a text node in order to calculate the line-height
+        // and thus vertically center the buttons in the div.  Apparently it
+        // can't use the button elements themselves to do this.
+        var label = button_bar.appendChild(
+            createNode('<div class="bg-top-label">&nbsp;</div>'));
+
+        this._renderOKCancel(label);
+        parent.appendChild(button_bar);
+        this.set(TOP_BUTTONS, button_bar);
+    },
+
+    /**
+     * Create a box to hold the OK and Cancel buttons around the bottom of a
+     * multi-line editor.
+     *
+     * @method _renderBottomButtons
+     * @protected
+     * @param parent {Node} The parent node that will hold the buttons.
+     */
+    _renderBottomButtons: function(parent) {
+        var button_bar = createNode('<div></div>')
+            .addClass(C_BTNBOX);
+
+        // Firefox needs a text node in order to calculate the line-height
+        // and thus vertically center the buttons in the div.  Apparently it
+        // can't use the button elements themselves to do this.
+        var label = button_bar.appendChild(
+            createNode('<div class="bg-bottom-label">&nbsp</div>'));
+
+        this._renderOKCancel(label);
+        parent.appendChild(button_bar);
+        this.set(BOTTOM_BUTTONS,  button_bar);
+    },
+
+    /**
+     * Render the OK and Cancel button pair.
+     *
+     * @method _renderOKCancel
+     * @protected
+     * @param parent {Node} The parent node that the buttons should be
+     * appended to.
+     */
+    _renderOKCancel: function(parent) {
+        var ok = createNode(InlineEditor.SUBMIT_TEMPLATE)
+                .addClass(C_SUBMIT);
+        var cancel = createNode(InlineEditor.CANCEL_TEMPLATE)
+                .addClass(C_CANCEL);
+        parent.appendChild(cancel);
+        parent.appendChild(ok);
+        this.set(SUBMIT_BUTTON, ok);
+        this.set(CANCEL_BUTTON, cancel);
+    },
+
+    /**
+     * Create the widget's HTML components.
+     *
+     * @method render
+     * @protected
+     */
+    renderUI: function() {
+        var bounding_box = this.get(BOUNDING_BOX);
+        var content = this.get(CONTENT_BOX);
+        var multiline = this.get(MULTILINE);
+        var buttons;
+        if (multiline) {
+            buttons = this.get(BUTTONS);
+        }
+
+        if (multiline) {
+            if (buttons == B_TOP || buttons == B_BOTH) {
+                this._renderTopButtons(content);
+            }
+        }
+
+        this._initInput();
+
+        if (multiline) {
+            if (buttons == B_BOTTOM || buttons == B_BOTH) {
+                this._renderBottomButtons(content);
+            }
+            bounding_box.addClass(C_MULTILINE);
+        } else {
+            this._renderSingleLineButtons(content);
+            bounding_box.addClass(C_SINGLELINE);
+        }
+
+        this._initErrorMsg();
+    },
+
+    _makeInputBox: function() {
+        var box = createNode(InlineEditor.INPUT_TEMPLATE),
+            size = this.get(SIZE);
+
+        if (size) {
+            if (Y.Lang.isNumber(size)) {
+                size = size + 'ex';
+            }
+            box.setStyle('width', size);
+        }
+        box.setStyle('height', '1em');
+        box.setStyle('overflow', HIDDEN);
+        box.addClass(C_INPUT);
+        this.get(CONTENT_BOX).appendChild(box);
+        return box;
+    },
+
+    /**
+     * Create the editor's <input> field if necessary, assign classes
+     * to it, and append it to the editor's contentBox.
+     *
+     * @method _initInput
+     * @protected
+     */
+    _initInput: function() {
+        if (!this.get(INPUT_EL)) {
+            this.set(INPUT_EL, this._makeInputBox());
+
+            // Create invisible alter ego for sizing purposes.
+            this.alter_ego = this._makeInputBox();
+            this.alter_ego.setStyles({
+                'visibility': HIDDEN,
+                'position': 'absolute',
+                'left': '-1000px'
+            });
+        }
+    },
+
+    /**
+     * Create the error message field if it was not discovered by the
+     * HTML_PARSER.
+     *
+     * @method _initErrorMsg
+     * @protected
+     */
+    _initErrorMsg: function() {
+        var cb = this.get(CONTENT_BOX),
+            msg = this.get(ERROR_MSG);
+
+        if (!msg) {
+            msg = cb.appendChild(createNode('<div/>'));
+            this.set(ERROR_MSG, msg);
+        } else if (!cb.contains(msg)) {
+            cb.appendChild(msg);
+        }
+        msg.addClass(C_ERROR);
+        msg.addClass(C_ERROR_HIDDEN);
+    },
+
+    showLoadingSpinner: function(e) {
+        // The multi-line editor submit icon should change to a spinner.
+        if (this.get(MULTILINE)) {
+            this.get(TOP_BUTTONS).one('.' + C_SUBMIT).setStyle(
+                'display', 'none');
+            this.get(TOP_BUTTONS).one('.' + C_CANCEL).setStyle(
+                'display', 'none');
+            var span = Y.Node.create('<span></span>');
+            span.addClass(LOADING);
+            e.target.get('parentNode').appendChild(span);
+        }
+    },
+
+    hideLoadingSpinner: function() {
+        // Remove the spinner from the multi-line editor.
+        if (this.get(MULTILINE)) {
+            var spinner = this.get(TOP_BUTTONS).one('.' + LOADING);
+            if (spinner) {
+                var parent = spinner.get('parentNode');
+                parent.removeChild(spinner);
+                this.get(TOP_BUTTONS).one('.' + C_SUBMIT).setStyle(
+                    'display', 'inline');
+                this.get(TOP_BUTTONS).one('.' + C_CANCEL).setStyle(
+                    'display', 'inline');
+            }
+        }
+    },
+
+    /**
+     * Bind the widget's DOM elements to their event handlers.
+     *
+     * @method bindUI
+     * @protected
+     */
+    bindUI: function() {
+        this.after('in_errorChange', this._afterInErrorChange);
+
+        this._bindButtons(C_SUBMIT, function(e) {
+            e.preventDefault();
+            this.showLoadingSpinner(e);
+            this.save();
+        });
+        this._bindButtons(C_CANCEL, function(e) {
+            e.preventDefault();
+            this.cancel();
+        });
+
+        if (!this.get(MULTILINE)) {
+            // 'down:13' is the decimal value of the Firefox DOM_VK_RETURN
+            // symbol or U+000D.
+            // https://developer.mozilla.org/en/DOM/Event/UIEvent/KeyEvent#Specification
+            Y.on('key', this.save, this.get(INPUT_EL), 'down:13', this);
+        }
+
+        // 'down:27' is the decimal value of the Firefox DOM_VK_ESCAPE symbol
+        // or U+001B.
+        Y.on('key', this.cancel, this.get(INPUT_EL), 'down:27', this);
+
+        // Update size right after rendering.  This is the first time we'll
+        // be able to get the actual required size.
+        this.get(INPUT_EL).on('focus', Y.bind(this._updateNow, this));
+
+        var change_handler = Y.bind(this._onChange, this);
+
+        // Various events that can change how we render the input field.
+        Y.on('keyup', change_handler, this.get(INPUT_EL), Y, this);
+        Y.on('keypress', change_handler, this.get(INPUT_EL), Y, this);
+        Y.on('cut', change_handler, this.get(INPUT_EL), Y, this);
+        Y.on('paste', change_handler, this.get(INPUT_EL), Y, this);
+
+        // Listen for window resizes when necessary.
+        this._original_show = this.show;
+        this.show = this._extendedShow;
+        this._original_hide = this.hide;
+        this.hide = this._extendedHide;
+    },
+
+    _bindButtons: function(button_class, method) {
+        var box = this.get(CONTENT_BOX);
+        box.all('.' + button_class).on(CLICK, Y.bind(method, this));
+    },
+
+    _extendedShow: function() {
+        // Editor may have ignored resize events while hidden.
+        if (!this._resize_handler) {
+            this._resize_handler = Y.on(
+                'resize', Y.bind(this._windowResize, this), Y.one(window), Y);
+        }
+        this._forgetInitialSize();
+        return this._original_show.apply(this, arguments[0]);
+    },
+
+    _extendedHide: function() {
+        if (this._resize_handler) {
+            this._resize_handler.detach();
+            this._resize_handler = null;
+        }
+        return this._original_hide.apply(this, arguments[0]);
+    },
+
+    /**
+     * Render the control's value to the INPUT control.  Normally, this is
+     * taken from the `value` attribute, which gets initialized from the
+     * read-only element, but this can be overridden by providing
+     * `initial_value_override` when constructing the widget.  To sync the
+     * value to the HTML DOM, call {syncHTML}.
+     *
+     * @method syncUI
+     * @protected
+     */
+    syncUI: function() {
+        var value = this.get(INITIAL_VALUE_OVERRIDE);
+        if (value === null || value === undefined) {
+            value = this.get(VALUE);
+        }
+        if (value !== null && value !== undefined) {
+            this.setInput(value);
+        }
+    },
+
+    /**
+     * A convenience method to fetch the control's input Element.
+     */
+    getInput: function() {
+        return this.get(INPUT_EL).get(VALUE);
+    },
+
+    /**
+     * Override current input area contents.  Will also update size, but
+     * not animate.
+     *
+     * @method setInput
+     * @param value New text to set as input box contents.
+     */
+    setInput: function(value) {
+        this.get(INPUT_EL).set(VALUE, value);
+        this.updateSize(false);
+    },
+
+    /**
+     * Hook to run after the 'in_error' attribute has changed.  Calls hooks
+     * to hide or show the UI's error message using _uiShowErrorMsg().
+     *
+     * @method _afterInErrorChange
+     * @param e {Event.Facade} An attribute change event instance.
+     * @protected
+     */
+    _afterInErrorChange: function(e) {
+        this._uiShowErrorMsg(e.newVal);
+    },
+
+    /**
+     * Show or hide the error message element.
+     *
+     * @method _uiShowErrorMsg
+     * @param show {Boolean} whether the error message should be shown
+     * or hidden.
+     * @protected
+     */
+    _uiShowErrorMsg: function(show) {
+        var emsg = this.get(ERROR_MSG),
+            cb   = this.get(CONTENT_BOX);
+
+        if (show) {
+            emsg.removeClass(C_ERROR_HIDDEN);
+            cb.addClass(C_IN_ERROR);
+        } else {
+            emsg.addClass(C_ERROR_HIDDEN);
+            cb.removeClass(C_IN_ERROR);
+        }
+    },
+
+    /**
+     * Set the 'waiting' user-interface state.  Be sure to call
+     * _uiClearWaiting() when you are done.
+     *
+     * @method _uiSetWaiting
+     * @protected
+     */
+    _uiSetWaiting: function() {
+        this.get(INPUT_EL).set('disabled', true);
+        this.get(BOUNDING_BOX).addClass(C_WAITING);
+
+    },
+
+    /**
+     * Clear the 'waiting' user-interface state.
+     *
+     * @method _uiClearWaiting
+     * @protected
+     */
+    _uiClearWaiting: function() {
+        this.get(INPUT_EL).set('disabled', false);
+        this.get(BOUNDING_BOX).removeClass(C_WAITING);
+    },
+
+    /**
+     * Validate the 'size' attribute.  Can be a positive number, or null.
+     *
+     * @method _validateSize
+     * @param val {ANY} the value to validate
+     * @protected
+     */
+    _validateSize: function(val) {
+        if (Y.Lang.isNumber(val)) {
+            return (val >= 0);
+        }
+        return (val === null);
+    }
+
+});
+
+Y.lazr.ui.disableTabIndex(InlineEditor);
+
+Y.InlineEditor = InlineEditor;
+
+
+var ETEXT         = 'editable_text',
+    TEXT          = 'text',
+    TRIGGER       = 'trigger',
+
+    C_TEXT        = getCN(ETEXT, TEXT),
+    C_TRIGGER     = getCN(ETEXT, TRIGGER),
+    C_EDIT_MODE   = getCN(ETEXT, 'edit_mode');
+
+/**
+ * The EditableText widget will let a user edit any string of DOM text.
+ * The DOM node containing the text must also have a clickable element
+ * that will activate the editor.
+ *
+ * @class EditableText
+ * @constructor
+ * @extends Widget
+ */
+var EditableText = function() {
+    EditableText.superclass.constructor.apply(this, arguments);
+};
+
+EditableText.NAME = ETEXT;
+
+EditableText.ATTRS = {
+    /**
+     * A clickable node that will display the text-editing widget. Can be
+     * set using a CSS selector, Node instance, or HTMLElement.
+     *
+     * @attribute trigger
+     * @type Node
+     */
+    trigger: {
+        setter: function(node) {
+            if (this.get(RENDERED)) {
+                this._bindTrigger(node);
+            }
+            return node;
+        }
+    },
+
+    /**
+     * The text to be updated by the editor's value.  Can be
+     * set using a CSS selector, Node instance, or HTMLElement.
+     *
+     * @attribute text
+     * @type Node
+     */
+    text: {
+        setter: function(v) {
+            return Y.Node.one(v);
+        },
+        validator: function(v) {
+            return Y.Node.one(v);
+        }
+    },
+
+    /**
+     * The editable text's current value.  Returns a normalized text
+     * string.
+     *
+     * If this is a DOM node of <p> tags, turn the node into a string
+     * with \n\n marking <p> breaks.
+     *
+     * @attribute value
+     * @type String
+     * @readOnly
+     */
+    value: {
+        getter: function() {
+            var text_node = this.get(TEXT);
+            var ptags = text_node.all('p');
+            if (Y.Lang.isValue(ptags) && ptags.size()) {
+                var lines = [];
+                ptags.each(function(ptag) {
+                    lines = lines.concat([ptag.get('text'), '\n\n']);
+                });
+                var content = lines.join("");
+                // Remove trailing whitespace.
+                return content.replace(/\s+$/,'');
+            } else {
+                return this.get(TEXT).get('text');
+            }
+        },
+        readOnly: true
+    },
+
+    /**
+     * Flag determining if the editor accepts empty input.
+     *
+     * @attribute accept_empty
+     * @type Boolean
+     * @default false
+     */
+    accept_empty: {
+        value: false,
+        getter: function() {
+            if (this.editor) {
+                return this.editor.get(ACCEPT_EMPTY);
+            }
+        }
+    }
+};
+
+/**
+ * Static object hash used to capture existing markup for progressive
+ * enhancement.  Can discover the 'trigger' and 'text' nodes.
+ *
+ * @property EditableText.HTML_PARSER
+ * @type Object
+ * @static
+ */
+EditableText.HTML_PARSER = {
+    trigger: '.' + C_TRIGGER,
+    text   : '.' + C_TEXT
+};
+
+Y.extend(EditableText, Y.Widget, {
+
+    /**
+     * Handle to the trigger's click event listener.
+     *
+     * @property _click_handler
+     * @type Event.Handle
+     * @protected
+     */
+    _click_handler: null,
+
+    /**
+     * The inline editor widget instance that will be used to display the
+     * text.
+     *
+     * @property editor
+     * @type InlineEditor
+     */
+    editor: null,
+
+    /**
+     * The inline editor's bounding box node.
+     *
+     * @property _editor_bb
+     * @type Node
+     * @protected
+     */
+    _editor_bb: null,
+
+    /**
+     * Handle trigger click events.  Displays the editor widget.
+     *
+     * @method _triggerEdit
+     * @param e {Event} Click event facade.
+     * @protected
+     */
+    _triggerEdit: function(e) {
+        e.preventDefault();
+        this.show_editor();
+        var cancel = this._editor_bb.one('.' + C_CANCEL);
+        var anim = new Y.Anim({
+            node: cancel,
+            easing: Y.Easing.easeOut,
+            duration: 0.2,
+            from: { left: 0 },
+            to: { left: -7 }
+        });
+        var self = this;
+        anim.on('end', function(e) {
+            self.editor.focus();
+        });
+        anim.run();
+    },
+
+    /**
+     * Displays the inline editor component, calling {render} if
+     * necessary.  Replaces the current {contentBox} with the widget
+     * contents.
+     *
+     * @method show_editor
+     */
+    show_editor: function() {
+        // Make sure that the cancel button starts back under the edit.
+        var bounding_box = this.get(BOUNDING_BOX);
+        bounding_box.one('.' + C_CANCEL).setStyle('left', 0);
+        bounding_box.addClass(C_EDIT_MODE);
+        this.editor.set(VALUE, this.get(VALUE));
+        this.editor.syncUI();
+        this.editor.show();
+        this.editor.focus();
+    },
+
+    /**
+     * Hide the editor component, and display the static
+     * text.
+     *
+     * @method hide_editor
+     */
+    hide_editor: function() {
+        var box = this.get(BOUNDING_BOX);
+        box.removeClass(C_EDIT_MODE);
+        this.editor.hide();
+    },
+
+    /**
+     * Animate new text being saved by the editor.
+     *
+     * @method _uiAnimateSave
+     * @protected
+     */
+    _uiAnimateSave: function() {
+        this._uiAnimateFlash(Y.lazr.anim.green_flash);
+    },
+
+    /**
+     * Animate the user canceling an in-progress edit.
+     *
+     * @method _uiAnimateCancel
+     * @protected.
+     */
+    _uiAnimateCancel: function() {
+        this._uiAnimateFlash(Y.lazr.anim.red_flash);
+    },
+
+    /**
+     * Run a flash-in animation on the editable text node.
+     *
+     * @method _uiAnimateFlash
+     * @param flash_fn {Function} A lazr.anim flash-in function.
+     * @protected
+     */
+    _uiAnimateFlash: function(flash_fn) {
+        var anim = flash_fn({ node: this.get(TEXT) });
+        anim.run();
+    },
+
+    /**
+     * Initialize the widget.  If an InlineEditor widget hasn't been
+     * supplied, it will construct one first, and configure the editor
+     * to appear in the appropriate DOM position.  Renders the editor
+     * widget, sets it's initial visibility, and adds attribute event
+     * listeners.
+     *
+     * @method initializer
+     * @protected
+     */
+    initializer: function(cfg) {
+        this.editor = cfg.editor ? cfg.editor : this._makeEditor(cfg);
+        this.editor.hide();
+        // Make sure the editor appears as a child of our contentBox.
+        this.editor.render(this.get(CONTENT_BOX));
+
+        // We want to publish the same events that the editor itself
+        // does.
+        this.editor.addTarget(this);
+
+        // Map the 'accept_empty' attribute through to the underlying
+        // editor.
+        this.on('accept_emptyChange', this._afterAcceptEmptyChange);
+
+        // We might want to cancel the render event, depending on the user's
+        // browser.
+        this.on('render', this._onRender);
+    },
+
+    /**
+     * Destroy the inline editor widget, and remove the DOM nodes
+     * created by it.
+     *
+     * @method destructor
+     * @protected
+     */
+    destructor: function() {
+        if (this._click_handler) {
+            this._click_handler.detach();
+        }
+
+        this.editor.destroy();
+
+        var bb = this._editor_bb;
+        if (bb && Y.Node.getDOMNode(bb)) {
+            var parentNode = bb.get('parentNode');
+            if (parentNode && Y.Node.getDOMNode(parentNode)) {
+                parentNode.removeChild(bb);
+            }
+        }
+    },
+
+    /**
+     * Check if we want to prevent the render event from firing for
+     * Launchpad B-Grade browsers (Konqueror with KHTML).
+     *
+     * @method _onRender
+     * @param e {Event.Facade} The event object.
+     * @protected
+     */
+    _onRender: function(e) {
+        // The webkit UA property will be set to the value '1' if the
+        // browser uses the KHTML engine.
+        //
+        // See http://developer.yahoo.com/yui/3/api/UA.html#property_webkit
+        if (Y.UA.webkit === 1) {
+            // Our editor is really broken in KHTML, so just prevent the
+            // render event from firing and modifying the DOM.  This
+            // effectively shuts down the widget.  See bug #331584.
+            e.preventDefault();
+        }
+
+        // XXX JeroenVermeulen 2009-03-11 bug=341098: The editor breaks
+        // in IE.  Fix the bug, or at least narrow this down to fewer IE
+        // versions.
+        if (Y.UA.ie) {
+            e.preventDefault();
+        }
+    },
+
+    /**
+     * Create an inline editor instance if one wasn't supplied by the user.
+     * Saves the editor's bounding box so that it can be removed later.
+     *
+     * @method _makeEditor
+     * @param cfg {Object} The current user configuration, usually supplied
+     * to the initializer function.
+     * @protected
+     */
+    _makeEditor: function(cfg) {
+        var editor_cfg = Y.merge(cfg, {
+            value: this.get(VALUE)
+        });
+
+        // We don't want these to be inherited from our own constructor
+        // arguments.
+        delete editor_cfg.boundingBox;
+        delete editor_cfg.contentBox;
+
+        var editor = new InlineEditor(editor_cfg);
+        // Save the bounding box so we can remove it later.
+        this._editor_bb = editor.get(BOUNDING_BOX);
+
+        return editor;
+    },
+
+    /**
+     * Create the editor's DOM structure, and assign the appropriate CSS
+     * classes.
+     *
+     * @method renderUI
+     * @protected
+     */
+    renderUI: function() {
+        // Just in case the user didn't assign the correct classes.
+        this.get(TEXT).addClass(C_TEXT);
+        this.get(TRIGGER).addClass(C_TRIGGER);
+    },
+
+    /**
+     * Subscribe to UI events generated by the inline editor widget.
+     *
+     * @method bindUI
+     * @protected
+     */
+    bindUI: function() {
+        // XXX: mars 2008-12-19
+        // I should be able to use this.after('editor:save') here, but
+        // the event model is broken: the listener will fire *before*
+        // the editor's event listener finishes, and *before* the
+        // editor's 'value' attribute has been set!
+        //
+        // For now, we'll just use editor.after() directly.
+        this.editor.after('ieditor:save', this._afterSave, this);
+        this.after('ieditor:cancel', this._afterCancel);
+
+        this._bindTrigger(this.get(TRIGGER));
+
+        // Multi-line editors display a frame on mouseover.
+        if (this.editor.get(MULTILINE)) {
+            var trigger = this.get(TRIGGER);
+            var edit_controls = trigger.get('parentNode');
+            if (Y.Lang.isValue(edit_controls)) {
+                var edit_text = this.get(TEXT);
+                var control_hover_class = 'edit-controls-hover';
+                var text_hover_class = C_TEXT + '-hover';
+                edit_controls.on('mouseover', function(e) {
+                    edit_controls.addClass(control_hover_class);
+                    edit_text.addClass(text_hover_class);
+                });
+                edit_controls.on('mouseout', function(e) {
+                    edit_controls.removeClass(control_hover_class);
+                    edit_text.removeClass(text_hover_class);
+                });
+            }
+        }
+    },
+
+    /**
+     * If the widget has been rendered, set the editable text's value to
+     * the value of the inline editor widget.
+     *
+     * @method syncUI
+     * @protected
+     */
+    syncUI: function() {
+        // We only want to grab the editor's current value if we've finished
+        // our own rendering phase.
+        if (this.get(RENDERED)) {
+            var text = this.get(TEXT),
+                val  = this.editor.get(VALUE);
+            text.set('innerHTML', '');
+            if (this.editor.get(MULTILINE)) {
+                text.set('innerHTML', val);
+            } else {
+                text.appendChild(document.createTextNode(val));
+            }
+        }
+        this.fire('rendered');
+    },
+
+    /**
+     * Bind the inline editor trigger element.
+     *
+     * @method _bindTrigger
+     * @param node {Node} The node instance to bind to.
+     * @protected
+     */
+    _bindTrigger: function(node) {
+        // Clean up the existing handler, to prevent event listener leaks.
+        if (this._click_handler) {
+            this._click_handler.detach();
+        }
+        this._click_handler = node.on('click', this._triggerEdit, this);
+    },
+
+
+    /**
+     * Function to run after the user clicks 'save' on the inline editor.
+     * Syncs the UI, hides the editor, and animates a successful text change.
+     * This also resets the initial_value_override so that we do not continue
+     * to override the value when syncUI is called.
+     *
+     * @method _afterSave
+     * @param e {Event.Custom} The editor widget's "save" event.
+     * @protected
+     */
+    _afterSave: function(e) {
+        this.editor.hideLoadingSpinner();
+        this.syncUI();
+        this.hide_editor();
+        this._uiAnimateSave();
+        this.editor.set(INITIAL_VALUE_OVERRIDE, null);
+    },
+
+    /**
+     * Function to run after the user clicks 'Cancel' on the editor
+     * widget.  Hides the editor, and animates a cancelled edit.
+     *
+     * @method _afterCancel
+     * @param e {Event.Custom} The editor's "cancel" event.
+     * @protected
+     */
+    _afterCancel: function(e) {
+        this.hide_editor();
+        this._uiAnimateCancel();
+    },
+
+    /**
+     * Pass through changes to the 'accept_empty' attribute to the editor
+     * widget.
+     *
+     * @method _afterAcceptEmptyChange
+     * @param e {Event} Change event for the 'accept_empty' attribute.
+     * @protected
+     */
+    _afterAcceptEmptyChange: function(e) {
+        this.editor.set(ACCEPT_EMPTY, e.newVal);
+    },
+
+    /**
+     * Override to disable the widget for certain browsers.
+     * See the YUI docs on `renderer` for widgets for more.
+     *
+     * @method renderer
+     */
+    renderer: function() {
+        if (this.editor.get(MULTILINE) && (Y.UA.ie || Y.UA.opera)) {
+            return;
+        }
+        EditableText.superclass.renderer.apply(this, arguments);
+    }
+});
+
+Y.lazr.ui.disableTabIndex(EditableText);
+
+Y.EditableText = EditableText;
+
+}, "0.2", {"skinnable": true,
+           "requires": ["oop", "anim", "event", "node", "widget",
+                        "lazr.anim", "lazr.base"]});

=== added directory 'lib/lp/app/javascript/lazr/inlineedit/tests'
=== added file 'lib/lp/app/javascript/lazr/inlineedit/tests/index.html'
--- lib/lp/app/javascript/lazr/inlineedit/tests/index.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/inlineedit/tests/index.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,28 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Inline Edit</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../inlineedit/editor.js"></script>
+  <script type="text/javascript" src="../../anim/anim.js"></script>
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="inline_edit.js"></script>
+
+</head>
+<body class="yui3-skin-sam">
+  <div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/inlineedit/tests/inline_edit.js'
--- lib/lp/app/javascript/lazr/inlineedit/tests/inline_edit.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/inlineedit/tests/inline_edit.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,1250 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.editor', 'lazr.testing.runner', 'node',
+          'event', 'event-simulate', 'console', 'plugin', function(Y) {
+var SAMPLE_HTML = "                                                                           \
+ <h1>Single-line editing</h1>                                                                 \
+  <div id='editable_single_text'>                                                             \
+    <span id='single_text' class='yui3-editable_text-text'>Some editable inline text.</span>  \
+    <button id='single_edit' class='yui3-editable_text-trigger'>Edit this</button>            \
+  </div>                                                                                      \
+  <hr />                                                                                      \
+  <h1>Multi-line editing</h1>                                                                 \
+  <div id='editable_multi_text'>                                                              \
+    <button id='multi_edit' class='yui3-editable_text-trigger'>Edit this</button>             \
+    <span id='multi_text' class='yui3-editable_text-text'>                                    \
+      <p>Some editable multi-line text.</p></span>                                            \
+  </div>                                                                                      \
+";
+
+var Assert = Y.Assert;  // For easy access to isTrue(), etc.
+
+/* Helper to stamp a Node with an ID attribute.  Needed for YUI 2.X
+ * testing, which is heavily ID-based.
+ *
+ * Returns the node's 'id' attribute.
+ */
+function id_for(node) {
+    if (!node.getAttribute('id')) {
+        var id = Y.stamp(node);
+        node.setAttribute('id', id);
+    }
+    return node.getAttribute('id');
+}
+
+/*
+ * A wrapper for the Y.Event.simulate() function.  The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(selector, evtype) {
+    var rawnode = Y.Node.getDOMNode(Y.one(selector));
+    Y.Event.simulate(rawnode, evtype);
+}
+
+/* Helper function that creates a new editor instance. */
+function make_editor(cfg) {
+    return new Y.InlineEditor(cfg);
+}
+
+/* Helper function to clean up a dynamically added widget instance. */
+function cleanup_widget(widget) {
+    // Nuke the boundingBox, but only if we've touched the DOM.
+    if (widget.get('rendered')) {
+        var bb = widget.get('boundingBox');
+        if (bb && Y.Node.getDOMNode(bb)) {
+            var parentNode = bb.get('parentNode');
+            if (parentNode && Y.Node.getDOMNode(parentNode)) {
+                parentNode.removeChild(bb);
+            }
+        }
+    }
+    // Kill the widget itself.
+    widget.destroy();
+}
+
+function setup_sample_html() {
+    if (! Y.one("#scaffolding")) {
+        Y.one(document.body).appendChild(
+            Y.Node.create("<div id='scaffolding'></div>"));
+    }
+
+    Y.one("#scaffolding").set("innerHTML", SAMPLE_HTML);
+}
+
+function make_editable_text(cfg) {
+    // For the editor
+    // TODO: fix this ugly hack
+    var defaults = {
+        contentBox: '#editable_single_text',
+        boundingBox: '#inline-edit-container'
+    };
+    return new Y.EditableText(Y.merge(defaults, cfg));
+}
+
+// Helper: convert size specification like "120px" to a number (in casu, 120).
+var strip_px = /px$/;
+function parse_size(size) {
+    return parseInt(size.replace(strip_px, ''), 10);
+}
+
+var suite = new Y.Test.Suite("Inline Editor Tests");
+
+suite.add(new Y.Test.Case({
+
+    name: 'inline_editor_basics',
+
+    setUp: function() {
+        this.editor = make_editor();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.editor);
+    },
+
+    test_input_value_set_during_sync: function() {
+        /* The input element's value should be set during the syncUI()
+         * call.
+         */
+        var ed = this.editor,
+            desired_value = 'x';
+
+        Assert.areNotEqual(
+            desired_value,
+            ed.get('value'),
+            "Sanity check: the editor's value shouldn't equal our " +
+            "desired value.");
+        Assert.isFalse(
+            ed.get('rendered'),
+            "Sanity check: the widget shouldn't be rendered yet.");
+
+        ed.set('value', desired_value);
+        ed.render();
+        Assert.areEqual(
+            desired_value,
+            ed.get('input_field').get('value'),
+            "The editor's input field's value should have been set.");
+    },
+
+    test_getInput_method: function() {
+        this.editor.render();
+        Assert.areEqual(
+            this.editor.get('input_field').get('value'),
+            this.editor.getInput(),
+            "The getInput() method should return the same value as " +
+            "the editor's input field's current value.");
+    },
+
+    test_validate_values: function() {
+        Assert.isFalse(this.editor.get('accept_empty'),
+            "The editor shouldn't accept empty values by default.");
+
+        var prev = this.editor.get('value');
+        this.editor.set('value', null);
+        Assert.areEqual(
+            prev,
+            this.editor.get('value'),
+            "The editor's value should not have changed.");
+
+        this.editor.set('value', '');
+        Assert.areEqual(
+            prev,
+            this.editor.get('value'),
+            "The editor should not accept the empty string as a " +
+            "value if 'accept_empty' is false.");
+
+        /* The control can be asked to accept empty values. */
+        this.editor.set('accept_empty', true);
+        this.editor.set('value', '');
+        Assert.areEqual(
+            '',
+            this.editor.get('value'),
+            "The editor should have accepted the empty string as a " +
+            "valid value if 'accept_empty' is true.");
+    },
+
+    test_validate_empty_editor_input: function() {
+        var ed = this.editor;
+
+        // A helper to catch the 'save' event.
+        var got_save = false;
+        var after_save = function(ev) { got_save = true; };
+        ed.after('ieditor:save', after_save);
+
+        ed.render();
+
+        Assert.isFalse(ed.hasErrors(),
+            "Sanity check: the editor shouldn't be displaying any " +
+            "errors.");
+        Assert.isFalse(ed.get('accept_empty'),
+            "Sanity check: the editor shouldn't accept empty inputs.");
+
+        ed.get('input_field').set('value', '');
+        ed.save();
+
+        Assert.isTrue(ed.hasErrors(),
+            "The editor should be displaying an error after the " +
+            "trying to save an empty input.");
+        Assert.isFalse(got_save,
+            "The editor should not have fired a 'save' event.");
+    },
+
+    test_set_and_clear_error_message: function() {
+        this.editor.render();
+
+        var ed       = this.editor,
+            edisplay = ed.get('error_message'),
+            c_hidden   = 'yui3-ieditor-errors-hidden';
+
+        Assert.isNotNull(
+            edisplay,
+            "The editor should have a valid error display node.");
+
+        Assert.isTrue(
+            edisplay.hasClass(c_hidden),
+            "The error display should start out hidden.");
+        Assert.isFalse(
+            ed.get("in_error"),
+            "The editor's 'in_error' attribute should not be set.");
+
+        var msg = "An error has occured.";
+        ed.showError(msg);
+
+        Assert.areEqual(
+            msg,
+            edisplay.get('text'),
+            "The error display's text should be set.");
+        Assert.isFalse(
+            edisplay.hasClass(c_hidden),
+            "The error display should be visible when an error is set.");
+        Assert.isTrue(
+            ed.hasErrors(),
+            "The editor .hasErrors() method should return true if " +
+            "there are errors being displayed.");
+        Assert.isTrue(
+            ed.get("in_error"),
+            "The editor's 'in_error' attribute should be set.");
+
+        ed.clearErrors();
+        Assert.isTrue(
+            edisplay.hasClass(c_hidden),
+            "The error display should be hidden when the error " +
+            "is cleared.");
+        Assert.isFalse(
+            ed.hasErrors(),
+            "The editor .hasErrors() method should return false " +
+            "if there are no errors being displayed.");
+    },
+
+    test_save_input_to_editor: function() {
+        var expected_value = 'abc',
+            ed = this.editor;
+
+        Assert.areNotEqual(
+            expected_value,
+            ed.get('value'),
+            "Sanity check");
+
+        ed.render();
+        ed.get('input_field').set('value', expected_value);
+        ed.save();
+
+        Assert.areEqual(
+            expected_value,
+            ed.get('value'),
+            "The value of the editor's input field should have been " +
+            "saved to the editor's 'value' attribute.");
+    },
+
+    test_focus_method_focuses_editor_input: function() {
+        this.editor.render();
+
+        var input = this.editor.get('input_field'),
+            test = this,
+            focused = false;
+
+        Y.on('focus', function() {
+            focused = true;
+        }, input);
+
+        this.editor.focus();
+
+        Assert.isTrue(focused,
+            "The editor's input field should have received focus " +
+            "after calling the editor's focus method.");
+    },
+
+    test_input_receives_focus_after_editor_errors: function() {
+        this.editor.render();
+
+        var ed = this.editor,
+            input = this.editor.get('input_field'),
+            got_focus = false;
+
+        Assert.isFalse(
+            ed.get('in_error'),
+            "Sanity check: the editor should be clear of errors.");
+        Assert.isFalse(
+            ed.get('accept_empty'),
+            "Sanity check: the editor should not accept empty " +
+            "values.");
+
+        // Force an error by setting the editor's input to the
+        // empty string.
+        input.set('value', '');
+
+        var test = this;
+        // Add our focus event listener.
+        Y.on('focus', function() {
+            got_focus = true;
+        }, input);
+
+        ed.save();
+        Assert.isTrue(
+            ed.get('in_error'),
+            "Sanity check: the editor should be in an error state " +
+            "after saving an empty value.");
+
+        Assert.isTrue(
+            got_focus,
+            "The editor's input field should have the current " +
+            "focus.");
+    },
+
+    test_widget_has_a_disabled_tabindex_when_focused: function() {
+        // The tabindex attribute appears when the widget is focused.
+        this.editor.render();
+        this.editor.focus();
+
+        // Be aware that in IE, get('tabIndex') and getAttribute('tabIndex')
+        // return different values when set to -1. This is due to YUI's
+        // getAttribute() calling dom_node.getAttribute('tabIndex', 2), which
+        // is an IE extension.
+        // http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx
+        Assert.areEqual(
+            -1,
+            this.editor.get('boundingBox').get('tabIndex'),
+            "The widget should have a tabindex of -1 (disabled).");
+    },
+
+    test_enter_key_saves_input: function() {
+        this.editor.render();
+
+        var ed = this.editor,
+            input_element = Y.Node.getDOMNode(
+                this.editor.get('input_field'));
+
+        input_element.value = 'abc';
+
+        // A helper to flag the 'save' event.
+        var saved = false;
+        function saveCheck(e) {
+            saved = true;
+        }
+
+        ed.after('ieditor:save', saveCheck, this);
+
+        // Simulate an 'Enter' key event in the editor's input field.
+        Y.Event.simulate(input_element, "keydown", { keyCode: 13 });
+
+        Assert.isFalse(ed.hasErrors());
+        Assert.isTrue(saved,
+            "Pressing the 'Enter' key inside the editor's input field " +
+            "should save the input.");
+    },
+
+    test_enter_key_ignored_in_multiline: function() {
+        this.editor.set('multiline', true);
+        this.editor.render();
+
+        var ed = this.editor;
+        var input_element = Y.Node.getDOMNode(this.editor.get('input_field'));
+
+        input_element.value = 'abc';
+
+        // A helper to flag the 'save' event.
+        var saved = false;
+        function saveCheck(e) {
+            saved = true;
+        }
+
+        ed.after('ieditor:save', saveCheck, this);
+
+        // Simulate an 'Enter' key event in the editor's input field.
+        Y.Event.simulate(input_element, "keydown", { keyCode: 13 });
+
+        // Restore to previous state.
+        this.editor.set('multiline', false);
+
+        Assert.isFalse(ed.hasErrors());
+        Assert.isFalse(saved,
+            "Pressing the 'Enter' key in multiline mode " +
+            "should not trigger a save.");
+    },
+
+    test_input_should_be_trimmed_of_whitespace: function() {
+        this.editor.render();
+
+        var input = this.editor.get('input_field');
+
+        // Set a whitespace value as the input.
+        input.set('value', '  ');
+
+        this.editor.save();
+
+        Assert.isTrue(
+            this.editor.hasErrors(),
+            "The editor should be displaying an error after trying to " +
+            "save a whitespace value.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+    name: 'Initial value',
+
+    setUp: function() {
+        this.editor = make_editor({initial_value_override: 'Initial value'});
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.editor);
+    },
+
+    test_initial_value_override: function() {
+        this.editor.render();
+        Assert.areEqual(
+            'Initial value',
+            this.editor.get('input_field').get('value'),
+            "The editor's input field should have the initial value.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+    name: 'Editable text initial values',
+
+    setUp: function() {
+        setup_sample_html();
+        this.etext = make_editable_text(
+            {initial_value_override: 'Initial value'});
+    },
+
+    tearDown: function() {
+        // Reset the <span>.
+        cleanup_widget(this.etext);
+    },
+
+    test_save_initial_value_override: function() {
+        this.etext.render();
+
+        Assert.areEqual(
+            'Initial value',
+            this.etext.editor.get('input_field').get('value'),
+            "The input_field should have been set to the initial value.");
+
+        this.etext.editor.save();
+        Assert.areEqual(
+            'Initial value',
+            this.etext.editor.get('value'),
+            "The editor's initial value did not get saved.");
+        Assert.areEqual(
+            null,
+            this.etext.editor.get('initial_value_override'),
+            "The editor's initial_value_override should be null.");
+    },
+
+    test_cancel_does_not_modify_value: function() {
+        this.etext.render();
+
+        Assert.areEqual(
+            'Some editable inline text.',
+            this.etext.editor.get('value'),
+            "The editor's value is not what it should be.");
+        Assert.areEqual(
+            'Initial value',
+            this.etext.editor.get('initial_value_override'),
+            "The editor's initial_value_override is not what it should be.");
+
+        this.etext.editor.cancel();
+        Assert.areEqual(
+            'Some editable inline text.',
+            this.etext.editor.get('value'),
+            "The editor's value did not get reset.");
+        Assert.areEqual(
+            'Initial value',
+            this.etext.editor.get('initial_value_override'),
+            "The editor's initial_value_override did not get preserved.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: "Inline editor input sizing for a positive size value",
+
+    setUp: function() {
+        this.expected_size = 32;
+        this.editor = make_editor({size: this.expected_size});
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.editor);
+    },
+
+    test_editor_size_attribute_matches_user_value: function() {
+        Assert.areEqual(
+            this.editor.get('size'),
+            this.expected_size,
+            "The editor's 'size' attribute should match the user's " +
+            "specified size.");
+    },
+
+    test_input_field_size_matches_the_editor_size: function() {
+        this.editor.render();
+        var input = this.editor.get('input_field');
+        Assert.areEqual(
+            this.expected_size + 'ex',
+            input.getStyle('width'),
+            "The editor's input field size should have been set from the " +
+            "'size' attribute.");
+    }
+
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: "Inline editor input sizing for a null size value",
+
+    setUp: function() {
+        this.editor = make_editor();
+        this.editor.render();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.editor);
+    },
+
+    test_editor_size_attribute_is_null: function() {
+        Assert.areEqual(
+            null,
+            this.editor.get('size'),
+            "The editor's 'size' attribute should default to 'null'.");
+    },
+
+    test_editor_input_has_browser_default_size: function() {
+        var input = this.editor.get('input_field');
+        Assert.isFalse(
+            input.hasAttribute('size'),
+            "The editor's input field should have the browser default " +
+            "size if the editor's size is 'null'.");
+    }
+}));
+
+/*
+ * XXX mars 20090206
+ *
+ * The following test is just for the attribute validators.  Most of this is
+ * made necessary because YUI doesn't publish attribute validation errors.
+ *
+ * See ticket http://yuilibrary.com/projects/yui3/ticket/2525946
+ */
+suite.add(new Y.Test.Case({
+
+    name: "Inline editor size attribute validation",
+
+    setUp: function() {
+        this.initial_size = null;
+        this.editor = make_editor({size: this.initial_size});
+    },
+
+    test_editor_accepts_null_as_size: function() {
+        this.editor.set('size', null);
+        Assert.areEqual(
+            null,
+            this.editor.get('size'),
+            "The editor should accept a null value for the size attribute.");
+    },
+
+    test_editor_accepts_positive_numbers_as_size: function() {
+        this.editor.set('size', 123);
+        Assert.areEqual(
+            123,
+            this.editor.get('size'),
+            "The editor should accept a positive number as a valid size.");
+    },
+
+    test_editor_rejects_negative_numbers_for_size: function() {
+        this.editor.set('size', -2);
+        Assert.areEqual(
+            this.initial_size,
+            this.editor.get('size'),
+            "The editor should not accept negative numbers for its size.");
+    },
+
+    test_editor_rejects_characters_for_size: function() {
+        this.editor.set('size', 'a');
+        Assert.areEqual(
+            this.initial_size,
+            this.editor.get('size'),
+            "The editor should not accept strings for its size.");
+    }
+}));
+
+
+suite.add(new Y.Test.Case({
+
+    name: 'editor_save_state_change',
+
+    setUp: function() {
+        this.editor = make_editor();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.editor);
+    },
+
+    test_ui_initial_state_is_not_waiting: function() {
+        this.editor.render();
+        Assert.isFalse(
+            this.editor.get('boundingBox').hasClass('yui3-ieditor-waiting'),
+            "The editor UI should not start out in the 'waiting' state.");
+    },
+
+    test_set_ui_waiting_state: function() {
+        var ed = this.editor;
+        ed.render();
+
+        ed._uiSetWaiting();
+
+        Assert.isTrue(
+            ed.get('input_field').get('disabled'),
+            "The editor's input should be disabled while in the " +
+            "'waiting' state.");
+        Assert.isTrue(
+            ed.get('boundingBox').hasClass('yui3-ieditor-waiting'),
+            "The editor's UI should reflect the 'waiting' state " +
+            "with an appropriate class.");
+    },
+
+    test_clear_ui_waiting_state: function() {
+        var ed = this.editor;
+        ed.render();
+
+        ed._uiSetWaiting();
+        ed._uiClearWaiting();
+
+        Assert.isFalse(
+            ed.get('input_field').get('disabled'),
+            "The editor's input should be re-enabled when clearing " +
+            "the 'waiting' state.");
+        Assert.isFalse(
+            ed.get('boundingBox').hasClass('yui3-ieditor-waiting'),
+            "The editor's UI should have the 'waiting' state " +
+            "class removed.");
+    }
+}));
+
+
+suite.add(new Y.Test.Case({
+
+    name: 'editable_text',
+
+    setUp: function() {
+        setup_sample_html();
+        this.etext = make_editable_text();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.etext);
+    },
+
+    test_initial_values_from_DOM: function() {
+        Assert.areEqual(
+            Y.one("#single_text"),
+            this.etext.get('text'),
+            "The editor's text node should have been set from the " +
+            "DOM.");
+
+        Assert.areEqual(
+            Y.one('#single_edit'),
+            this.etext.get('trigger'),
+            "The editor's trigger node should have been set from " +
+            "the DOM.");
+
+        Assert.areEqual(
+            'Some editable inline text.',
+            this.etext.editor.get('value'),
+            "The editor's initial value should be set from it's " +
+            "text node.");
+
+        Assert.areEqual(
+            this.etext.editor.get('value'),
+            this.etext.get('value'),
+            "The editable text's value should be the same as the " +
+            "editor's.");
+    },
+
+    test_show: function() {
+        /* The show() method should display the editor, and hide the
+         * existing contents.
+         */
+        this.etext.render();
+        this.etext.show_editor();
+        Assert.isTrue(this.etext.editor.get('visible'),
+            "The editor's 'visible' attribute should be true.");
+    },
+
+    test_hide: function() {
+        /* The hide() method should hide the editor, and display the
+         * original contents.
+         */
+        this.etext.render();
+        this.etext.show_editor();
+        this.etext.hide_editor();
+        Assert.isFalse(this.etext.editor.get('visible'),
+            "The editor's 'visible' attribute should be False.");
+    },
+
+    test_trigger_edit: function() {
+        /* Clicking on the editable text's "Edit" button should
+         * make the editor visible.
+         */
+        Assert.isFalse(this.etext.editor.get('visible'),
+            "Sanity check, the editor should be hidden.");
+
+        this.etext.render();
+        simulate('#single_edit', 'click');
+
+        Assert.isTrue(this.etext.editor.get('visible'),
+            "The editor should be visible.");
+    },
+
+    test_text_is_updated_to_saved_value: function() {
+        this.etext.render();
+
+        // Grab the normalized text.
+        var expected_value = 'abc';
+
+        Assert.areNotEqual(
+            expected_value,
+            this.etext.get('value'),
+            "Sanity check");
+
+        simulate('#single_edit', 'click');
+        this.etext.editor
+            .get('input_field')
+            .set('value', expected_value);
+
+        this.etext.editor.save();
+
+        Assert.areEqual(
+            expected_value,
+            this.etext.editor.get('value'),
+            "Sanity check: the editor's value should have been " +
+            "saved.");
+
+        Assert.areEqual(
+            expected_value,
+            this.etext.get('value'),
+            "The editable text's current value should be updated " +
+            "after saving some new text in the editor.");
+    },
+
+    test_text_is_escaped: function() {
+        this.etext.render();
+
+        var input_value = '<i>l33t inject0r d00d</i> 0wnz y00';
+        var shown_value = '&lt;i&gt;l33t inject0r d00d&lt;/i&gt; 0wnz y00';
+
+        simulate('#single_edit', 'click');
+        this.etext.editor.setInput(input_value);
+        this.etext.editor.save();
+
+        Assert.areEqual(
+            shown_value,
+            this.etext.get('text').get('innerHTML'),
+            "Input text should be escaped before being inserted in HTML.");
+        Assert.areEqual(
+            input_value,
+            this.etext.editor.getInput(),
+            "Input text should be retained verbatim.");
+    },
+
+    test_accept_empty_attribute_passthrough: function() {
+        var et = this.etext;
+
+        Assert.areEqual(
+            et.get('accept_empty'),
+            et.editor.get('accept_empty'),
+            "The editor and inline editor's 'accept_empty " +
+            "should start out the same.");
+
+        et.set('accept_empty', true);
+        Assert.isTrue(
+            et.editor.get('accept_empty'),
+            "The inline editor's 'accept_empty' attribute should " +
+            "also be set to 'true'.");
+        Assert.isTrue(
+            et.get('accept_empty'),
+            "The editor's 'accept_empty' attribute should be true.");
+
+        et.set('accept_empty', false);
+        Assert.isFalse(
+            et.get('accept_empty'),
+            "The editor's 'accept_empty' attribute should be false.");
+        Assert.isFalse(
+            et.editor.get('accept_empty'),
+            "The inline editor's 'accept_empty' attribute should " +
+            "also be set to 'false'.");
+    },
+
+    test_widget_has_a_disabled_tabindex_when_focused: function() {
+        // The tabindex attribute appears when the widget is focused.
+        this.etext.render();
+        this.etext.focus();
+
+        // Be aware that in IE, get('tabIndex') and getAttribute('tabIndex')
+        // return different values when set to -1. This is due to YUI's
+        // getAttribute() calling dom_node.getAttribute('tabIndex', 2), which
+        // is an IE extension.
+        // http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx
+
+        // On IE and KHTML, EditableText._onRender() will prevent the
+        // default widget rendering that would set the tabIndex on the
+        // boundingBox, so this test will fail for those browsers.
+        Assert.areEqual(
+            -1,
+            this.etext.get('boundingBox').get('tabIndex'),
+            "The widget should have a tabindex of -1 (disabled).");
+    },
+
+    test_trigger_is_disabled_if_the_widget_is_not_rendered: function() {
+        var trigger = this.etext.get('trigger');
+        Assert.isInstanceOf(
+            Y.Node, trigger,
+            "Sanity check: the editor's trigger should be a valid node.");
+        Assert.isFalse(
+            this.etext.get('rendered'),
+            "Sanity check: the editor should not be rendered.");
+
+        simulate(trigger, 'click');
+        // Peek inside the box a bit, and check that the nested editor
+        // instance is still invisible.  Assume that if it is, then
+        // the show_editor() method was never called.
+        Assert.isFalse(
+            this.etext.editor.get('visible'),
+            "Triggering an unrendered editor should not display the widget.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: "EditableText single-line/multi-line modes",
+
+    setUp: function() {
+        setup_sample_html();
+        this.single = make_editable_text({
+            contentBox: '#editable_single_text',
+            multiline: false
+        });
+        this.single.render();
+        this.single.show_editor();
+        this.multi = make_editable_text({
+            contentBox: '#editable_multi_text',
+            multiline: true
+        });
+        this.multi.render();
+        this.multi.show_editor();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.single);
+        cleanup_widget(this.multi);
+    },
+
+    test_multi_line_has_larger_minimum: function() {
+        var single = this.single.editor;
+        var multi = this.multi.editor;
+
+        single.setInput('');
+        multi.setInput('');
+
+        var single_height = single.get('input_field').getStyle('height');
+        var multi_height = multi.get('input_field').getStyle('height');
+
+        single_height = parse_size(single_height);
+        multi_height = parse_size(multi_height);
+
+        Assert.areNotEqual(
+            multi_height,
+            single_height,
+            "Multi-line and single-line editors should have different sizes.");
+        Assert.isTrue(
+            multi_height > single_height,
+            "Multi-line editor should start out larger.");
+    },
+
+    test_single_line_top_button_box: function() {
+        var box = this.single.editor.get("top_buttons");
+        Assert.areEqual(
+            null,
+            box,
+            "Single-line editor should not have a top button box.");
+    },
+
+    test_multi_line_top_button_box: function() {
+        var box = this.multi.editor.get("top_buttons");
+        Assert.areNotEqual(
+            null,
+            box,
+            "Multi-line editor should have a top button box.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: "Editor input sizing",
+
+    setUp: function() {
+        this.expected_size = 23;
+        this.editor = make_editor({
+            contentBox: '#editable_single_text',
+            size: this.expected_size
+        });
+        this.editor.render();
+
+        this.long_line = 'mm mmmmm mmmmmmm mm mmm mm mmmmmmm mmmmmmmm mm mmm m';
+        this.long_word = 'mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm';
+        this.short_line = 'hi mom';
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.editor);
+    },
+
+    test_size_attribute_passthrough: function() {
+        // create a new editor with the input size set to 23 characters.
+        Assert.areEqual(
+            this.expected_size,
+            this.editor.get('size'),
+            "The inline editor widget should have received a size " +
+            "from the EditableText widget.");
+    },
+
+    test_long_text_wraps: function() {
+        var editor = this.editor,
+            input = editor.get('input_field'),
+            original_height = input.getStyle('height');
+
+        editor.setInput(this.long_line);
+        var new_height = input.getStyle('height');
+
+        Assert.areNotEqual(
+            original_height,
+            new_height,
+            "Inserting a long text should grow the input area.");
+        Assert.areEqual(
+            'hidden',
+            input.getStyle('overflow'),
+            "Scrollbars should be hidden when no unbreakable lines present.");
+        Assert.isTrue(
+            parse_size(new_height) > parse_size(original_height),
+            "A grown input area should be larger than before.");
+    },
+
+    test_long_words_scroll: function() {
+        var editor = this.editor,
+            input = editor.get('input_field'),
+            original_height = input.getStyle('height');
+
+        editor.setInput(this.long_word);
+        var new_height = input.getStyle('height');
+
+        Assert.areNotEqual(
+            original_height,
+            new_height,
+            "Long words should add legroom for a horizontal scrollbar.");
+        Assert.isTrue(
+            parse_size(new_height) > parse_size(original_height),
+            "A grown input area should be larger than before.");
+    },
+
+    test_resize_on_growth: function() {
+        var editor = this.editor,
+            input = editor.get('input_field');
+
+        var test = this;
+        var resized = false;
+        editor.on('ieditor:resized', function() {
+            resized = true;
+        });
+        input.set('value', this.long_line);
+        editor.updateSize();
+        Assert.isTrue(resized, "Editor resize event was not fired.");
+    },
+
+    test_resize_on_shrinkage: function() {
+        var editor = this.editor,
+            input = editor.get('input_field');
+
+        editor.setInput(this.long_line);
+
+        var test = this;
+        var resized = false;
+        editor.on('ieditor:resized', function() {
+            resized = true;
+        });
+        input.set('value', this.short_line);
+
+        editor.updateSize();
+        Assert.isTrue(resized, "Editor resize event was not fired.");
+    },
+
+    test_long_text_unwraps: function() {
+        var editor = this.editor,
+            input = editor.get('input_field');
+
+        editor.setInput(this.short_line);
+        var original_height = input.getStyle('height');
+
+        editor.setInput(this.long_line);
+        editor.setInput(this.short_line);
+        var new_height = input.getStyle('height');
+
+        Assert.areEqual(
+            original_height,
+            new_height,
+            "Removing lines of text should shrink the input area.");
+    },
+
+    test_long_words_unscroll: function() {
+        var editor = this.editor,
+            input = editor.get('input_field');
+
+	editor.setInput(this.short_line);
+	var original_height = input.getStyle('height');
+
+        editor.setInput(this.long_word);
+	editor.setInput(this.short_line);
+        var new_height = input.getStyle('height');
+
+        Assert.areEqual(
+            original_height,
+            new_height,
+            "Removing long words should remove legroom for scrollbar.");
+        Assert.areEqual(
+            'hidden',
+            input.getStyle('overflow'),
+            "Scrollbars should be hidden when long lines are removed.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+    name: "Window resizing",
+
+    setUp: function() {
+        this.small_size = 5;
+        this.large_size = 40;
+        this.short_line = 'i';
+        // A line that fits in small_size but not in large_size.
+        this.long_line = 'x xx x xx x xx x xx';
+        // A word that fits in small_size but not in large_size.
+        this.long_word = 'xxxxxxxxxxxxxxxxxxx';
+        this.editor = make_editor();
+        this.editor.render();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.editor);
+    },
+
+    // Pretend that a window resize has changed the editor's width to
+    // the given number of columns.  Also lets you set new contents for
+    // the input box.  Returns resulting input box height in pixels.
+    _pretendResize: function(new_size, new_text) {
+        var text = (new_text ? new_text : this.editor.getInput());
+        var content_box = this.editor.get('contentBox');
+
+        var old_input = this.editor.get('input_field');
+        content_box.removeChild(old_input);
+        this.editor.set('input_field', null);
+
+        var old_alter_ego = this.editor.alter_ego;
+        content_box.removeChild(old_alter_ego);
+        this.editor.alter_ego = null;
+
+        this.editor.set('size', new_size);
+        this.editor._initInput();
+
+        this.editor.setInput(text);
+        this.editor._windowResize();
+        return this._getInputBoxHeight();
+    },
+
+    _getInputBoxHeight: function() {
+        var input = this.editor.get('input_field');
+        return parse_size(input.getStyle('height'));
+    },
+
+    // Helper: assert lower < higher, and print helpful message.
+    _assertLower: function(lower, higher, failure_text) {
+        if (!(lower < higher)) {
+            // Log values in separate statements to avoid infinite recursion
+            // during ill-typed attempts at string concatenation.
+            Y.log("Expected the first of these to be lower than the second:");
+            Y.log(lower);
+            Y.log(higher);
+        }
+        Assert.isTrue(lower < higher, failure_text);
+    },
+
+    test_resize_might_not_change_layout: function() {
+        var roomy = this._pretendResize(this.large_size, this.short_line);
+        var tight = this._pretendResize(this.small_size);
+        Assert.areEqual(
+            roomy,
+            tight,
+            "If there's enough room, resizing should not affect height.");
+    },
+
+    test_undersize_adds_lines: function() {
+        var roomy = this._pretendResize(this.large_size, this.long_line);
+        var tight = this._pretendResize(this.small_size);
+        this._assertLower(
+            roomy,
+            tight,
+            "Undersizing a long line should break it.");
+    },
+
+    test_oversize_removes_lines: function() {
+        var tight = this._pretendResize(this.small_size, this.long_line);
+        var roomy = this._pretendResize(this.large_size);
+        this._assertLower(
+            roomy,
+            tight,
+            "Oversizing a long line should unbreak it.");
+    },
+
+    test_undersize_adds_scrollbar: function() {
+        // Actually, a scrollbar and/or more lines.  The spec leaves it
+        // all up to the browser, but either way we'll see a higher
+        // input box.
+        var roomy = this._pretendResize(this.large_size, this.long_word);
+        var tight = this._pretendResize(this.small_size);
+        this._assertLower(
+            roomy,
+            tight,
+            "Undersizing a long word should require a taller input box.");
+    },
+
+    test_oversize_removes_scrollbar: function() {
+        var tight = this._pretendResize(this.small_size, this.long_word);
+        var roomy = this._pretendResize(this.large_size);
+        this._assertLower(
+            roomy,
+            tight,
+            "Oversizing a long word should reduce input box height.");
+    },
+
+    test_resize_works_while_hidden: function() {
+        var roomy = this._pretendResize(this.large_size, this.long_line);
+        this.editor.hide();
+        var tight = this._pretendResize(this.small_size);
+        this.editor.show();
+        this.editor.updateSize();
+        Assert.areNotEqual(
+            roomy,
+            tight,
+            "Editors should notice window resizes even while hidden.");
+    }
+}));
+
+
+suite.add(new Y.Test.Case({
+    name: "EditableText text value",
+
+    setUp: function() {
+        setup_sample_html();
+        this.multi = make_editable_text({
+
+            contentBox: '#editable_multi_text',
+            multiline: true
+        });
+        this.multi.render();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.multi);
+    },
+
+    test_text_value_no_trailing_newlines: function() {
+        var text = this.multi.get('value');
+        Assert.areEqual(
+           "Some editable multi-line text.",
+           text,
+           "The editor kills trailing whitespace.");
+    }
+}));
+
+function FailedSavePlugin() {
+  FailedSavePlugin.superclass.constructor.apply(this, arguments);
+}
+
+FailedSavePlugin.NAME = 'failedsave';
+FailedSavePlugin.NS = 'test';
+
+Y.extend(FailedSavePlugin, Y.Plugin.Base, {
+    initializer: function(config) {
+      this.doBefore("_saveData", this._altSave);
+    },
+
+    _altSave: function() {
+      var host  = this.get('host');
+      // Set the UI 'waiting' status.
+      host._uiSetWaiting();
+      host.showError("Some error occurred.");
+      // Make sure we clear the 'waiting' status.
+      host._uiClearWaiting();
+      return new Y.Do.Halt();
+    }
+  });
+
+suite.add(new Y.Test.Case({
+    name: "Edit buttons enabled on error",
+
+    setUp: function() {
+        setup_sample_html();
+        this.multi = make_editable_text({
+
+            contentBox: '#editable_multi_text',
+            multiline: true
+        });
+        this.multi.render();
+        this.multi.show_editor();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.multi);
+    },
+
+    test_error_on_save_enabled_buttons: function() {
+        var editor = this.multi.editor;
+        editor.plug({fn:FailedSavePlugin});
+        // Now saving should invoke an error.
+        editor.save();
+        Assert.isTrue(editor.get('in_error'), "Editor should be in error");
+        // Both the submit and cancel buttons should be visible.
+        Assert.areEqual(
+            'inline',
+            editor.get('submit_button').getStyle('display'),
+            "Submit should be set to display:inline");
+        Assert.areEqual(
+            'inline',
+            editor.get('cancel_button').getStyle('display'),
+            "Cancel should be set to display:inline");
+    }
+}));
+
+
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added directory 'lib/lp/app/javascript/lazr/lazr'
=== added directory 'lib/lp/app/javascript/lazr/lazr/assets'
=== added directory 'lib/lp/app/javascript/lazr/lazr/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowLeft-inactive.png'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowLeft-inactive.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowLeft-inactive.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowLeft.png'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowLeft.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowLeft.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowRight-inactive.png'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowRight-inactive.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowRight-inactive.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowRight.png'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowRight.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/arrowRight.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/lazr-skin.css'
--- lib/lp/app/javascript/lazr/lazr/assets/skins/sam/lazr-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/lazr/assets/skins/sam/lazr-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,50 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+.yui3-skin-sam button.lazr-btn {
+  background: transparent no-repeat scroll center center;
+  overflow: hidden;
+  cursor: pointer;
+  border: none;
+  margin: 0;
+  margin-right: 4px;
+  padding: 0;
+  text-indent: 100em;
+  width: 16px;
+  /* Prevent icons from being cut off at the top and bottom in Safari 4. */
+  min-height: 16px;
+}
+
+.yui3-skin-sam button.lazr-pos {
+  background-image: url('positive.png');
+}
+
+.yui3-skin-sam button.lazr-neg {
+  background-image: url('negative.png');
+}
+
+.yui3-skin-sam button.lazr-search {
+  background-image: url('search.png');
+}
+
+.yui3-skin-sam button.lazr-prev {
+  background-image: url('arrowLeft.png');
+}
+
+.yui3-skin-sam button.lazr-next {
+  background-image: url('arrowRight.png');
+}
+
+.yui3-skin-sam button.lazr-prev:disabled {
+  background-image: url('arrowLeft-inactive.png');
+}
+
+.yui3-skin-sam button.lazr-next:disabled {
+  background-image: url('arrowRight-inactive.png');
+}
+
+.yui3-lazr-even {
+}
+
+.yui3-lazr-odd {
+  background-color: #f2f2f2;
+}

=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/negative.png'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/negative.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/negative.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/positive.png'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/positive.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/positive.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/search.png'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/search.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/search.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/assets/skins/sam/spinner.gif'
Binary files lib/lp/app/javascript/lazr/lazr/assets/skins/sam/spinner.gif	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/lazr/assets/skins/sam/spinner.gif	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/lazr/lazr.js'
--- lib/lp/app/javascript/lazr/lazr/lazr.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/lazr/lazr.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,148 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.base', function(Y) {
+
+var UI = Y.namespace('lazr.ui');
+
+var LAZR = 'lazr';
+var getCN = Y.ClassNameManager.getClassName;
+
+/**
+ * The LAZR standard 'positive' glyph as an HTML button template.  Used for
+ * "Ok" buttons, confirmations, etc.  It uses an image sprite for the icon.
+ *
+ * The button's default text is "Ok", and its default type is 'button'.
+ * http://www.w3.org/TR/html4/interact/forms.html#h-17.5
+ *
+ * @property lazr.ui.OK_BUTTON
+ * @type String
+ * @static
+ */
+UI.OK_BUTTON = '<button type="button" class="lazr-pos lazr-btn">Ok</button>';
+
+/**
+ * The LAZR standard 'negative' glyph as an HTML button template.  Used for
+ * "Cancel" buttons, etc.  It uses an image sprite for the icon.
+ *
+ * The button's default text is "Cancel", and its default type is 'button'.
+ * http://www.w3.org/TR/html4/interact/forms.html#h-17.5
+ *
+ * @property lazr.ui.CANCEL_BUTTON
+ * @type String
+ * @static
+ */
+UI.CANCEL_BUTTON = '<button type="button" class="lazr-neg lazr-btn">Cancel</button>';
+
+/**
+ * The LAZR standard 'search' glyph as an HTML button template.  Used for
+ * "Search" buttons, etc.  It uses an image sprite for the icon.
+ *
+ * The button's default text is "Search", and its default type is 'button'.
+ * http://www.w3.org/TR/html4/interact/forms.html#h-17.5
+ *
+ * @property lazr.ui.SEARCH_BUTTON
+ * @type String
+ * @static
+ */
+UI.SEARCH_BUTTON = '<button type="button" class="lazr-search lazr-btn">Search</button>';
+
+/**
+ * The LAZR standard 'previous' glyph as an HTML button template.  Used for
+ * "previous"-type buttons.  It uses an image sprite for the icon.
+ *
+ * The button's default text is "Previous", and its default type is 'button'.
+ * http://www.w3.org/TR/html4/interact/forms.html#h-17.5
+ *
+ * @property lazr.ui.PREVIOUS_BUTTON
+ * @type String
+ * @static
+ */
+UI.PREVIOUS_BUTTON = '<button type="button" class="lazr-prev lazr-btn">Previous</button>';
+
+/**
+ * The LAZR standard 'next' glyph as an HTML button template.  Used for
+ * "next"-type buttons.  It uses an image sprite for the icon.
+ *
+ * The button's default text is "Next", and its default type is 'button'.
+ * http://www.w3.org/TR/html4/interact/forms.html#h-17.5
+ *
+ * @property lazr.ui.NEXT_BUTTON
+ * @type String
+ * @static
+ */
+UI.NEXT_BUTTON = '<button type="button" class="lazr-next lazr-btn">Next</button>';
+
+/**
+ * Standard CSS class for even elements in a listing.
+ *
+ * @property lazr.ui.CSS_EVEN
+ * @type String
+ * @static
+ */
+UI.CSS_EVEN = getCN(LAZR, 'even');
+
+/**
+ * Standard CSS class for odd elements in a listing.
+ *
+ * @property lazr.ui.CSS_ODD
+ * @type String
+ * @static
+ */
+UI.CSS_ODD = getCN(LAZR, 'odd');
+
+/**
+ * This function forces a class to have a tabIndex attribute which
+ * takes the widget's boundingBox out of the tab order.
+ * It is intended to be called on subclasses of Widget.
+ *
+ * Use with caution.  tabindex is intended as a usability feature, for
+ * keyboard accessibility, and visual feedback.  If you disable it, be sure to
+ * have a really good reason, or a replacement ready.
+ *
+ * @method disableTabIndex
+ * @param {Class} widget_class Widget that should not be in the tab order.
+ */
+UI.disableTabIndex = function(widget_class) {
+    if (widget_class === undefined) {
+        throw "disableTabIndex() must be called after ATTRS " +
+              "is set on the widget.";
+    }
+    widget_class.ATTRS.tabIndex = {
+        readOnly: true,
+        value: -1
+    };
+};
+
+/**
+ * Standard class for the UI 'waiting for new content' indicator.
+ *
+ * @property lazr.ui.CSS_WAITING
+ * @type String
+ * @static
+ */
+UI.CSS_WAITING = 'lazr-waiting';
+
+/**
+ * This function sets the lazr 'waiting' CSS class on the given node.
+ *
+ * @method waiting
+ * @param node {Node} The node to apply the CSS 'waiting' class to.
+ * @chainable
+ */
+UI.waiting = function(node) {
+    node.addClass(UI.CSS_WAITING);
+};
+
+/**
+ * Clears the lazr 'waiting' CSS class from the given node.
+ *
+ * @method clear_waiting
+ * @param node {Node} The node to remove the class from.
+ * @chainable
+ */
+UI.clear_waiting = function(node) {
+    node.removeClass(UI.CSS_WAITING);
+};
+
+
+}, "0.1", {"skinnable": true, "requires": ["classnamemanager"]});

=== added directory 'lib/lp/app/javascript/lazr/loader'
=== added file 'lib/lp/app/javascript/lazr/loader/prefetch.js'
--- lib/lp/app/javascript/lazr/loader/prefetch.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/loader/prefetch.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,87 @@
+/* Copyright (c) 2010, Canonical Ltd. All rights reserved. */
+
+/**
+ * Prefetch a set of files, deferring calls to 'use' until the
+ * preloaded files are finished loading.
+ *
+ * @public
+ */
+
+YUI.prototype.prefetch = function prefetch() {
+    var Y = this,
+        deferred = [],
+        preload = arguments,
+        pending = arguments.length;
+
+    var YArray_each = Y.Array.each,
+        YGet_script = Y.Get.script;
+
+    /**
+     * Wrap the native 'use' function to add some smarts around
+     * our custom loading of the rolled-up minified files so that
+     * we delay the loader from firing until the rolled-up files
+     * are finished loading, in order to avoid loading the
+     * modules twice.
+     */
+    var native_use = Y.use;
+
+    /* Now replace the original 'use' function with our own. */
+    Y.use = function use() {
+        /**
+         * If all external dependencies have been loaded, just
+         * call the native 'use' function directly.
+         */
+        if (!pending) {
+            native_use.apply(Y, arguments);
+        } else {
+            /**
+             * If there are things still loading, queue calls to 'use'
+             *  until they are finished.
+             */
+            var ridx = arguments.length,
+            args = [];
+            /* Make a copy of the original arguments. */
+            while (--ridx >= 0) {
+                args[ridx] = arguments[ridx];
+            }
+
+            /* Push copied arguments into the queue. */
+            deferred.push(args);
+        }
+    };
+
+    /**
+     * For each item to be preloaded, use the Y.Get utility to
+     * fetch the script (which might fetch them in parallel). When
+     * all the scripts are finished loading, we'll process the
+     * deferred calls to use with the native 'use' function.
+     */
+    YArray_each(preload, function(value) {
+        YGet_script(value, {onEnd: function() {
+            /**
+             * Once an item has finished preloading, we decrement
+             * the pending variable. Once it reaches zero, we
+             * know all preload items have finished loading.
+             */
+            pending--;
+
+            /**
+             * Once we're done, restore the original 'use'
+             * function and call all of the deferred callbacks in
+             * their original order.
+             */
+            if (!pending) {
+                Y.use = native_use;
+
+                /**
+                 * Attach the 'loader' module, which *should* be
+                 * already loaded by now.
+                 */
+                Y._attach(["loader"]);
+                YArray_each(deferred, function(value) {
+                    native_use.apply(this, value);
+                });
+            }
+        }, attributes: {defer: "defer"}});
+    });
+};

=== added directory 'lib/lp/app/javascript/lazr/overlay'
=== added directory 'lib/lp/app/javascript/lazr/overlay/assets'
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/pretty-overlay-core.css'
--- lib/lp/app/javascript/lazr/overlay/assets/pretty-overlay-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/overlay/assets/pretty-overlay-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,43 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+/* blocking-div appears above everything else. */
+.blocking-div {
+    z-index: 999;
+    opacity:0;
+    filter:alpha(opacity=0);
+    position:absolute;
+    border:none;
+    top:0px;
+    left:0px;
+    padding:0;
+    margin:0;
+    width:100%;
+    height:100%;
+}
+
+/* overlay appears above blocking-div. */
+.yui3-pretty-overlay {
+    background-color: #fff;
+    z-index: 1000;
+    text-align: left;
+
+    -moz-border-radius: 5px;
+    -webkit-border-radius: 5px;
+    border-radius: 5px;
+
+    -moz-box-shadow: 0px 0px 20px 10px #aaa;
+    -webkit-box-shadow: 0px 0px 20px 10px #aaa;
+    box-shadow: 0px 0px 20px 10px #aaa;
+}
+
+/* Ensure that td has no border (YUI base css adds one). */
+.yui3-pretty-overlay td {
+    border-width: 0;
+    padding: 0;
+}
+
+/* Hide the overlay if you use PrettyOverlay directly; if you subclass,
+   you have to do this yourself with your own class yui3-yourclass-hidden. */
+.yui3-pretty-overlay-hidden {
+    visibility: hidden;
+}

=== added directory 'lib/lp/app/javascript/lazr/overlay/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam'
=== added directory 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images'
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_h2.gif'
Binary files lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_h2.gif	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_h2.gif	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_steps-estatus.gif'
Binary files lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_steps-estatus.gif	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_steps-estatus.gif	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_steps.gif'
Binary files lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_steps.gif	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/bg_steps.gif	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/btn_back.gif'
Binary files lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/btn_back.gif	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/btn_back.gif	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/close.gif'
Binary files lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/close.gif	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/close.gif	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/stats.png'
Binary files lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/stats.png	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/overlay/assets/skins/sam/images/stats.png	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/overlay/assets/skins/sam/pretty-overlay-skin.css'
--- lib/lp/app/javascript/lazr/overlay/assets/skins/sam/pretty-overlay-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/overlay/assets/skins/sam/pretty-overlay-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,104 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+/* Modal Box */
+
+.yui3-pretty-overlay {
+    border: none;
+    position: absolute;
+    top: 50px;
+    padding: 0;
+    margin: 0;
+    left: 405px;
+    min-width: 40%;
+}
+
+.yui3-pretty-overlay #yui3-pretty-overlay-modal {
+    margin: 7px 0px;
+    padding: 0;
+    font: normal normal 12px/normal;
+    color: #484848;
+    background: #fff;
+}
+
+.yui3-pretty-overlay .close {
+    margin: 0;
+    padding: 0 5px;
+    font: normal normal 10px/normal;
+    color: #484848;
+    background: #fff;
+}
+
+.yui3-pretty-overlay .close a {
+    float: right;
+    width: 15px;
+    height: 15px;
+    background: url('images/close.gif');
+    display: block;
+    margin-top: 4px;
+}
+
+.yui3-pretty-overlay .close .clear {
+    clear: both;
+}
+
+.yui3-pretty-overlay #yui3-pretty-overlay-modal h1,
+.yui3-pretty-overlay #yui3-pretty-overlay-modal h2 {
+    font: normal normal 18px/normal;
+    color: #000;
+    text-indent: 15px;
+    margin: 0;
+    padding: 0;
+}
+
+.yui3-pretty-overlay #yui3-pretty-overlay-modal h2 {
+    height: 30px;
+    font: normal normal 18px;
+    color: #000;
+}
+
+.yui3-pretty-overlay #yui3-pretty-overlay-modal .steps h2 {
+    height: 30px;
+    font: normal normal 14px/30px;
+    color: #666;
+}
+
+.yui3-pretty-overlay #yui3-pretty-overlay-modal h2 strong {
+    color: #000;
+    font-weight: normal;
+}
+
+.yui3-pretty-overlay .steps {
+    width: 100%;
+    height: 3px;
+    color: #666;
+    background: #f0f0f0 url('images/bg_steps.gif') bottom repeat-x;
+    border-top: 1px solid #e6e6e6;
+}
+
+.yui3-pretty-overlay .contains-steptitle {
+  height: 33px;
+}
+
+.yui3-pretty-overlay .step-on,
+.yui3-pretty-overlay .step-onb,
+.yui3-pretty-overlay .step-off,
+.yui3-pretty-overlay .step-offb {
+    width: 100%;
+    height: 3px;
+    background: green url('images/bg_steps-estatus.gif') top repeat-x;
+}
+
+.yui3-pretty-overlay .step-onb,
+.yui3-pretty-overlay .step-offb {
+    border-bottom: 1px solid #e6e6e6;
+    margin-bottom: 15px;
+}
+
+.yui3-pretty-overlay .step-off,
+.yui3-pretty-overlay .step-offb {
+    background: gray url('images/bg_steps-estatus.gif') bottom repeat-x;
+}
+
+.yui3-pretty-overlay .yui3-widget-bd {
+    margin-left: 1em;
+    margin-right: 1em;
+}

=== added file 'lib/lp/app/javascript/lazr/overlay/overlay.js'
--- lib/lp/app/javascript/lazr/overlay/overlay.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/overlay/overlay.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,351 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.overlay', function(Y) {
+
+/**
+ * LAZR-specific overlay implementation.
+ *
+ * @module lazr.overlay
+ */
+
+var ESCAPE = 27,
+    CANCEL = 'cancel',
+    BOUNDING_BOX = 'boundingBox',
+    CONTENT_BOX = 'contentBox',
+    BINDUI = "bindUI";
+
+
+   /**
+    * An Overlay subclass which draws a rounded-corner, drop-shadow
+    * border around the content.
+    * TODO PrettyOverlay implements an in-page modal dialog box.
+    * The background is blocked using a layer, in order to prevent
+    * clicks on other elements on the page. Pressing Escape or clicking
+    * the close button at the top-right corner dismisses the box.
+    *
+    * Note: Classes extending PrettyOverlay must have a corresponding
+    * yui3-widget-name-hidden CSS class in order to allow hiding.
+    * Also, all extending classes must explicitly calls PrettyOverlay's
+    * bindUI method in order to get the event handlers attached.
+    *
+    * @class PrettyOverlay
+    * @namespace lazr
+    */
+    var PrettyOverlay = function() {
+        // Check whether the callsite has set a zIndex... if not, set it
+        // to 1000, as the YUI.overlay default is zero.
+        if (arguments[0] && arguments[0].zIndex === undefined){
+            arguments[0].zIndex = 1000;
+        }
+        PrettyOverlay.superclass.constructor.apply(this, arguments);
+        Y.after(this._bindUIPrettyOverlay, this, BINDUI);
+
+    };
+
+    PrettyOverlay.NAME = 'pretty-overlay';
+
+    PrettyOverlay.ATTRS = {
+       /**
+        * The value, in percentage, of the progress bar.
+        *
+        * @attribute progress
+        * @type Float
+        * @default 100
+        */
+        progress: {
+            value: 100
+        },
+
+       /**
+        * Should the progress bar be shown?
+        * (note that if set to true, headerContent must be supplied).
+        *
+        * @attribute progressbar
+        * @type Boolean
+        * @default true
+        */
+        progressbar: {
+            value: true
+        },
+
+       /**
+        * Title for this step, displayed below the progressbar.
+        * (you must have a progressbar to have a steptitle)
+        *
+        * @attribute steptitle
+        * @type Boolean
+        * @default null
+        */
+        steptitle: {
+          value: null
+        }
+    };
+
+    Y.extend(PrettyOverlay, Y.Overlay, {
+        /**
+         * The div element shown behind the modal dialog.
+         *
+         * @private
+         * @property _blocking_div
+         * @type Node
+         */
+        _blocking_div: null,
+
+        /**
+         * The key press handler..
+         *
+         * @private
+         * @property _doc_kp_handler
+         * @type EventHandle
+         */
+        _doc_kp_handler: null,
+
+        /**
+         * The div displaying the prograss bar.
+         *
+         * @private
+         * @property _green_bar
+         * @type Node
+         */
+        _green_bar: null,
+
+       /**
+        * Create the DOM elements needed by the widget.
+        *
+        * @protected
+        * @method initializer
+        */
+        initializer: function() {
+            // The 20% width style is here to force
+            // legacy browsers to include an accessible
+            // style attribute.
+            this._green_bar = Y.Node.create([
+              '<div class="steps">',
+              '<div class="step-on" style="width:20%;">',
+              '</div></div>'].join(""));
+
+            this._blocking_div = Y.Node.create(
+                '<div class="blocking-div"></div>');
+
+            this.after("renderedChange", function() {
+                var bounding_box = this.get(BOUNDING_BOX);
+                var content_box = this.get(CONTENT_BOX);
+                var content_box_container = bounding_box.one(
+                    ".content_box_container");
+                if (content_box_container) {
+                    content_box_container.appendChild(content_box);
+                }
+                this._setupCloseFacilities();
+            });
+
+            this.after('visibleChange', function(e) {
+                this._setupCloseFacilities();
+            });
+
+           /**
+            * Fires when the user presses the 'Cancel' button.
+            *
+            * @event cancel
+            * @preventable _defCancel
+            */
+            this.publish(CANCEL, {
+                defaultFn: this._defaultCancel
+            });
+        },
+
+        /**
+         * Event handler to update HTML when steptitle is set.
+         *
+         * @private
+         * @param e {Event.Facade}
+         * @method _afterSteptitleChange
+         */
+        _afterSteptitleChange: function(e) {
+            // It's only possible to  have a step title
+            // if you also have a progress bar.
+            var progress_bar = this.get(BOUNDING_BOX).one(".steps");
+            if (!progress_bar) {
+                return;
+            }
+            var h2 = progress_bar.one("h2");
+            if (!h2) {
+              h2 = Y.Node.create("<h2></h2>");
+              progress_bar.appendChild(h2);
+              progress_bar.addClass("contains-steptitle");
+            }
+            // We can't just set innerHTML here because Firefox gets it wrong
+            // so remove all existing nodes and add the steptitle as a textnode
+            while (h2.hasChildNodes()) {
+              h2.removeChild(h2.get("firstChild"));
+            }
+            h2.appendChild(document.createTextNode(this.get("steptitle")));
+        },
+
+        /**
+         * Handle the progress change event, adjusting the display
+         * of the progress bar.
+         *
+         * @private
+         * @param e {Event.Facade}
+         * @method _afterProgressChange
+         */
+        _afterProgressChange: function(e) {
+            var width = parseInt(this.get("progress"), 10);
+            if (width < 0) {
+                width = 0;
+            }
+            if (width > 100) {
+                width = 100;
+            }
+            if (this.get("progressbar") &&
+                this.get(CONTENT_BOX).one(".steps")) {
+                // The prograss bar is only being created if
+                // you both ask for it and supply header content
+                var progress_steps = this.get(CONTENT_BOX).one(".step-on");
+                progress_steps.setStyle("width", width + "%");
+            }
+        },
+
+        /**
+         * Hook the events for the escape key press and include
+         * the blocking div.
+         *
+         * @protected
+         * @method _setupCloseFacilities
+         */
+        _setupCloseFacilities: function() {
+            var self = this;
+            var visible = this.get('visible');
+            if (visible) {
+                Y.one('body').appendChild(this._blocking_div);
+                // Handle Escape (code 27) on keydown.
+                this._doc_kp_handler = Y.on('key', function() {
+                        self.fire(CANCEL);
+                    }, document, 'down:27');
+            } else {
+                this._removeBlockingDiv();
+            }
+        },
+
+        /**
+         * Remove the HTML for the blocking DIV.
+         *
+         * @method _removeBlockingDiv
+         */
+        _removeBlockingDiv: function() {
+            if (this._blocking_div) {
+                var blocking_div = Y.one(this._blocking_div);
+                if (blocking_div) {
+                    var parent = blocking_div.get('parentNode');
+                    if (parent) {
+                        parent.removeChild(this._blocking_div);
+                    }
+                }
+            }
+        },
+
+        /**
+         * Destroy the widget (remove its HTML from the page).
+         *
+         * @method destructor
+         */
+        destructor: function() {
+            this._removeBlockingDiv();
+            if (this._doc_kp_handler) {
+                this._doc_kp_handler.detach();
+            }
+        },
+
+        /**
+         * Bind UI events.
+         * <p>
+         * This method is invoked after bindUI is invoked for the Widget class
+         * using YUI's aop infrastructure.
+         * </p>
+         *
+         * @method _bindUIPrettyOverlay
+         * @protected
+         */
+        _bindUIPrettyOverlay: function() {
+            var self = this;
+            var close_button = this.get(BOUNDING_BOX).one('.close a');
+            close_button.on('click', function(e) {
+                e.halt();
+                self.fire(CANCEL);
+            });
+            this._blocking_div.on('click', function(e) {
+                e.halt();
+                self.fire(CANCEL);
+            });
+            // Ensure that when the overlay is clicked, it doesn't stay
+            // focused (with the ugly gray border).
+            var bounding_box = this.get(BOUNDING_BOX);
+            bounding_box.on('click', function(e) {
+                bounding_box.blur();
+            });
+            this.after('steptitleChange', this._afterSteptitleChange);
+            this.after('progressChange', this._afterProgressChange);
+        },
+
+        /**
+         * Event handler for cancel event; hides the widget.
+         *
+         * @private
+         * @method _defaultCancel
+         */
+        _defaultCancel: function(e) {
+            this.hide();
+            this._doc_kp_handler.detach();
+        },
+
+        /**
+         * Overrides the method from WidgetStdMod which creates the separate
+         * sections in the contentBox to also add the progressbar widget
+         * after headerContent.
+         *
+         * @private
+         * @method _insertStdModSection
+         */
+        _insertStdModSection: function(content_box, section, section_node) {
+            PrettyOverlay.superclass._insertStdModSection.apply(
+                this, arguments);
+            if (section === Y.WidgetStdMod.HEADER &&
+                this.get("progressbar"))
+            {
+                var nxt = section_node.next();
+                if (nxt) {
+                  content_box.insertBefore(this._green_bar, nxt);
+                } else {
+                  content_box.appendChild(this._green_bar);
+                }
+            }
+            this._afterProgressChange();
+            if (this.get('steptitle')) {
+                this._afterSteptitleChange();
+            }
+        }
+    });
+
+   /**
+    * The HTML for drawing the border.
+    *
+    * The border is implemented using a table. The content area is
+    * marked with the `content_box_container` class so that the widget
+    * can find it and insert the content box into it.
+    *
+    * @property BOUNDING_TEMPLATE
+    */
+    PrettyOverlay.prototype.BOUNDING_TEMPLATE = [
+        '<div class="pretty-overlay-window">',
+        '<div class="content_box_container" id="yui3-pretty-overlay-modal">',
+        '<div class="close">',
+        '<a href="#" title="Close" class="close-button"></a>',
+        '</div>',
+        '</div>',
+        '</div>'].join('');
+
+    Y.namespace('lazr');
+
+    Y.lazr.PrettyOverlay = PrettyOverlay;
+
+}, "0.1", {"skinnable": true, "requires": ["oop", "overlay", "event", "widget", "widget-stack", "widget-position"]});

=== added directory 'lib/lp/app/javascript/lazr/overlay/tests'
=== added file 'lib/lp/app/javascript/lazr/overlay/tests/overlay.html'
--- lib/lp/app/javascript/lazr/overlay/tests/overlay.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/overlay/tests/overlay.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,26 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Pretty Overlay</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../overlay/overlay.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="overlay.js"></script>
+
+</head>
+<body class="yui3-skin-sam">
+  <div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/overlay/tests/overlay.js'
--- lib/lp/app/javascript/lazr/overlay/tests/overlay.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/overlay/tests/overlay.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,217 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.overlay', 'lazr.testing.runner', 'node',
+          'event', 'event-simulate', 'widget-stack', 'console', function(Y) {
+
+// KeyCode for escape
+var ESCAPE = 27;
+
+// Local aliases
+var Assert = Y.Assert,
+    ArrayAssert = Y.ArrayAssert;
+
+/*
+ * A wrapper for the Y.Event.simulate() function.  The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(widget, selector, evtype, options) {
+    var rawnode = Y.Node.getDOMNode(widget.one(selector));
+    Y.Event.simulate(rawnode, evtype, options);
+}
+
+/* Helper function to clean up a dynamically added widget instance. */
+function cleanup_widget(widget) {
+    // Nuke the boundingBox, but only if we've touched the DOM.
+    if (!widget) {
+        return;
+    }
+    if (widget.get('rendered')) {
+        var bb = widget.get('boundingBox');
+        bb.get('parentNode').removeChild(bb);
+    }
+    // Kill the widget itself.
+    widget.destroy();
+}
+
+var suite = new Y.Test.Suite("LAZR Pretty Overlay Tests");
+
+suite.add(new Y.Test.Case({
+
+    name: 'pretty_overlay_basics',
+
+    setUp: function() {
+        this.overlay = null;
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.overlay);
+    },
+
+    hitEscape: function() {
+        simulate(this.overlay.get('boundingBox'),
+                 '.close .close-button',
+                 'keydown', { keyCode: ESCAPE });
+    },
+
+    test_picker_can_be_instantiated: function() {
+        this.overlay = new Y.lazr.PrettyOverlay();
+        Assert.isInstanceOf(
+            Y.lazr.PrettyOverlay, this.overlay, "Overlay not instantiated.");
+    },
+
+    test_overlay_has_elements: function() {
+        this.overlay = new Y.lazr.PrettyOverlay();
+        this.overlay.render();
+        var bb = this.overlay.get('boundingBox');
+        Assert.isNotNull(
+            bb.one('.close'),
+            "Missing close button div.");
+        Assert.isNotNull(
+            bb.one('.close .close-button'),
+            "Missing close button.");
+    },
+
+    test_overlay_can_show_progressbar: function() {
+        this.overlay = new Y.lazr.PrettyOverlay({'headerContent': 'bu bu bu'});
+        var bb = this.overlay.get('boundingBox');
+        this.overlay.render();
+        Assert.isNotNull(
+            bb.one('.steps'),
+            "Progress bar is not present.");
+    },
+
+    test_overlay_can_hide_progressbar: function() {
+        this.overlay = new Y.lazr.PrettyOverlay({progressbar: false});
+        this.overlay.render();
+        var bb = this.overlay.get('boundingBox');
+        bb.set('headerContent', 'ALL HAIL DISCORDIA!');
+        Assert.isNull(
+            bb.one('.steps'),
+            "Progress bar is present when it shouldn't be.");
+    },
+
+    test_overlay_can_show_steptitle: function() {
+        this.overlay = new Y.lazr.PrettyOverlay({
+            'headerContent': 'Fnord',
+            'steptitle': 'No wife, no horse and no moustache'});
+        var bb = this.overlay.get('boundingBox');
+        this.overlay.render();
+        Assert.isNotNull(
+            bb.one('.contains-steptitle h2'),
+            "Step title is not present.");
+    },
+
+    test_overlay_can_hide_steptitle: function() {
+        this.overlay = new Y.lazr.PrettyOverlay({progressbar: false});
+        this.overlay.render();
+        var bb = this.overlay.get('boundingBox');
+        bb.set('headerContent', 'ALL HAIL DISCORDIA!');
+        Assert.isNull(
+            bb.one('.contains-steptitle h2'),
+            "Step title is present when it shouldn't be.");
+    },
+
+    test_click_cancel_hides_the_widget: function() {
+        /* Test that clicking the cancel button hides the widget. */
+        this.overlay = new Y.lazr.PrettyOverlay();
+        this.overlay.render();
+
+        simulate(this.overlay.get('boundingBox'), '.close .close-button', 'click');
+        Assert.isFalse(this.overlay.get('visible'), "The widget wasn't hidden");
+    },
+
+    test_click_cancel_fires_cancel_event: function() {
+        this.overlay = new Y.lazr.PrettyOverlay();
+        this.overlay.render();
+
+        var event_was_fired = false;
+        this.overlay.subscribe('cancel', function() {
+                event_was_fired = true;
+        }, this);
+        simulate(this.overlay.get('boundingBox'), '.close .close-button','click');
+        Assert.isTrue(event_was_fired, "cancel event wasn't fired");
+    },
+
+    test_stroke_escape_hides_the_widget: function() {
+        /* Test that stroking the escape button hides the widget. */
+        this.overlay = new Y.lazr.PrettyOverlay();
+        this.overlay.render();
+
+        Assert.isTrue(this.overlay.get('visible'), "The widget wasn't visible");
+        this.hitEscape();
+        Assert.isFalse(this.overlay.get('visible'), "The widget wasn't hidden");
+    },
+
+    test_stroke_escape_fires_cancel_event: function() {
+        this.overlay = new Y.lazr.PrettyOverlay();
+        this.overlay.render();
+
+        var event_was_fired = false;
+        this.overlay.subscribe('cancel', function() {
+            event_was_fired = true;
+        }, this);
+        this.hitEscape();
+        Assert.isTrue(event_was_fired, "cancel event wasn't fired");
+    },
+
+    test_show_again_re_hooks_events: function() {
+        /* Test that hiding the overlay and showing it again
+         * preserves the event handlers.
+         */
+        this.overlay = new Y.lazr.PrettyOverlay();
+        this.overlay.render();
+
+        this.hitEscape();
+        Assert.isFalse(this.overlay.get('visible'), "The widget wasn't hidden");
+        this.overlay.show();
+        Assert.isTrue(this.overlay.get('visible'), "The widget wasn't shown again");
+        this.hitEscape();
+        Assert.isFalse(this.overlay.get('visible'), "The widget wasn't hidden");
+    },
+
+    test_pretty_overlay_without_header: function() {
+        this.overlay = new Y.lazr.PrettyOverlay();
+        function PrettyOverlaySubclass(config) {
+            PrettyOverlaySubclass.superclass.constructor.apply(this, arguments);
+        }
+        PrettyOverlaySubclass.NAME = 'lazr-overlaysubclass';
+        Y.extend(PrettyOverlaySubclass, Y.lazr.PrettyOverlay);
+
+        var overlay = new PrettyOverlaySubclass({bodyContent: "Hi"});
+        // This shouldn't raise an error if the header content is not
+        // supplied and progressbar is set to `true`.
+        overlay.render();
+        cleanup_widget(overlay);
+    },
+
+    test_overlay_bodyContent_has_size_1: function() {
+        this.overlay = new Y.Overlay({
+            headerContent: 'Form for testing',
+            bodyContent: '<input type="text" name="field1" />'
+        });
+        this.overlay.render();
+        Assert.areEqual(
+            1,
+            this.overlay.get("bodyContent").size(),
+            "The bodContent should contain only one node.");
+    },
+
+    test_set_progress: function() {
+        // test that the progress bar is settable
+        this.overlay = new Y.lazr.PrettyOverlay({
+            'headerContent': 'Fnord',
+            'steptitle': 'No wife, no horse and no moustache'});
+        this.overlay.render();
+        this.overlay.set('progress', 23);
+        Assert.areEqual(
+            '23%',
+            this.overlay.get('boundingBox').one('.steps .step-on').getStyle('width')
+        );
+    }
+
+}));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added directory 'lib/lp/app/javascript/lazr/passwordmeter'
=== added directory 'lib/lp/app/javascript/lazr/passwordmeter/assets'
=== added directory 'lib/lp/app/javascript/lazr/passwordmeter/assets/images'
=== added file 'lib/lp/app/javascript/lazr/passwordmeter/assets/images/gradient.jpg'
Binary files lib/lp/app/javascript/lazr/passwordmeter/assets/images/gradient.jpg	1970-01-01 00:00:00 +0000 and lib/lp/app/javascript/lazr/passwordmeter/assets/images/gradient.jpg	2011-06-30 12:01:55 +0000 differ
=== added file 'lib/lp/app/javascript/lazr/passwordmeter/assets/passwordmeter-core.css'
--- lib/lp/app/javascript/lazr/passwordmeter/assets/passwordmeter-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/passwordmeter/assets/passwordmeter-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,26 @@
+
+.bar {
+    background-image: url('images/gradient.jpg');
+    background-position: 0 0;
+    background-repeat: no-repeat;
+    position: absolute;
+    z-index: 0;
+}
+
+.border {
+    border: solid black 1px;
+}
+
+.border, .bar, .percentage {
+    width: 100px;
+    height: 16px;
+}
+
+.percentage {
+    position: relative;
+    z-index: 1;
+    text-align: center;
+    top: -16px;
+    font-weight: bold;
+    color: black;
+}

=== added directory 'lib/lp/app/javascript/lazr/passwordmeter/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/passwordmeter/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/passwordmeter/assets/skins/sam/passwordmeter-skin.css'
--- lib/lp/app/javascript/lazr/passwordmeter/assets/skins/sam/passwordmeter-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/passwordmeter/assets/skins/sam/passwordmeter-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,4 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+/* Placeholder for skinning of the PasswordMeter widget */
+

=== added file 'lib/lp/app/javascript/lazr/passwordmeter/passwordmeter.js'
--- lib/lp/app/javascript/lazr/passwordmeter/passwordmeter.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/passwordmeter/passwordmeter.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,222 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.passwordmeter', function(Y){
+
+/**
+ * A password strength meter widget.
+ *
+ *
+ * Given the id of a password field and the name of a function that will check
+ * the strength of the input password this meter will display on each character
+ * entered the strength value computed by the function provided.
+ *
+ * @module lazr.passwordmeter
+ * @namespace lazr
+ */
+Y.namespace('lazr');
+
+/**
+ * @class PasswordMeter
+ * @extends Widget
+ * @constructor
+ */
+var PasswordMeter = function(){
+	PasswordMeter.superclass.constructor.apply(this, arguments);
+};
+
+PasswordMeter.NAME = 'passwordmeter';
+
+/**
+ * The HTML_PARSER static constant is used by the Widget base class to
+ * populate the configuration for the PasswordMeter instance from markup
+ * already on the page.
+ *
+ * @property PasswordMeter.HTML_PARSER
+ * @type Object
+ * @static
+ */
+PasswordMeter.HTML_PARSER = {
+};
+
+/**
+ * This function will be used to determine the password's strength. It only
+ * gives a 100% strength if the password has a combination of lower case, upper
+ * case, numeric and symbolic characters with at least a length of 8.
+ * The function can be replaed by the user of the widget by simply supplying
+ * their own function in the "func" attribute.
+ * The function returns two values: strength which is an int between 0 and 100
+ * and text which is one of the following strings: weak, medium, strong.
+ */
+var default_password_strength_function = function(password) {
+    var color = '#8b0000';
+    var text = '';
+    if (password) {
+        var len = password.length;
+        var hasLower = /[a-z]/.test(password) ? 1 : 0;
+        var hasUpper = /[A-Z]/.test(password) ? 1 : 0;
+        var hasNumber = /\d/.test(password) ? 1 : 0;
+        var hasSymbol = /[\~\`\!\@\#\$\%\^\&\*\(\)\_\-\+\=\{\}\[\]\:\;\"\'\<\>\?\|\\\,\.\/]/.test(password) ? 1 : 0;
+        var points = hasLower + hasUpper + hasNumber + hasSymbol;
+		if (len < 7) {
+			return {
+				'color': '#8b0000',
+				'text': 'Stength: too short'
+			}
+		}
+		sum = hasLower + hasUpper + hasNumber + hasSymbol;
+		switch(sum) {
+			case 1:
+				color = '#f99300';
+				text = 'fair';
+				break;
+			case 2:
+			case 3:
+				color= '#1e6400';
+				text = 'good';
+				break;
+			case 4:
+				color = '#1e6400';
+				text = 'strong';
+				break;
+		}
+	}
+	return {
+		'color': color,
+		'text': "Strength: " + text
+	}
+};
+
+PasswordMeter.ATTRS = {
+	/**
+	 * Current color for the password strength
+	 *
+	 * @attribute color
+	 * @type String
+	 * @default #8b0000
+	 */
+	color: {
+		value: '#8b0000'
+	},
+
+	/**
+	 * Current description of password strength
+	 *
+	 * @attribute text
+	 * @type String
+	 * @default ""
+	 */
+	text: {
+		value: ""
+	},
+
+	/**
+	 * Password field we will be monitoring
+	 *
+	 * @attribute input
+	 * @type Node
+	 * @default null
+	 */
+	input: {
+		value: null,
+		setter: function(v){
+			return Y.one(v);
+		}
+	},
+
+	/**
+	 * Javascript function that will calculate the password's strength
+	 * and strength description
+	 * Should return an object literal in the form
+	 * {"color": <color of the text displaying the strength>,
+	 *  "text": <textual description of the strength>}
+	 *
+	 * @attribute func
+	 * @type function
+	 * @default a standard password strength function
+	 */
+	func: {
+		value: default_password_strength_function,
+		validator: function(val) {
+			return Y.Lang.isFunction(val);
+		}
+	},
+}; //end ATTRS
+Y.extend(PasswordMeter, Y.Widget, {
+	/**
+	 * Initialize the widget.
+	 *
+	 * @method initializer
+	 * @protected
+	 */
+	initializer: function(cfg){
+	},
+
+	/**
+	 * Destroy the widget.
+	 *
+	 * @method dest
+	 * @protected
+	 */
+	destructor: function(){
+	},
+
+	/**
+	 * Update the DOM structure and edit CSS classes.
+	 *
+	 * @method renderUI
+	 * @protected
+	 */
+	renderUI: function(){
+		this.get("contentBox").setStyle('width', this.get('input').getStyle('width'));
+		this.get("contentBox").setStyle('height', this.get('input').getStyle('height'));
+		this.updateDesplay();
+	},
+
+	/**
+	 * Set the event handlers for the input element.
+	 *
+	 * @method bindUI
+	 * @protected
+	 */
+	bindUI: function(){
+		var input = this.get('input');
+		input.on('keyup', this._onKeyup, this);
+	},
+
+	/**
+	 * Synchronize the DOM with our current attribute state
+	 *
+	 * @method syncUI
+	 * @protected
+	 */
+	syncUI: function(){
+		this.updateDesplay();
+	},
+
+	/**
+	 * Capture each password character
+	 *
+	 * @method _onKeyup
+	 * @protected
+	 * @param e {Event.Custom} The event object.
+	 */
+	_onKeyup: function(e){
+		var input = this.get('input');
+		var password = input.get('value');
+		var result = this.get('func')(password);
+		this.set('color', result.color);
+		this.set('text', result.text);
+		this.syncUI();
+	},
+
+	updateDesplay: function() {
+		this.get('contentBox').set('innerHTML', this.get('text'));
+		this.get('contentBox').setStyle("color", this.get('color'));
+	}
+}); //end extend
+
+Y.PasswordMeter = PasswordMeter;
+}, "0.1", {
+    "skinnable": true,
+    "requires": ["widget", "lazr.base"]
+});

=== added directory 'lib/lp/app/javascript/lazr/passwordmeter/tests'
=== added file 'lib/lp/app/javascript/lazr/passwordmeter/tests/passwordmeter.html'
--- lib/lp/app/javascript/lazr/passwordmeter/tests/passwordmeter.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/passwordmeter/tests/passwordmeter.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,30 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>passwordmeter unit tests</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../passwordmeter.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="passwordmeter.js"></script>
+
+</head>
+<body class="yui-skin-sam">
+
+  <!-- Widget markup goes here... -->
+
+  <div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/passwordmeter/tests/passwordmeter.js'
--- lib/lp/app/javascript/lazr/passwordmeter/tests/passwordmeter.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/passwordmeter/tests/passwordmeter.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,174 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.passwordmeter', 'lazr.testing.runner', 'node',
+          'event', 'console', function(Y) {
+
+// Local aliases
+var Assert = Y.Assert,
+    ArrayAssert = Y.ArrayAssert;
+
+/*
+ * A wrapper for the Y.Event.simulate() function.  The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(widget, selector, evtype, options) {
+    var rawnode = Y.Node.getDOMNode(widget.one(selector));
+    Y.Event.simulate(rawnode, evtype, options);
+}
+
+/* Helper function to clean up a dynamically added widget instance. */
+function cleanup_widget(widget) {
+    // Nuke the boundingBox, but only if we've touched the DOM.
+    if (widget.get('rendered')) {
+        var bb = widget.get('boundingBox');
+        if (bb.get('parentNode')) {
+            bb.get('parentNode').removeChild(bb);
+        }
+    }
+    // Kill the widget itself.
+    widget.destroy();
+}
+
+var suite = new Y.Test.Suite("LAZR Password Meter Tests");
+
+function setUp() {
+    Y.log("top of setUp");
+    // add the in-page HTML
+    var markup = Y.Node.create([
+        '<div id="all">',
+        '<input type="password" id="password"/>',
+        '<div id="meter"></div>',
+        '</div>'].join(''));
+    Y.log(markup);
+    Y.one("body").appendChild(markup);
+    this.config = this.make_config();
+    this.passwordMeter = new Y.PasswordMeter(this.config);
+    this.passwordMeter.render();
+}
+
+function tearDown() {
+    if (this.passwordMeter) {
+        cleanup_widget(this.passwordMeter);
+    }
+    var all = Y.one("document").one("#all");
+    if (all) {
+        all.get("parentNode").removeChild(all);
+    }
+}
+
+var strengthFunc = function(password){
+	var strength = password.length * 10;
+	var text = "";
+	if (strength > 100)
+		strength = 100;
+	switch (strength) {
+		case 10:
+		case 20:
+			color = '#8b0000';
+			text = "Weak";
+			break;
+		case 30:
+		case 40:
+		case 50:
+			color = '#f99300';
+			text = "Average";
+			break;
+		case 60:
+		case 70:
+			color = '#f003ff';
+			text = "Better";
+			break;
+		case 80:
+		case 90:
+		case 100:
+			color = '#00FF00';
+			text = "Strong";
+			break;
+		default:
+			color = '#FF0000';
+			text = "Weak";
+	}
+	return {
+		"color": color,
+		"text": text
+	};
+}
+
+function simulatePasswordUpdate(key, value) {
+    element = document.getElementById("password");
+    element.value = value;
+    Y.Event.simulate(element, 'keyup', { keyCode: key });
+}
+
+suite.add(new Y.Test.Case({
+
+    name: 'password_meter_tests',
+
+    setUp: setUp,
+
+    tearDown: tearDown,
+
+    make_config: function() {
+        return {
+            meter:       '#meter',
+            input:       '#password',
+            func: strengthFunc
+        };
+    },
+
+    test_for_correct_color: function() {
+        simulatePasswordUpdate(65, 'A');
+        simulatePasswordUpdate(65, '');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(255, 0, 0)');
+        simulatePasswordUpdate(65, 'A');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(139, 0, 0)');
+        simulatePasswordUpdate(66, 'AB');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(139, 0, 0)');
+        simulatePasswordUpdate(67, 'ABC');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(249, 147, 0)');
+        simulatePasswordUpdate(68, 'ABCD');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(249, 147, 0)');
+        simulatePasswordUpdate(69, 'ABCDE');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(249, 147, 0)');
+        simulatePasswordUpdate(70, 'ABCDEF');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(240, 3, 255)');
+        simulatePasswordUpdate(71, 'ABCDEFG');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(240, 3, 255)');
+        simulatePasswordUpdate(72, 'ABCDEFGH');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(0, 255, 0)');
+        simulatePasswordUpdate(73, 'ABCDEFGHI');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'),'rgb(0, 255, 0)');
+        simulatePasswordUpdate(74, 'ABCDEFGHIJ');
+        Assert.areEqual(this.passwordMeter.get('contentBox').getStyle('color'), 'rgb(0, 255, 0)');
+    },
+
+    test_for_correct_text: function() {
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), '');
+        simulatePasswordUpdate(65, 'A');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Weak');
+        simulatePasswordUpdate(66, 'AB');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Weak');
+        simulatePasswordUpdate(67, 'ABC');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Average');
+        simulatePasswordUpdate(68, 'ABCD');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Average');
+        simulatePasswordUpdate(69, 'ABCDE');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Average');
+        simulatePasswordUpdate(70, 'ABCDEF');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Better');
+        simulatePasswordUpdate(71, 'ABCDEFG');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Better');
+        simulatePasswordUpdate(72, 'ABCDEFGH');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Strong');
+        simulatePasswordUpdate(73, 'ABCDEFGHI');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Strong');
+        simulatePasswordUpdate(74, 'ABCDEFGHIJ');
+        Assert.areEqual(this.passwordMeter.get('contentBox').get('innerHTML'), 'Strong');
+    }
+
+}));
+
+Y.lazr.testing.Runner.add(suite);
+Y.lazr.testing.Runner.run();
+
+});

=== added directory 'lib/lp/app/javascript/lazr/picker'
=== added directory 'lib/lp/app/javascript/lazr/picker/assets'
=== added file 'lib/lp/app/javascript/lazr/picker/assets/picker-core.css'
--- lib/lp/app/javascript/lazr/picker/assets/picker-core.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/picker/assets/picker-core.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,3 @@
+/* Copyright (c) 2008, Canonical Ltd. All rights reserved. */
+
+.yui3-picker-hidden { display: none; }

=== added directory 'lib/lp/app/javascript/lazr/picker/assets/skins'
=== added directory 'lib/lp/app/javascript/lazr/picker/assets/skins/sam'
=== added file 'lib/lp/app/javascript/lazr/picker/assets/skins/sam/picker-skin.css'
--- lib/lp/app/javascript/lazr/picker/assets/skins/sam/picker-skin.css	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/picker/assets/skins/sam/picker-skin.css	2011-06-30 12:01:55 +0000
@@ -0,0 +1,90 @@
+.yui3-picker-search-box, .yui3-picker-footer-slot {
+    position: relative;
+    margin:15px auto 5px; /* Centered */
+    width: 70%;
+    padding-right: 24px; /* Give room for the search button */
+}
+
+.yui3-picker-footer-slot {
+    padding-bottom: 1em;
+}
+
+input.yui3-picker-search {
+    width: 100%;
+}
+
+.yui3-picker-search-box button.lazr-search {
+    /* The search button is floated right to avoid problems with
+     * the input.yui3-picker-search width in Safari 3.
+     */
+    float: right;
+    /* Ensure that the top of the image doesn't get cut off
+     * if the font size has a lower line height. Affects Safari 4beta.
+     */
+    height: 14px;
+}
+
+.yui3-picker-search-mode button.lazr-search {
+    background: url('../../../../lazr/assets/skins/sam/spinner.gif') 
+                0.2em 0 no-repeat;
+}
+
+.yui3-picker-search-box .lazr-search {
+    position: absolute;
+    right: -4px; /* Puts the button within the parent padding area */
+}
+
+.yui3-picker-error {
+    padding-top: 0.3em;
+    color: red;
+}
+
+ul.yui3-picker-results {
+    margin: 0 0 1em;
+}
+
+ul.yui3-picker-results li {
+    position: relative;
+    list-style-image: none;
+    list-style-position: outside;
+    list-style:none;
+    cursor: pointer;
+}
+
+.yui3-picker-results li img {
+    position: absolute;
+    top: 3px;
+    left: 3px;
+}
+
+.yui3-picker-results li {
+    /* Hard-code the space between items, keep the space on the left relative */
+    padding: 3px 0 3px 2em;
+}
+
+.yui3-picker-result-description {
+    color: #888888;
+}
+
+.yui3-picker-results li:hover {
+    background-color: #bbbbff;
+}
+
+.yui3-picker-no-results li:hover {
+    background-color: transparent;
+}
+
+.yui3-picker-batches {
+    margin-bottom: 5px;
+    text-align: center;
+}
+
+.yui3-picker-batches span {
+    margin-right: 2px;
+    cursor: pointer;
+}
+
+.yui3-picker-selected-batch {
+    font-weight: bold;
+}
+

=== added file 'lib/lp/app/javascript/lazr/picker/picker.js'
--- lib/lp/app/javascript/lazr/picker/picker.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/picker/picker.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,996 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI.add('lazr.picker', function(Y) {
+
+/**
+ * Module containing the Lazr searchable picker.
+ *
+ * @module lazr.picker
+ * @namespace lazr
+ */
+
+/**
+ * A picker is a pop-up widget containing a search field and displaying a list
+ * of found results.
+ *
+ * @class Picker
+ * @extends lazr.PrettyOverlay
+ * @constructor
+ */
+
+var PICKER  = 'picker',
+    BOUNDING_BOX = 'boundingBox',
+    CONTENT_BOX  = 'contentBox',
+
+    // Local aliases
+    getCN = Y.ClassNameManager.getClassName,
+
+    // CSS Classes
+    C_SEARCH = getCN(PICKER, 'search'),
+    C_SEARCH_BOX = getCN(PICKER, 'search-box'),
+    C_SEARCH_SLOT = getCN(PICKER, 'search-slot'),
+    C_FOOTER_SLOT = getCN(PICKER, 'footer-slot'),
+    C_SEARCH_MODE = getCN(PICKER, 'search-mode'),
+    C_RESULTS = getCN(PICKER, 'results'),
+    C_RESULT_TITLE = getCN(PICKER, 'result-title'),
+    C_RESULT_DESCRIPTION = getCN(PICKER, 'result-description'),
+    C_ERROR = getCN(PICKER, 'error'),
+    C_ERROR_MODE = getCN(PICKER, 'error-mode'),
+    C_NO_RESULTS = getCN(PICKER, 'no-results'),
+    C_BATCHES = getCN(PICKER, 'batches'),
+    C_SELECTED_BATCH = getCN(PICKER, 'selected-batch'),
+
+    // Events
+    SAVE = 'save',
+    SEARCH = 'search',
+
+    // Property constants
+    MIN_SEARCH_CHARS = 'min_search_chars',
+    CURRENT_SEARCH_STRING = 'current_search_string',
+    ERROR = 'error',
+    RESULTS = 'results',
+    BATCHES = 'batches',
+    BATCH_COUNT = 'batch_count',
+    SEARCH_SLOT = 'search_slot',
+    FOOTER_SLOT = 'footer_slot',
+    SELECTED_BATCH = 'selected_batch',
+    SEARCH_MODE = 'search_mode',
+    NO_RESULTS_SEARCH_MESSAGE = 'no_results_search_message',
+    RENDERUI = "renderUI",
+    BINDUI = "bindUI",
+    SYNCUI = "syncUI";
+
+
+var Picker = function () {
+    Picker.superclass.constructor.apply(this, arguments);
+
+    Y.after(this._renderUIPicker, this, RENDERUI);
+    Y.after(this._bindUIPicker, this, BINDUI);
+    Y.after(this._syncUIPicker, this, BINDUI);
+};
+
+Y.extend(Picker, Y.lazr.PrettyOverlay, {
+    /**
+     * The search input box node.
+     *
+     * @property _search_input
+     * @type Node
+     * @private
+     */
+    _search_input: null,
+
+    /**
+     * The search button node.
+     *
+     * @property _search_button
+     * @type Node
+     * @private
+     */
+    _search_button: null,
+
+    /**
+     * The node containing search results.
+     *
+     * @property _results_box
+     * @type Node
+     * @private
+     */
+    _results_box: null,
+
+    /**
+     * The node containing the extra form inputs.
+     *
+     * @property _search_slot_box
+     * @type Node
+     * @private
+     */
+    _search_slot_box: null,
+
+    /**
+     * The node containing the batches.
+     *
+     * @property _batches_box
+     * @type Node
+     * @private
+     */
+    _batches_box: null,
+
+    /**
+     * The node containing the previous batch button.
+     *
+     * @property _prev_button
+     * @type Node
+     * @private
+     */
+    _prev_button: null,
+
+    /**
+     * The node containing the next batch button.
+     *
+     * @property _next_button
+     * @type Node
+     * @private
+     */
+    _next_button: null,
+
+    /**
+     * The node containing an error message if any.
+     *
+     * @property _error_box
+     * @type Node
+     * @private
+     */
+    _error_box: null,
+
+    initializer: function(cfg) {
+        /**
+         * Fires when the user presses the 'Search' button.
+         * The event details contain the search string entered by the user.
+         *
+         * This event is only fired if the search string is longer than the
+         * min_search_chars attribute.
+         *
+         * This event is also fired when the user clicks on one of the batch
+         * items, the details then contain both the previous search string and
+         * the value of the batch item selected.
+         *
+         * @event search
+         * @preventable _defaultSearch
+         */
+        this.publish(SEARCH, { defaultFn: this._defaultSearch });
+
+        /**
+         * Fires when the user selects one of the result. The event details
+         * contain the value of the selected result.
+         *
+         * @event save
+         * @preventable _defaultSave
+         */
+        this.publish(SAVE, { defaultFn: this._defaultSave } );
+
+        // Subscribe to the cancel event so that we can clear the widget when
+        // requested.
+        this.subscribe('cancel', this._defaultCancel);
+
+        if ( this.get('picker_activator') ) {
+            var element = Y.one(this.get('picker_activator'));
+            element.on('click', function(e) {
+                e.halt();
+                this.show();
+            }, this);
+            element.addClass(this.get('picker_activator_css_class'));
+        }
+
+    },
+
+    /**
+     * Update the container for extra form inputs.
+     *
+     * @method _syncSearchSlotUI
+     * @protected
+     */
+    _syncSearchSlotUI: function() {
+        var search_slot = this.get(SEARCH_SLOT);
+
+        // Clear previous slot contents.
+        this._search_slot_box.set('innerHTML', '');
+
+        if (search_slot !== null) {
+            this._search_slot_box.appendChild(search_slot);
+        }
+    },
+
+    /**
+     * Update the container for extra form inputs.
+     *
+     * @method _syncSearchSlotUI
+     * @protected
+     */
+    _syncFooterSlotUI: function() {
+        var footer_slot = this.get(FOOTER_SLOT);
+
+        // Clear previous slot contents.
+        this._footer_slot_box.set('innerHTML', '');
+
+        if (footer_slot !== null) {
+            this._footer_slot_box.appendChild(footer_slot);
+        }
+    },
+
+    /**
+     * Return the batch page information.
+     *
+     * @method _getBatches
+     * @protected
+     */
+    _getBatches: function() {
+        var batches = this.get(BATCHES);
+
+        if (batches === null) {
+            var batch_count = this.get(BATCH_COUNT);
+            if (batch_count === null) {
+                batches = [];
+            }
+            else {
+                batches = [];
+                // Only create batch pages when there's more than one.
+                if (batch_count > 1) {
+                    for (var i = 0; i < batch_count; i++) {
+                        batches.push({ value: i, name: i + 1 });
+                    }
+                }
+            }
+        }
+        return batches;
+    },
+
+    /**
+     * Update the batches container in the UI.
+     *
+     * @method _syncBatchesUI
+     * @protected
+     */
+    _syncBatchesUI: function() {
+        var batches = this._getBatches();
+
+        // Clear previous batches.
+        Y.Event.purgeElement(this._batches_box, true);
+        this._batches_box.set('innerHTML', '');
+
+        if (batches.length === 0) {
+            this._prev_button = null;
+            this._next_button = null;
+            return;
+        }
+
+        // The enabled property of the prev/next buttons is controlled
+        // in _syncSelectedBatchUI.
+        this._prev_button = Y.Node.create(Y.lazr.ui.PREVIOUS_BUTTON);
+        this._prev_button.on('click', function (e) {
+            var selected = this.get(SELECTED_BATCH) - 1;
+            this.set(SELECTED_BATCH, selected);
+            this.fire(
+                SEARCH, this.get(CURRENT_SEARCH_STRING),
+                batches[selected].value);
+        }, this);
+        this._batches_box.appendChild(this._prev_button);
+
+        Y.Array.each(batches, function(data, i) {
+            var batch_item = Y.Node.create('<span></span>');
+            batch_item.appendChild(
+                document.createTextNode(data.name));
+            this._batches_box.appendChild(batch_item);
+
+            batch_item.on('click', function (e) {
+                this.set(SELECTED_BATCH, i);
+                this.fire(
+                    SEARCH, this.get(CURRENT_SEARCH_STRING), data.value);
+            }, this);
+        }, this);
+
+        this._next_button = Y.Node.create(Y.lazr.ui.NEXT_BUTTON);
+        this._batches_box.appendChild(this._next_button);
+        this._next_button.on('click', function (e) {
+            var selected = this.get(SELECTED_BATCH) + 1;
+            this.set(SELECTED_BATCH, selected);
+            this.fire(
+                SEARCH, this.get(CURRENT_SEARCH_STRING),
+                batches[selected].value);
+        }, this);
+    },
+
+    /**
+     * Synchronize the selected batch with the UI.
+     *
+     * @method _syncSelectedBatchUI
+     * @protected
+     */
+    _syncSelectedBatchUI: function() {
+        var idx = this.get(SELECTED_BATCH);
+        var items = this._batches_box.all('span');
+        if (items.size()) {
+            this._prev_button.set('disabled', idx === 0);
+            items.removeClass(C_SELECTED_BATCH);
+            items.item(idx).addClass(C_SELECTED_BATCH);
+            this._next_button.set('disabled', idx+1 === items.size());
+        }
+    },
+
+    /**
+     * Return a node containing the specified text. If a href is provided,
+     * then the text will be linkified with with the given css class. The
+     * link will open in a new window (but the browser can be configured to
+     * open a new tab instead if the user so wishes).
+     * @param text the text to render
+     * @param href the URL of the new window
+     * @param css the style to use when rendering the link
+     */
+    _text_or_link: function(text, href, css) {
+        var result;
+        if (href) {
+            result=Y.Node.create('<a></a>').addClass(css);
+            result.set('text', text).set('href', href);
+            Y.on('click', function(e) {
+                e.halt();
+                window.open(href);
+            }, result);
+        } else {
+            result = document.createTextNode(text);
+        }
+        return result;
+    },
+
+    /**
+     * Render a node containing the title part of the picker entry.
+     * The title will consist of some main text with some optional alternate
+     * text which will be rendered in parentheses after the main text. The
+     * title/alt_title text may separately be turned into a link with user
+     * specified URLs.
+     * @param data a json data object with the details to render
+     */
+    _renderTitleUI: function(data) {
+        var li_title = Y.Node.create(
+            '<span></span>').addClass(C_RESULT_TITLE);
+        var title = this._text_or_link(
+            data.title, data.title_link, data.link_css);
+        li_title.appendChild(title);
+        if (data.alt_title) {
+            var alt_title = this._text_or_link(
+                data.alt_title, data.alt_title_link, data.link_css);
+            li_title.appendChild('&nbsp;(');
+            li_title.appendChild(alt_title);
+            li_title.appendChild(')');
+        }
+        return li_title;
+    },
+
+    /**
+     * Render a node containing the badges part of the picker entry.
+     * The badges are small images which are displayed next to the title. The
+     * display of badges is optional.
+     * @param data a json data object with the details to render
+     */
+    _renderBadgesUI: function(data) {
+        if (data.badges) {
+            var badges = Y.Node.create('<div></div>').addClass('badge');
+            Y.each(data.badges, function(badge_info) {
+                var badge_url = badge_info.url;
+                var badge_alt = badge_info.alt;
+                var badge = Y.Node.create('<img></img>')
+                    .addClass('badge')
+                    .set('src', badge_url)
+                    .set('alt', badge_alt);
+                badges.appendChild(badge);
+            });
+            return badges;
+        }
+        return null;
+    },
+
+    /**
+     * Render a node containing the description part of the picker entry.
+     * @param data a json data object with the details to render
+     */
+    _renderDescriptionUI: function(data) {
+        var li_desc = Y.Node.create(
+            '<div><br /></div>').addClass(C_RESULT_DESCRIPTION);
+        if (data.description) {
+            li_desc.replaceChild(
+                document.createTextNode(data.description),
+                li_desc.one('br'));
+        }
+        return li_desc;
+    },
+
+    /**
+     * Update the UI based on the results attribute.
+     *
+     * @method _syncResultsUI
+     * @protected
+     */
+    _syncResultsUI: function() {
+        var results = this.get(RESULTS);
+
+        // Remove any previous results.
+        Y.Event.purgeElement(this._results_box, true);
+        this._results_box.set('innerHTML', '');
+
+        Y.Array.each(results, function(data, i) {
+            // Sort out the badges div.
+            var li_badges = this._renderBadgesUI(data);
+            // Sort out the title span.
+            var li_title = this._renderTitleUI(data);
+            // Sort out the description div.
+            var li_desc = this._renderDescriptionUI(data);
+            // Put the list item together.
+            var li = Y.Node.create('<li></li>').addClass(
+                i % 2 ? Y.lazr.ui.CSS_ODD : Y.lazr.ui.CSS_EVEN);
+            if (data.css) {
+                li.addClass(data.css);
+            }
+            if (data.image) {
+                li.appendChild(
+                    Y.Node.create('<img />').set('src', data.image));
+            }
+            if (li_badges !== null)
+                li.appendChild(li_badges);
+            li.appendChild(li_title);
+            li.appendChild(li_desc);
+            // Attach handlers.
+            li.on('click', function (e, value) {
+                this.fire(SAVE, value);
+            }, this, data);
+
+            this._results_box.appendChild(li);
+        }, this);
+
+        // If the user has entered a search and there ain't no results,
+        // display the message about no items matching.
+        if (this._search_input.get('value') && !results.length) {
+            var msg = Y.Node.create('<li></li>');
+            msg.appendChild(
+                document.createTextNode(
+                    Y.substitute(this.get(NO_RESULTS_SEARCH_MESSAGE),
+                    {query: this._search_input.get('value')})));
+            this._results_box.appendChild(msg);
+            this._results_box.addClass(C_NO_RESULTS);
+        } else {
+            this._results_box.removeClass(C_NO_RESULTS);
+        }
+
+        if (results.length) {
+            // Set PrettyOverlay's green progress bar to 100%.
+            this.set('progress', 100);
+        } else {
+            // Set PrettyOverlay's green progress bar to 50%.
+            this.set('progress', 50);
+        }
+    },
+
+    /**
+     * Sync UI with search mode. Disable the search input and button.
+     *
+     * @method _syncSearchModeUI
+     * @protected
+     */
+    _syncSearchModeUI: function() {
+        var search_mode = this.get(SEARCH_MODE);
+        this._search_input.set('disabled', search_mode);
+        this._search_button.set('disabled', search_mode);
+        if (search_mode) {
+            this.get(BOUNDING_BOX).addClass(C_SEARCH_MODE);
+        } else {
+            this.get(BOUNDING_BOX).removeClass(C_SEARCH_MODE);
+            // If the search input isn't blurred before it is focused,
+            // then the I-beam disappears.
+            this._search_input.blur();
+            this._search_input.focus();
+        }
+    },
+
+    /**
+     * Sync UI with the error message.
+     *
+     * @method _syncErrorUI
+     * @protected
+     */
+    _syncErrorUI: function() {
+        var error = this.get(ERROR);
+        this._error_box.set('innerHTML', '');
+        if (error === null) {
+            this.get(BOUNDING_BOX).removeClass(C_ERROR_MODE);
+        } else {
+            this._error_box.appendChild(document.createTextNode(error));
+            this.get(BOUNDING_BOX).addClass(C_ERROR_MODE);
+        }
+    },
+
+    /**
+     * Create the widget's HTML components.
+     * <p>
+     * This method is invoked after renderUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _renderUIPicker
+     * @protected
+     */
+    _renderUIPicker: function() {
+        this._search_button = Y.Node.create(Y.lazr.ui.SEARCH_BUTTON);
+
+        var search_box = Y.Node.create([
+            '<div>',
+            '<input type="text" size="20" name="search" ',
+            'autocomplete="off"/>',
+            '<div></div></div>'].join(""));
+
+        this._search_input = search_box.one('input');
+        this._search_input.addClass(C_SEARCH);
+
+        this._error_box = search_box.one('div');
+        this._error_box.addClass(C_ERROR);
+
+        // The search button is floated right to avoid problems with
+        // the input width in Safari 3.
+        search_box.insertBefore(this._search_button, this._search_input);
+        search_box.addClass(C_SEARCH_BOX);
+
+        this._search_slot_box = Y.Node.create('<div></div');
+        this._search_slot_box.addClass(C_SEARCH_SLOT);
+        search_box.appendChild(this._search_slot_box);
+
+        this._results_box = Y.Node.create('<ul></ul>');
+        this._results_box.addClass(C_RESULTS);
+
+        this._batches_box = Y.Node.create('<div></div');
+        this._batches_box.addClass(C_BATCHES);
+
+        this._footer_slot_box = Y.Node.create('<div></div');
+        this._footer_slot_box.addClass(C_FOOTER_SLOT);
+
+        var body = Y.Node.create('<div></div>');
+        body.appendChild(search_box);
+        body.appendChild(this._batches_box);
+        body.appendChild(this._results_box);
+        body.appendChild(this._footer_slot_box);
+        body.addClass('yui3-widget-bd');
+
+        this.setStdModContent(Y.WidgetStdMod.BODY, body, Y.WidgetStdMod.APPEND);
+    },
+
+    /**
+     * Bind the widget's DOM elements to their event handlers.
+     * <p>
+     * This method is invoked after bindUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _bindUIPicker
+     * @protected
+     */
+    _bindUIPicker: function() {
+        Y.on('click', this._defaultSearchUserAction, this._search_button,
+             this);
+
+        // Enter key
+        Y.on(
+            'key', this._defaultSearchUserAction, this._search_input,
+            'down:13', this);
+
+        // Focus search box when the widget is first displayed.
+        this.after('visibleChange', function (e) {
+            var change = e.details[0];
+            if (change.newVal === true && change.prevVal === false) {
+                // The widget has to be centered before the search
+                // input is focused, so that it is centered in the current
+                // viewport and not the viewport after scrolling to the
+                // widget.
+                this.set('centered', true);
+                this._search_input.focus();
+            }
+        }, this);
+
+        // Update the display whenever the "results" property is changed and
+        // clear the search mode.
+        this.after('resultsChange', function (e) {
+            this._syncResultsUI();
+            this.set(SEARCH_MODE, false);
+        }, this);
+
+        // Update the search slot box whenever the "search_slot" property
+        // is changed.
+        this.after('search_slotChange', function (e) {
+            this._syncSearchSlotUI();
+        }, this);
+
+        // Update the footer slot box whenever the "footer_slot" property
+        // is changed.
+        this.after('footer_slotChange', function (e) {
+            this._syncFooterSlotUI();
+        }, this);
+
+        // Update the batch list whenever the "batches" or "results" property
+        // is changed.
+        var doBatchesChange = function (e) {
+            this._syncBatchesUI();
+            this._syncSelectedBatchUI();
+        };
+
+        this.after('batchesChange', doBatchesChange, this);
+        this.after('resultsChange', doBatchesChange, this);
+
+        // Keep the UI in sync with the currently selected batch.
+        this.after('selected_batchChange', function (e) {
+            this._syncSelectedBatchUI();
+        }, this);
+
+        // Update the display whenever the "results" property is changed.
+        this.after('search_modeChange', function (e) {
+            this._syncSearchModeUI();
+        }, this);
+
+        // Update the display whenever the "error" property is changed.
+        this.after('errorChange', function (e) {
+            this._syncErrorUI();
+        });
+    },
+
+    /**
+     * Synchronize the search box, error message and results with the UI.
+     * <p>
+     * This method is invoked after syncUI is invoked for the Widget class
+     * using YUI's aop infrastructure.
+     * </p>
+     *
+     * @method _syncUIPicker
+     * @protected
+     */
+    _syncUIPicker: function() {
+        this._syncResultsUI();
+        this._syncSearchModeUI();
+        this._syncBatchesUI();
+        this._syncSelectedBatchUI();
+        this._syncErrorUI();
+        this._search_input.focus();
+    },
+
+    /*
+     * Clear all elements of the picker, resetting it to its original state.
+     *
+     * @method _clear
+     * @param e {Object} The event object.
+     * @protected
+     */
+    _clear: function() {
+        this.set(CURRENT_SEARCH_STRING, '');
+        this.set(ERROR, '');
+        this.set(RESULTS, [{}]);
+        this.set(BATCHES, null);
+        this.set(BATCH_COUNT, null);
+        this._search_input.set('value', '');
+        this._results_box.set('innerHTML', '');
+    },
+
+    /**
+     * Handle clicks on the 'Search' button or entering the enter key in the
+     * search field.  This fires the search event.
+     *
+     * @method _defaultSearchUserAction
+     * @param e {Event.Facade} An Event Facade object.
+     * @private
+     */
+    _defaultSearchUserAction: function(e) {
+        e.preventDefault();
+        var search_string = Y.Lang.trim(this._search_input.get('value'));
+        if (search_string.length < this.get(MIN_SEARCH_CHARS)) {
+            var msg =  Y.substitute(
+                "Please enter at least {min} characters.",
+                {min: this.get(MIN_SEARCH_CHARS)});
+            this.set(ERROR, msg);
+        } else {
+            this.set(CURRENT_SEARCH_STRING, search_string);
+            this.fire(SEARCH, search_string);
+        }
+    },
+
+    /**
+     * By default, the search event puts the widget in search mode. It also
+     * clears the error, if there is any.
+     *
+     * @method _defaultSearch
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _defaultSearch: function(e) {
+        this.set(ERROR, null);
+        this.set(SEARCH_MODE, true);
+    },
+
+    /**
+     * By default, the cancel event just hides the widget, but you can
+     * have it also cleared by setting clear_on_cancel to 'true'.
+     *
+     * @method _defaultCancel
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _defaultCancel : function(e) {
+        Picker.superclass._defaultCancel.apply(this, arguments);
+        if ( this.get('clear_on_cancel') ) {
+            this._clear();
+        }
+    },
+
+    /**
+     * By default, the save event clears and hides the widget, but you can
+     * have it not cleared by setting clear_on_save to 'false'. The search
+     * entered by the user is passed in the first details attribute of the
+     * event.
+     *
+     * @method _defaultSave
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _defaultSave : function(e) {
+        this.hide();
+        if ( this.get('clear_on_save') ) {
+            this._clear();
+        }
+    },
+
+    /**
+     * By default, the select-batch event turns on search-mode.
+     *
+     * @method _defaultSelectBatch
+     * @param e {Event.Facade} An Event Facade object.
+     * @protected
+     */
+    _defaultSelectBatch: function(e) {
+        this.set(SEARCH_MODE, true);
+    }
+    });
+
+Picker.NAME = PICKER;
+
+/**
+ * The details index of the save result.
+ *
+ * @static
+ * @property SAVE_RESULT
+ */
+Picker.SAVE_RESULT = 0;
+
+/**
+ * The details index of the search string.
+ *
+ * @static
+ * @property SEARCH_STRING
+ */
+Picker.SEARCH_STRING = 0;
+
+/**
+ * The details index of the selected batch value.
+ *
+ * @static
+ * @property SELECTED_BATCH_VALUE
+ */
+Picker.SELECTED_BATCH_VALUE = 1;
+
+
+Picker.ATTRS = {
+    /**
+     * Whether or not the search box and result list should be cleared when
+     * the save event is fired.
+     *
+     * @attribute clear_on_save
+     * @type Boolean
+     */
+    clear_on_save: { value: true },
+
+    /**
+     * Whether or not the search box and result list should be cleared when
+     * the cancel event is fired.
+     *
+     * @attribute clear_on_cancel
+     * @type Boolean
+     */
+    clear_on_cancel: { value: false },
+
+    /**
+     * A CSS selector for the DOM element that will activate (show) the picker
+     * once clicked.
+     *
+     * @attribute picker_activator
+     * @type String
+     */
+    picker_activator: { value: null },
+
+    /**
+     * An extra CSS class to be added to the picker_activator, generally used
+     * to distinguish regular links from js-triggering ones.
+     *
+     * @attribute picker_activator_css_class
+     * @type String
+     */
+    picker_activator_css_class: { value: 'js-action' },
+
+    /**
+     * Minimum number of characters that need to be entered in the search
+     * string input before a search event will be fired. The search string
+     * will be trimmed before testing the length.
+     *
+     * @attribute min_search_chars
+     * @type Integer
+     */
+    min_search_chars: { value: 3 },
+
+    /**
+     * The current search string, which is needed when clicking on a different
+     * batch if the search input has been modified.
+     *
+     * @attribute current_search_string
+     * @type String
+     */
+    current_search_string: {value: ''},
+
+    /**
+     * Results currently displayed by the widget. Updating this value
+     * automatically updates the display.
+     *
+     * @attribute results
+     * @type Array
+     */
+    results: { value: [] },
+
+    /**
+     * This adds any form fields you want below the search field.
+     * Updating this value automatically updates the display, but only
+     * if the widget has already been rendered. Otherwise, the change
+     * event never fires.
+     *
+     * @attribute search_slot
+     * @type Node
+     */
+    search_slot: {value: null},
+
+    /**
+     * A place for custom html at the bottom of the widget. When there
+     * are no search results the search_slot and the footer_slot are
+     * right next to each other.
+     * Updating this value automatically updates the display, but only
+     * if the widget has already been rendered. Otherwise, the change
+     * event never fires.
+     *
+     * @attribute footer_slot
+     * @type Node
+     */
+    footer_slot: {value: null},
+
+    /**
+     * Batches currently displayed in the widget, which can be
+     * clicked to change the batch of results being displayed. Updating
+     * this value automatically updates the display.
+     *
+     * This an array of object containing the two keys, name (used as
+     * the batch label) and value (used as additional details to SEARCH
+     * event).
+     *
+     * @attribute batches
+     * @type Array
+     */
+    batches: {value: null},
+
+    /**
+     * For simplified batch creation, you can set this to the number of
+     * batches in the search results.  In this case, the batch labels
+     * and values are automatically calculated.  The batch name (used as the
+     * batch label) will be the batch number starting from 1.  The batch value
+     * (used as additional details to the SEARCH event) will be the batch
+     * number, starting from zero.
+     *
+     * If 'batches' is set (see above), batch_count is ignored.
+     *
+     * @attribute batch_count
+     * @type Integer
+     */
+    batch_count: {value: null},
+
+    /**
+     * Batch currently selected.
+     *
+     * @attribute selected_batch
+     * @type Integer
+     */
+    selected_batch: {
+        value: 0,
+        getter: function (value) {
+            return value || 0;
+        },
+        validator: function (value) {
+            var batches = this._getBatches();
+            return Y.Lang.isNumber(value) &&
+                   value >= 0 &&
+                   value < batches.length;
+        }},
+
+    /**
+     * Flag indicating if the widget is currently in search mode (so users
+     * has triggered a search and we are waiting for results.)
+     *
+     * @attribute search_mode
+     * @type Boolean
+     */
+    search_mode: { value: false },
+
+    /**
+     * The current error message. This puts the widget in 'error-mode',
+     * setting this value to null clears that state.
+     *
+     * @attribute error
+     * @type String
+     */
+    error: { value: null },
+
+    /**
+     * The message to display when the search returned no results. This string
+     * can contain a 'query' placeholder
+     *
+     * @attribute no_results_search_message
+     * @type String
+     * @default No items matched "{query}".
+     */
+    no_results_search_message: {
+        value: 'No items matched "{query}".'
+    }
+};
+
+
+/**
+ * This plugin is used to associate a picker instance to an input element of
+ * the DOM.  When the picker is shown, it takes its initial value from that
+ * element and when the save event is fired, the value of the chosen item
+ * (from the picker's list of results) is copied to that element.
+ *
+ * Also, this plugin expects a single attribute (input_element) in the
+ * config passed to its constructor, which defines the element that will be
+ * associated with the picker.
+ *
+ * @class TextFieldPickerPlugin
+ * @extends Y.Plugin.Base
+ * @constructor
+ */
+
+function TextFieldPickerPlugin(config) {
+    TextFieldPickerPlugin.superclass.constructor.apply(this, arguments);
+}
+
+TextFieldPickerPlugin.NAME = 'TextFieldPickerPlugin';
+TextFieldPickerPlugin.NS = 'txtpicker';
+
+Y.extend(TextFieldPickerPlugin, Y.Plugin.Base, {
+    initializer: function(config) {
+        var input = Y.one(config.input_element);
+        this.doAfter('save', function (e) {
+            var result = e.details[Y.lazr.Picker.SAVE_RESULT];
+            input.set("value",  result.value);
+            // If the search input isn't blurred before it is focused,
+            // then the I-beam disappears.
+            input.blur();
+            input.focus();
+        });
+        this.doAfter('show', function() {
+            if ( input.get("value") ) {
+                this.get('host')._search_input.set('value', input.get("value"));
+            }
+        });
+    }
+});
+
+Y.lazr.Picker = Picker;
+Y.lazr.TextFieldPickerPlugin = TextFieldPickerPlugin;
+
+}, "0.1", {"skinnable": true,
+           "requires": ["oop", "event", "event-focus", "node", "plugin",
+                        "substitute", "widget", "widget-stdmod",
+                        "lazr.overlay", "lazr.anim", "lazr.base"]
+});

=== added directory 'lib/lp/app/javascript/lazr/picker/tests'
=== added file 'lib/lp/app/javascript/lazr/picker/tests/picker.html'
--- lib/lp/app/javascript/lazr/picker/tests/picker.html	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/picker/tests/picker.html	2011-06-30 12:01:55 +0000
@@ -0,0 +1,28 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd";>
+<html>
+  <head>
+  <title>Picker</title>
+
+  <!-- YUI 3.0 Setup -->
+  <script type="text/javascript" src="../../testing/config.js"></script>
+  <script type="text/javascript" src="../../yui/yui/yui.js"></script>
+  <link rel="stylesheet" href="../../yui/cssreset/reset.css"/>
+  <link rel="stylesheet" href="../../yui/cssfonts/fonts.css"/>
+  <link rel="stylesheet" href="../../yui/cssbase/base.css"/>
+  <link rel="stylesheet" href="../../testing/assets/testlogger.css"/>
+
+  <!-- The module under test -->
+  <script type="text/javascript" src="../../overlay/overlay.js"></script>
+  <script type="text/javascript" src="../../picker/picker.js"></script>
+  <script type="text/javascript" src="../../anim/anim.js"></script>
+  <script type="text/javascript" src="../../lazr/lazr.js"></script>
+  <script type="text/javascript" src="../../testing/testing.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript" src="picker.js"></script>
+</head>
+<body class="yui3-skin-sam">
+  <div id="log"></div>
+</body>
+</html>

=== added file 'lib/lp/app/javascript/lazr/picker/tests/picker.js'
--- lib/lp/app/javascript/lazr/picker/tests/picker.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/lazr/picker/tests/picker.js	2011-06-30 12:01:55 +0000
@@ -0,0 +1,960 @@
+/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
+
+YUI().use('lazr.picker', 'lazr.testing.runner', 'node',
+          'event', 'event-focus', 'event-simulate', 'console', 'dump',
+          function(Y) {
+
+// Local aliases
+var Assert = Y.Assert,
+    ArrayAssert = Y.ArrayAssert;
+
+/*
+ * A wrapper for the Y.Event.simulate() function.  The wrapper accepts
+ * CSS selectors and Node instances instead of raw nodes.
+ */
+function simulate(widget, selector, evtype, options) {
+    var rawnode = Y.Node.getDOMNode(widget.one(selector));
+    Y.Event.simulate(rawnode, evtype, options);
+}
+
+/* Helper function to clean up a dynamically added widget instance. */
+function cleanup_widget(widget) {
+    // Nuke the boundingBox, but only if we've touched the DOM.
+    if (widget.get('rendered')) {
+        var bb = widget.get('boundingBox');
+        bb.get('parentNode').removeChild(bb);
+    }
+    // Kill the widget itself.
+    widget.destroy();
+}
+
+var suite = new Y.Test.Suite("LAZR Picker Tests");
+
+suite.add(new Y.Test.Case({
+
+    name: 'picker_basics',
+
+    setUp: function() {
+        this.picker = new Y.lazr.Picker();
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.picker);
+    },
+
+    test_picker_can_be_instantiated: function() {
+        Assert.isInstanceOf(
+            Y.lazr.Picker, this.picker, "Picker failed to be instantiated");
+    },
+
+    test_picker_is_stackable: function() {
+        // We should probably define an Assert.hasExtension.
+        Assert.areSame(
+            Y.WidgetStack.prototype.sizeShim, this.picker.sizeShim,
+            "Picker should be stackable.");
+        Assert.areSame(
+            Y.WidgetPositionAlign.prototype.align, this.picker.align,
+            "Picker should be positionable.");
+    },
+
+    test_picker_has_elements: function () {
+        /**
+         * Test that renderUI() adds search box, an error container and a
+         * results container to the widget.
+         * */
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        Assert.isNotNull(
+            bb.one('.yui3-picker-search'),
+            "Missing search box.");
+        Assert.isNotNull(
+            bb.one('.lazr-search.lazr-btn'),
+            "Missing search button.");
+        Assert.isNotNull(
+            bb.one('.yui3-picker-results'),
+            "Missing search results.");
+        Assert.isNotNull(
+            bb.one('.yui3-picker-error'), "Missing error box.");
+    },
+
+    test_set_results_updates_display: function () {
+        this.picker.render();
+        var image_url = '../../lazr/assets/skins/sam/search.png';
+        this.picker.set('results', [
+            {
+                image: image_url,
+                css: 'yui3-blah-blue',
+                value: 'jschmo',
+                title: 'Joe Schmo',
+                description: 'joe@xxxxxxxxxxx'
+            }
+        ]);
+        var bb = this.picker.get('boundingBox');
+        var li = bb.one('.yui3-picker-results li');
+        Assert.isNotNull(li, "Results not found");
+        Assert.isTrue(li.hasClass('yui3-blah-blue'), "Missing class name.");
+        Assert.isNotNull(li.one('img'), "Missing image.");
+        Assert.areEqual(
+            image_url, li.one('img').getAttribute('src'),
+            "Unexpected image url");
+        var title_el = li.one('.yui3-picker-result-title');
+        Assert.isNotNull(title_el, "Missing title element");
+        Assert.areEqual(
+            'Joe Schmo', title_el.get('text'), 'Unexpected title value.');
+        var description_el = li.one('.yui3-picker-result-description');
+        Assert.isNotNull(description_el, "Missing description element.");
+        Assert.areEqual(
+            'joe@xxxxxxxxxxx', description_el.get('text'),
+            'Unexpected description value.');
+    },
+
+    test_alternate_title_text: function () {
+        this.picker.render();
+        this.picker.set('results', [
+            {
+                css: 'yui3-blah-blue',
+                value: 'jschmo',
+                title: 'Joe Schmo',
+                description: 'joe@xxxxxxxxxxx',
+                alt_title: 'Another Joe'
+            }
+        ]);
+        var bb = this.picker.get('boundingBox');
+        var li = bb.one('.yui3-picker-results li');
+        var title_el = li.one('.yui3-picker-result-title');
+        Assert.isNotNull(title_el, "Missing title element");
+        Assert.areEqual(
+            'Joe Schmo\u00a0(Another Joe)', title_el.get('text'),
+            'Unexpected title value.');
+    },
+
+    test_title_links: function () {
+        this.picker.render();
+        this.picker.set('results', [
+            {
+                css: 'yui3-blah-blue',
+                value: 'jschmo',
+                title: 'Joe Schmo',
+                description: 'joe@xxxxxxxxxxx',
+                alt_title: 'Joe Again <foo></foo>',
+                title_link: 'http://somewhere.com',
+                alt_title_link: 'http://somewhereelse.com',
+                link_css: 'cool-style'
+            }
+        ]);
+
+        function check_link(picker, link_selector, title, href) {
+            var bb = picker.get('boundingBox');
+            var link_clicked = false;
+            var link_node = bb.one(link_selector);
+
+            Assert.areEqual(title, link_node.get('text'));
+            Assert.areEqual(href, link_node.get('href'));
+
+            Y.on('click', function(e) {
+                link_clicked = true;
+            }, link_node);
+            simulate(bb, link_selector, 'click');
+            Assert.isTrue(link_clicked,
+                link_selector + ' link was not clicked');
+        }
+        check_link(
+            this.picker, '.cool-style:nth-child(1)', 'Joe Schmo',
+            'http://somewhere.com/');
+        check_link(
+            this.picker, '.cool-style:nth-child(2)', 'Joe Again <foo></foo>',
+            'http://somewhereelse.com/');
+    },
+
+    test_title_badges: function () {
+        this.picker.render();
+        var badge_info = [
+            {url: '../../lazr/assets/skins/sam/search.png', alt: 'alt 1'},
+            {url: '../../lazr/assets/skins/sam/spinner.png', alt: 'alt 2'}];
+        this.picker.set('results', [
+            {
+                badges: badge_info,
+                css: 'yui3-blah-blue',
+                value: 'jschmo',
+                title: 'Joe Schmo',
+                description: 'joe@xxxxxxxxxxx'
+            }
+        ]);
+        var bb = this.picker.get('boundingBox');
+        var li = bb.one('.yui3-picker-results li');
+        for (var i=0; i<badge_info.length; i++) {
+            var img_node = li.one(
+                'div.badge img.badge:nth-child(' + (i + 1) + ')');
+            Assert.areEqual(
+                badge_info[i].url, img_node.getAttribute('src'),
+                'Unexpected badge url');
+            Assert.areEqual(
+                badge_info[i].alt, img_node.get('alt'),
+                'Unexpected badge alt text');
+        }
+    },
+
+    test_results_display_escaped: function () {
+        this.picker.render();
+        this.picker.set('results', [
+            {
+                image: '<script>throw "back";</script>',
+                css: 'yui3-blah-blue',
+                value: '<script>throw "wobbly";</script>',
+                title: '<script>throw "toys out of pram";</script>',
+                description: '<script>throw "up";</script>'
+            }
+        ]);
+        var bb = this.picker.get('boundingBox');
+        var li = bb.one('.yui3-picker-results li');
+        var image_el = li.one('img');
+        Assert.areEqual(
+            '<script>throw "back";</script>', image_el.getAttribute('src'),
+            "Unexpected image url");
+        var title_el = li.one('.yui3-picker-result-title');
+        Assert.areEqual(
+            '&lt;script&gt;throw "toys out of pram";&lt;/script&gt;',
+            title_el.get('innerHTML'), 'Unexpected title value.');
+        var description_el = li.one('.yui3-picker-result-description');
+        Assert.areEqual(
+            '&lt;script&gt;throw "up";&lt;/script&gt;',
+            description_el.get('innerHTML'), 'Unexpected description value.');
+    },
+
+    test_results_updates_display_with_missing_data: function () {
+        this.picker.render();
+        var image_url = '../../lazr/assets/skins/sam/search.png';
+        this.picker.set('results', [
+            { value: 'jschmo', title: 'Joe Schmo' }
+        ]);
+        var bb = this.picker.get('boundingBox');
+        var li = bb.one('.yui3-picker-results li');
+        Assert.isNotNull(li, "Results not found.");
+        Assert.areEqual(Y.lazr.ui.CSS_EVEN, li.getAttribute('class'));
+        Assert.isNull(li.one('img'), "Unexpected image.");
+        var description_el = li.one('.yui3-picker-result-description.');
+        Assert.isNull(description_el, "Unexpected description element.");
+    },
+
+    test_render_displays_initial_results: function () {
+        this.picker.set('results', [
+                {'title': 'Title 1'},
+                {'title': 'Title 2'}
+            ]);
+        this.picker.render();
+        var bb = this.picker.get('boundingBox');
+        var results = bb.all('.yui3-picker-results li');
+        Assert.isNotNull(results, "Results not found.");
+        Assert.areEqual(2, results.size());
+    },
+
+    test_resetting_results_removes_previous_results: function () {
+        this.picker.render();
+        var bb = this.picker.get('boundingBox');
+
+        // First time setting the results.
+        this.picker.set('results', [
+                {'title': 'Title 1'},
+                {'title': 'Title 2'}
+            ]);
+        var results = bb.all('.yui3-picker-results li');
+        Assert.isNotNull(results, "Results not found.");
+        Assert.areEqual(2, results.size());
+
+        // Second time setting the results.
+        this.picker.set('results', [
+                {'title': 'Title 1'}
+            ]);
+        results = bb.all('.yui3-picker-results li');
+        Assert.isNotNull(results, "Results not found");
+        Assert.areEqual(1, results.size());
+    },
+
+    test_updateResultsDisplay_adds_even_odd_class: function () {
+        this.picker.set('results', [
+                {'title': 'Title 1'},
+                {'title': 'Title 2'},
+                {'title': 'Title 1'},
+                {'title': 'Title 2'}
+            ]);
+        this.picker.render();
+        var bb = this.picker.get('boundingBox');
+        var results = bb.all('.yui3-picker-results li');
+        Assert.isNotNull(results, "Results not found.");
+        ArrayAssert.itemsAreEqual(
+            [true, false, true, false], results.hasClass(Y.lazr.ui.CSS_EVEN));
+        ArrayAssert.itemsAreEqual(
+            [false, true, false, true], results.hasClass(Y.lazr.ui.CSS_ODD));
+    },
+
+    test_clicking_search_button_fires_search_event: function () {
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        var input = bb.one('.yui3-picker-search');
+        input.set('value', 'a search');
+        var event_has_fired = false;
+        this.picker.subscribe('search', function(e) {
+                event_has_fired = true;
+                Assert.areEqual(
+                    'a search', e.details[0],
+                    'Search event is missing the search string.');
+        }, this);
+        simulate(
+            this.picker.get('boundingBox'), '.lazr-search.lazr-btn', 'click');
+        Assert.isTrue(event_has_fired, "search event wasn't fired");
+    },
+
+    test_set_search_mode_disables_search_button: function () {
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        this.picker.set('search_mode', true);
+        Assert.isTrue(
+            bb.one('.lazr-search.lazr-btn').get('disabled'),
+            "Search button wasn't disabled.");
+        this.picker.set('search_mode', false);
+        Assert.isFalse(
+            bb.one('.lazr-search.lazr-btn').get('disabled'),
+            "Search button wasn't re-enabled.");
+    },
+
+    test_hitting_enter_in_search_input_fires_search_event: function () {
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        var input = bb.one('.yui3-picker-search');
+        input.set('value', 'a search');
+        var event_has_fired = false;
+        this.picker.subscribe('search', function() {
+            event_has_fired = true;
+        }, this);
+        simulate(
+            this.picker.get('boundingBox'), '.yui3-picker-search', 'keydown',
+            {keyCode: 13});
+        Assert.isTrue(event_has_fired, "search event wasn't fired");
+    },
+
+    test_search_event_sets_the_in_search_mode: function () {
+        this.picker.render();
+
+        Assert.isFalse(
+            this.picker.get('search_mode'),
+            "Widget shouldn't be in search mode.");
+        this.picker.fire('search');
+        Assert.isTrue(
+            this.picker.get('search_mode'),
+            "Widget should be in search mode.");
+    },
+
+    test_setting_search_mode: function () {
+        // Setting search_mode adds a CSS class and disables the search box.
+
+        this.picker.render();
+
+        this.picker.set('search_mode', true);
+        var bb = this.picker.get('boundingBox');
+        Assert.isTrue(
+            bb.one('.yui3-picker-search').get('disabled'),
+            "Search box should be disabled.");
+        Assert.isTrue(
+            bb.hasClass('yui3-picker-search-mode'),
+            'Missing CSS class on widget.');
+    },
+
+    test_unsetting_search_mode: function () {
+
+        this.picker.render();
+
+        this.picker.set('search_mode', true);
+        this.picker.set('search_mode', false);
+        var bb = this.picker.get('boundingBox');
+        Assert.isFalse(
+            bb.one('.yui3-picker-search').get('disabled'),
+            "Search input should be enabled.");
+        Assert.isFalse(
+            bb.hasClass('yui3-picker-search-mode'),
+            'CSS class should be removed from the widget.');
+    },
+
+    test_set_results_remove_search_mode: function () {
+        this.picker.render();
+
+        this.picker.set('search_mode', true);
+        this.picker.set('results', []);
+
+        Assert.isFalse(
+            this.picker.get('search_mode'),
+            "Widget should be out of search_mode.");
+    },
+
+    test_set_error: function () {
+        // Setting the error property displays the string in the
+        // error box and puts an in-error CSS class on the widget.
+        this.picker.render();
+
+        var error_msg = 'Sorry an <error> occured.';
+        this.picker.set('error', error_msg);
+
+        var bb = this.picker.get('boundingBox');
+        Assert.areEqual(
+            error_msg, bb.one('.yui3-picker-error').get('text'),
+            "Error message wasn't displayed.");
+        Assert.isTrue(
+            bb.hasClass('yui3-picker-error-mode'),
+            "Missing error-mode class.");
+    },
+
+    test_set_error_null_clears_ui: function () {
+        this.picker.render();
+
+        this.picker.set('error', 'Sorry an error occured.');
+        this.picker.set('error', null);
+        var bb = this.picker.get('boundingBox');
+        Assert.areEqual('', bb.one('.yui3-picker-error').get('text'),
+            "Error message wasn't cleared.");
+        Assert.isFalse(
+            bb.hasClass('yui3-picker-error-mode'),
+            "error-mode class should be removed.");
+    },
+
+    test_small_search_sets_error: function () {
+        this.picker.render();
+        this.picker.set('min_search_chars', 4);
+        var bb = this.picker.get('boundingBox');
+        var input = bb.one('.yui3-picker-search');
+        input.set('value', ' 1 3 '); // 3 characters after trim.
+        simulate(
+            this.picker.get('boundingBox'), '.lazr-search.lazr-btn', 'click');
+        Assert.areEqual(
+            "Please enter at least 4 characters.",
+            this.picker.get('error'),
+            "Error message wasn't displayed.");
+    },
+
+    test_click_on_result_fire_save_event: function () {
+        this.picker.set('results', [
+            {'title': 'Object 1', value: 'first'},
+            {'title': 'Object 2', value: 'second'}
+        ]);
+
+        this.picker.render();
+
+        var event_has_fired = false;
+        this.picker.subscribe('save', function(e) {
+            event_has_fired = true;
+            Assert.areEqual(
+                'first', e.details[0].value,
+                "The event value of the clicked li is wrong.");
+            Assert.areEqual(
+                'Object 1', e.details[0].title,
+                "The event title of the clicked li is wrong.");
+        }, this);
+        simulate(
+            this.picker.get('boundingBox'), '.yui3-picker-results li', 'click');
+        Assert.isTrue(event_has_fired, "save event wasn't fired.");
+    },
+
+    test_cancel_event_hides_widget: function () {
+        this.picker.render();
+
+        this.picker.fire('cancel', 'bogus');
+        Assert.isFalse(
+            this.picker.get('visible'), "The widget should be hidden.");
+    },
+
+    test_save_event_hides_widget: function () {
+        this.picker.render();
+
+        this.picker.fire('save', 'bogus');
+        Assert.isFalse(
+            this.picker.get('visible'), "The widget should be hidden.");
+    },
+
+    test_save_event_clears_widget_by_default: function () {
+        this.picker.render();
+
+        this.picker._search_input.set('value', 'foo');
+        this.picker.fire('save', 'bogus');
+        Assert.areEqual(
+            '', this.picker._search_input.get('value'),
+            "The widget hasn't been cleared");
+    },
+
+    test_save_does_not_clear_widget_when_clear_on_save_is_false: function () {
+        picker = new Y.lazr.Picker({clear_on_save: false});
+        picker.render();
+
+        picker._search_input.set('value', 'foo');
+        picker.fire('save', 'bogus');
+        Assert.areEqual(
+            'foo', picker._search_input.get('value'),
+            "The widget has been cleared but it should not");
+    },
+
+    test_cancel_event_does_not_clear_widget_by_default: function () {
+        this.picker.render();
+
+        this.picker._search_input.set('value', 'foo');
+        this.picker.fire('cancel', 'bogus');
+        Assert.areEqual(
+            'foo', this.picker._search_input.get('value'),
+            "The widget has been cleared but it should not");
+    },
+
+    test_cancel_event_clears_widget_when_clear_on_cancel_is_true: function () {
+        picker = new Y.lazr.Picker({clear_on_cancel: true});
+        picker.render();
+
+        picker._search_input.set('value', 'foo');
+        picker.fire('cancel', 'bogus');
+        Assert.areEqual(
+            '', picker._search_input.get('value'),
+            "The widget hasn't been cleared");
+    },
+
+    test_search_clears_any_eror: function () {
+        this.picker.render();
+        this.picker.set('error', "An error");
+
+        this.picker.fire('search');
+
+        Assert.isNull(
+            this.picker.get('error'), 'Error should be cleared.');
+
+    },
+
+    test_no_search_result_msg: function () {
+        this.picker.render();
+
+        this.picker.set(
+            'no_results_search_message', "Your query '{query}' sucked.");
+        var bb = this.picker.get('boundingBox');
+        bb.one('.yui3-picker-search').set('value', 'my <search> string');
+        this.picker.set('results', []);
+
+        var search_results = bb.one('.yui3-picker-results');
+        Assert.areEqual(
+            "Your query 'my <search> string' sucked.",
+            search_results.get('text'),
+            "Empty results message wasn't displayed.");
+        Assert.isTrue(
+            search_results.hasClass('yui3-picker-no-results'),
+            "Missing no-results CSS class.");
+    },
+
+    test_search_results_clear_no_results_css: function () {
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        bb.one('.yui3-picker-search').set('value', 'my search string');
+        this.picker.set('results', []);
+
+        this.picker.set('results', [{title: 'Title 1'}, {title: 'Title 2'}]);
+        Assert.isFalse(
+            bb.one('.yui3-picker-results').hasClass('yui3-picker-no-results'),
+            "The no-results CSS class should have been removed.");
+    },
+
+    test_setting_search_slot_updates_ui: function () {
+        this.picker.render();
+        var filler = '<span>hello</span>';
+        this.picker.set('search_slot', Y.Node.create(filler));
+        var bb = this.picker.get('boundingBox');
+        var div = bb.one('.yui3-picker-search-slot');
+
+        Assert.isNotNull(div, 'Container for form extras not found.');
+        Assert.areEqual(filler, div.get('innerHTML'));
+    },
+
+    test_setting_footer_slot_updates_ui: function () {
+        this.picker.render();
+        var filler = '<span>foobar</span>';
+        this.picker.set('footer_slot', Y.Node.create(filler));
+        var bb = this.picker.get('boundingBox');
+        var div = bb.one('.yui3-picker-footer-slot');
+
+        Assert.isNotNull(div, 'Container for form extras not found.');
+        Assert.areEqual(filler, div.get('innerHTML'));
+    },
+
+    test_setting_batches_updates_ui: function () {
+        this.picker.render();
+        this.picker.set('batches', [
+            {value: 'new', name: 'New'},
+            {value: 'assigned', name: 'Assigned'}
+            ]);
+        var bb = this.picker.get('boundingBox');
+        Assert.isNotNull(
+            bb.one('.yui3-picker-batches span'),
+            "Container for batches not found.");
+        var batches = bb.all('.yui3-picker-batches span');
+        Assert.isNotNull(batches, "Batches not found");
+        Assert.areEqual(2, batches.size());
+        ArrayAssert.itemsAreEqual(
+            ['New', 'Assigned'],
+            batches.get('text'),
+            "Batches don't contain batch names.");
+        ArrayAssert.itemsAreEqual(
+            [true, false],
+            batches.hasClass('yui3-picker-selected-batch'),
+            "Selected batches missing CSS class.");
+
+        Assert.isNotNull(
+            bb.one('.yui3-picker-batches .lazr-prev'),
+            "There should be a previous button.");
+        Assert.isNotNull(
+            bb.one('.yui3-picker-batches .lazr-next'),
+            "There should be a next button.");
+    },
+
+    test_simplified_batching_interface: function () {
+        this.picker.render();
+        this.picker.set('batch_count', 4);
+        this.picker.set('results', [
+            { value: 'aardvark', title: 'Aardvarks' },
+            { value: 'bats', title: 'Bats' },
+            { value: 'cats', title: 'Cats' },
+            { value: 'dogs', title: 'Dogs' },
+            { value: 'emus', title: 'Emus' },
+            { value: 'frogs', title: 'Frogs' },
+            { value: 'gerbils', title: 'Gerbils' }
+        ]);
+        var bb = this.picker.get('boundingBox');
+        Assert.isNotNull(
+            bb.one('.yui3-picker-batches span'),
+            "Container for batches not found.");
+        var batches = bb.all('.yui3-picker-batches span');
+        Assert.isNotNull(batches, "Batches not found");
+        Assert.areEqual(4, batches.size());
+        ArrayAssert.itemsAreEqual(
+            ['1', '2', '3', '4'],
+            batches.get('text'),
+            "Batches don't contain batch names.");
+        ArrayAssert.itemsAreEqual(
+            [true, false, false, false],
+            batches.hasClass('yui3-picker-selected-batch'),
+            "Selected batches missing CSS class.");
+
+        Assert.isNotNull(
+            bb.one('.yui3-picker-batches .lazr-prev'),
+            "There should be a previous button.");
+        Assert.isNotNull(
+            bb.one('.yui3-picker-batches .lazr-next'),
+            "There should be a next button.");
+    },
+
+    test_clicking_a_batch_item_fires_search_event: function () {
+        this.picker.set('current_search_string', 'search');
+        this.picker.set('batches', [
+            {value: 'item1', name: 'Item 1'},
+            {value: 'item2', name: 'Item 2'}
+            ]);
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        var event_has_fired = false;
+        this.picker.subscribe('search', function(e) {
+            event_has_fired = true;
+            ArrayAssert.itemsAreEqual(
+                ['search', 'item1'], e.details,
+                "Search event details should contain search" +
+                "string and selected batch");
+        }, this);
+        simulate(
+            this.picker.get('boundingBox'),
+            '.yui3-picker-batches span', 'click');
+        Assert.isTrue(event_has_fired, "search event wasn't fired.");
+    },
+
+    test_clicking_a_batch_item_sets_selected_batch: function () {
+        this.picker.set('current_search_string', 'search');
+        this.picker.set('batches', [
+            {value: 'item1', name: 'Item 1'},
+            {value: 'item2', name: 'Item 2'}
+            ]);
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        Assert.areEqual(
+            0, this.picker.get('selected_batch'),
+            "First batch should be selected.");
+        simulate(
+            this.picker.get('boundingBox'),
+            '.yui3-picker-batches span:nth-last-child(2)', 'click');
+        Assert.areEqual(
+            1, this.picker.get('selected_batch'),
+            "selected_batch should have been updated.");
+    },
+
+    test_set_selected_batch_updates_css: function () {
+        this.picker.render();
+        this.picker.set('batches', [
+            {value: 'item1', name: '1'},
+            {value: 'item2', name: '2'}
+            ]);
+        Assert.areEqual(
+            0, this.picker.get('selected_batch'),
+            "Expected first batch to be selected by default.");
+        this.picker.set('selected_batch', 1);
+
+        var bb = this.picker.get('boundingBox');
+        var batches = bb.all('.yui3-picker-batches span');
+        ArrayAssert.itemsAreEqual(
+            [false, true],
+            batches.hasClass('yui3-picker-selected-batch'),
+            "Selected batch missing CSS class.");
+    },
+
+    test_set_selected_batch_validator: function () {
+        this.picker.render();
+        this.picker.set('batches', [
+            {value: 'item1', name: '1'},
+            {value: 'item2', name: '2'}
+            ]);
+
+        this.picker.set('selected_batch', -1);
+        Assert.areEqual(
+            0, this.picker.get('selected_batch'),
+            "Negative index shouldn't update selected_batch.");
+
+        this.picker.set('selected_batch', 3);
+        Assert.areEqual(
+            0, this.picker.get('selected_batch'),
+            "Index greather than last batch item shouldn't " +
+            "update selected_batch.");
+
+        this.picker.set('selected_batch', 'one');
+        Assert.areEqual(
+            0, this.picker.get('selected_batch'),
+            "Non-integere shouldn't update selected_batch.");
+    },
+
+    test_prev_button_is_disabled_only_on_first_batch: function () {
+        this.picker.set('batches', [
+            {value: 'item1', name: '1'},
+            {value: 'item2', name: '2'}
+            ]);
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        Assert.isTrue(
+            bb.one('.lazr-prev').get('disabled'),
+            "Previous button should be disabled on first batch.");
+
+        this.picker.set('selected_batch', 1);
+        Assert.isFalse(
+            bb.one('.lazr-prev').get('disabled'),
+            "Previous button shouldn't be disabled on last batch.");
+    },
+
+    test_next_button_is_disabled_only_on_last_batch: function () {
+        this.picker.set('batches', [
+            {value: 'item1', name: '1'},
+            {value: 'item2', name: '2'}
+            ]);
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        Assert.isFalse(
+            bb.one('.lazr-next').get('disabled'),
+            "Next button shouldn't be disabled on first batch.");
+
+        this.picker.set('selected_batch', 1);
+        Assert.isTrue(
+            bb.one('.lazr-next').get('disabled'),
+            "Previous button should be disabled on last batch.");
+    },
+
+    test_click_on_next_button_selects_next_batch: function () {
+        this.picker.set('batches', [
+            {value: 'item1', name: '1'},
+            {value: 'item2', name: '2'},
+            {value: 'item3', name: '3'}
+            ]);
+        this.picker.set('selected_batch', 1);
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        simulate(
+            this.picker.get('boundingBox'), '.lazr-next.lazr-btn', 'click');
+        Assert.areEqual(
+            2, this.picker.get('selected_batch'),
+            "Next batch should have been selected.");
+    },
+
+    test_click_on_next_button_fires_search_event: function () {
+        this.picker.set('current_search_string', 'search');
+        this.picker.set('batches', [
+            {value: 'item1', name: 'Item 1'},
+            {value: 'item2', name: 'Item 2'}
+            ]);
+        this.picker.render();
+
+        var event_has_fired = false;
+        this.picker.subscribe('search', function(e) {
+            event_has_fired = true;
+            ArrayAssert.itemsAreEqual(
+                ['search', 'item2'], e.details,
+                "Search event details should contain search" +
+                "string and selected batch");
+        }, this);
+        simulate(
+            this.picker.get('boundingBox'),
+            '.yui3-picker-batches .lazr-next', 'click');
+        Assert.isTrue(event_has_fired, "search event wasn't fired.");
+    },
+
+    test_click_on_prev_button_selects_prev_batch: function () {
+        this.picker.set('batches', [
+            {value: 'item1', name: '1'},
+            {value: 'item2', name: '2'},
+            {value: 'item3', name: '3'}
+            ]);
+        this.picker.set('selected_batch', 1);
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        simulate(
+            this.picker.get('boundingBox'), '.lazr-prev.lazr-btn', 'click');
+        Assert.areEqual(
+            0, this.picker.get('selected_batch'),
+            "Previous batch should have been selected.");
+    },
+
+    test_click_on_prev_button_fires_search_event: function () {
+        this.picker.set('current_search_string', 'search');
+        this.picker.set('batches', [
+            {value: 'item1', name: 'Item 1'},
+            {value: 'item2', name: 'Item 2'}
+            ]);
+        this.picker.set('selected_batch', 1);
+        this.picker.render();
+
+        var event_has_fired = false;
+        this.picker.subscribe('search', function(e) {
+            event_has_fired = true;
+            ArrayAssert.itemsAreEqual(
+                ['search', 'item1'], e.details,
+                "Search event details should contain search" +
+                "string and selected batch");
+        }, this);
+        simulate(
+            this.picker.get('boundingBox'),
+            '.yui3-picker-batches .lazr-prev', 'click');
+        Assert.isTrue(event_has_fired, "search event wasn't fired.");
+    },
+
+    test_buttons_are_displayed_only_if_there_are_batches: function () {
+        this.picker.render();
+
+        var bb = this.picker.get('boundingBox');
+        Assert.isNull(
+            bb.one('.yui3-picker-batches .lazr-prev'),
+            "There should be no previous button.");
+        Assert.isNull(
+            bb.one('.yui3-picker-batches .lazr-next'),
+            "There should be no next button.");
+    },
+
+    test_text_input_on_footer_can_be_focused: function () {
+        this.picker.render();
+        this.picker.set('footer_slot', Y.Node.create(
+            '<input class="extra-input" name="extra_input" type="text" />'));
+        var extra_input = this.picker.get('boundingBox').one('.extra-input');
+        var got_focus = false;
+        extra_input.on('focus', function(e) {
+            got_focus = true;
+        });
+        extra_input.focus();
+        Assert.isTrue(got_focus, "focus didn't go to the extra input.");
+    },
+
+    test_overlay_progress_value: function () {
+        // Setting the progress attribute controls the overlay's
+        // green progress bar.
+        this.picker.render();
+        Assert.areEqual(
+            50,
+            this.picker.get('progress'),
+            "The picker should start out with progress at 50%.");
+
+        this.picker.set('results', [
+            {
+                value: 'jschmo',
+                title: 'Joe Schmo',
+                description: 'joe@xxxxxxxxxxx'
+            }
+        ]);
+        Assert.areEqual(
+            100,
+            this.picker.get('progress'),
+            "The picker progress should be 100% with results.");
+
+        this.picker.set('results', []);
+        Assert.areEqual(
+            50,
+            this.picker.get('progress'),
+            "The picker progress should be 50% without results.");
+    },
+
+    test_exiting_search_mode_focus_search_box: function () {
+        this.picker.render();
+        this.picker.set('search_mode', true);
+
+        var bb = this.picker.get('boundingBox');
+        var search_input = bb.one('.yui3-picker-search');
+        var got_focus = false;
+        search_input.on('focus', function(e) {
+            got_focus = true;
+        });
+        this.picker.set('search_mode', false);
+        Assert.isTrue(got_focus, "focus didn't go to the search input.");
+    }
+}));
+
+suite.add(new Y.Test.Case({
+
+    name: 'picker_text_field_plugin',
+
+    setUp: function() {
+        this.search_input = Y.Node.create(
+                '<input id="field.initval" value="foo" />');
+        var node = Y.one(document.body).appendChild(this.search_input);
+        this.picker = new Y.lazr.Picker();
+        this.picker.plug(Y.lazr.TextFieldPickerPlugin,
+                         {input_element: '[id="field.initval"]'});
+    },
+
+    tearDown: function() {
+        cleanup_widget(this.picker);
+        this.search_input.remove();
+    },
+
+    test_TextFieldPickerPlugin_initial_value: function () {
+        this.picker.render();
+        this.picker.show();
+        Assert.areEqual('foo', this.picker._search_input.get('value'));
+    },
+
+    test_Te