← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/new-team-picker into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/new-team-picker into lp:launchpad with lp:~jcsackett/launchpad/cleanup-pickers-with-base-create as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/new-team-picker/+merge/111346

== Implementation ==

The LOC is a little high, but much of this is a necessary cut and paste to remove duplicate code from existing person picker tests. Also, there is some hardwired html which will be replaced by a generated form.

This branch is the first in a series which will add the ability to create a new team directly from within the person picker. There is one view in Launchpad which offers the user a chance to create a new team alongside providing a person picker. This is the product +edit-people view, and what is rendered is a text input field for typing the field value (eg maintainer) and next to that two links: a "Choose..." link and a "or create new team" link. However, with inline pickers being used on the project overview page for setting maintainer and driver, only people without javascript actually see this view.

So what is done is that a "New Team" link is provided for the person picker. This link is rendered in the "extra buttons" div alongside the "Pick Me" and "Remove Person" links. The new team link is part of the picker and so is available regardless of how the picker is created, inline popup or Choose... link. So part of the work therefore is to have the picker javascript convert the "Choose... or Create new team" pair of links into a single, simple Choose... link.

As is currently the case, only person pickers configured to "show_create_team_link" have the create team functionality. This is used on product maintainer and driver fields.

The feature is controlled by a feature flag: "disclosure.add-team-person-picker.enabled". Without the flag, everything renders as before.

This is only an initial implementation to get the base infrastructure in place. Current unfinished things include:

- The create team form html is hardwired into the picker as a mustache template. It needs to be fetched from the backend using a ++form++ call.
- The form doesn't allow subscription policy to be set.
- The team is not actually created. A 'save' event is published by the picker as if it were created allowing the feature to be demoed.

Some code in the picker stuff was moved. There was code in picker_patcher to show and hide the validation forms used to confirm a user's selection. These methods were moved to Picker itself and made into generic functions to show/hide arbitrary forms (eg the team creation form).

== Demo ==

http://people.canonical.com/~ianb/picker-new-team-demo.ogv

== Tests ==

Add tests for the back end widgets: test_popup and test_inlineeditpickerwidget:
- test_show_create_team_link_with_feature_flag
- test_show_create_team_link
- test_create_team_link

Move duplicated tests in test_personpicker to a common base class.

Add new yui tests for the create team functionality:
- test_picker_no_team_button_unless_configured
- test_picker_new_team_button_click_shows_form
- test_picker_new_team_cancel
- test_picker_new_team_save

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/browser/lazrjs.py
  lib/lp/app/browser/tests/test_inlineeditpickerwidget.py
  lib/lp/app/javascript/picker/person_picker.js
  lib/lp/app/javascript/picker/picker.js
  lib/lp/app/javascript/picker/picker_patcher.js
  lib/lp/app/javascript/picker/tests/test_personpicker.html
  lib/lp/app/javascript/picker/tests/test_personpicker.js
  lib/lp/app/widgets/popup.py
  lib/lp/app/widgets/tests/test_popup.py
  lib/lp/registry/browser/product.py
  lib/lp/services/features/flags.py
-- 
https://code.launchpad.net/~wallyworld/launchpad/new-team-picker/+merge/111346
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/new-team-picker into lp:launchpad.
=== modified file 'lib/lp/app/browser/lazrjs.py'
--- lib/lp/app/browser/lazrjs.py	2012-06-04 14:06:41 +0000
+++ lib/lp/app/browser/lazrjs.py	2012-06-21 07:01:35 +0000
@@ -346,7 +346,8 @@
 class InlinePersonEditPickerWidget(InlineEditPickerWidget):
     def __init__(self, context, exported_field, default_html,
                  content_box_id=None, header='Select an item',
-                 step_title='Search', assign_me_text='Pick me',
+                 step_title='Search', show_create_team=False,
+                 assign_me_text='Pick me',
                  remove_person_text='Remove person',
                  remove_team_text='Remove team',
                  null_display_value='None',
@@ -372,13 +373,13 @@
             in and when JS is off.  Defaults to the edit_view on the context.
         :param edit_title: Used to set the title attribute of the anchor.
         :param help_link: Used to set a link for help for the widget.
-        :param target_context: The target the person is being set for.
         """
         super(InlinePersonEditPickerWidget, self).__init__(
             context, exported_field, default_html, content_box_id, header,
             step_title, null_display_value,
             edit_view, edit_url, edit_title, help_link)
 
+        self._show_create_team = show_create_team
         self.assign_me_text = assign_me_text
         self.remove_person_text = remove_person_text
         self.remove_team_text = remove_team_text
@@ -399,11 +400,18 @@
         user = getUtility(ILaunchBag).user
         return user and user in vocabulary
 
+    @property
+    def show_create_team(self):
+        return (self._show_create_team
+                and getFeatureFlag(
+                    "disclosure.add-team-person-picker.enabled"))
+
     def getConfig(self):
         config = super(InlinePersonEditPickerWidget, self).getConfig()
         config.update(dict(
             show_remove_button=self.optional_field,
             show_assign_me_button=self.show_assign_me_button,
+            show_create_team=self.show_create_team,
             assign_me_text=self.assign_me_text,
             remove_person_text=self.remove_person_text,
             remove_team_text=self.remove_team_text))

=== modified file 'lib/lp/app/browser/tests/test_inlineeditpickerwidget.py'
--- lib/lp/app/browser/tests/test_inlineeditpickerwidget.py	2012-01-01 02:58:52 +0000
+++ lib/lp/app/browser/tests/test_inlineeditpickerwidget.py	2012-06-21 07:01:35 +0000
@@ -15,6 +15,7 @@
     InlineEditPickerWidget,
     InlinePersonEditPickerWidget,
     )
+from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     login_person,
     TestCaseWithFactory,
@@ -68,7 +69,7 @@
 
     layer = DatabaseFunctionalLayer
 
-    def getWidget(self, widget_value, **kwargs):
+    def getWidget(self, widget_value, show_create_team=False, **kwargs):
         class ITest(Interface):
             test_field = Choice(**kwargs)
 
@@ -80,7 +81,8 @@
 
         context = Test()
         return InlinePersonEditPickerWidget(
-            context, ITest['test_field'], None, edit_url='fake')
+            context, ITest['test_field'], None, edit_url='fake',
+            show_create_team=show_create_team)
 
     def test_person_selected_value_meta(self):
         # The widget has the correct meta value for a person value.
@@ -115,3 +117,19 @@
             None, vocabulary='TargetPPAs', required=True)
         login_person(self.factory.makePerson())
         self.assertFalse(widget.config['show_assign_me_button'])
+
+    def test_show_create_team_link_with_feature_flag(self):
+        with FeatureFixture(
+                {'disclosure.add-team-person-picker.enabled': 'true'}):
+            widget = self.getWidget(
+                None, vocabulary='ValidPersonOrTeam', required=True,
+                show_create_team=True)
+            login_person(self.factory.makePerson())
+            self.assertTrue(widget.config['show_create_team'])
+
+    def test_show_create_team_link(self):
+        widget = self.getWidget(
+            None, vocabulary='ValidPersonOrTeam', required=True,
+            show_create_team=True)
+        login_person(self.factory.makePerson())
+        self.assertFalse(widget.config['show_create_team'])

=== modified file 'lib/lp/app/javascript/picker/person_picker.js'
--- lib/lp/app/javascript/picker/person_picker.js	2012-06-21 07:01:35 +0000
+++ lib/lp/app/javascript/picker/person_picker.js	2012-06-21 07:01:35 +0000
@@ -16,8 +16,60 @@
     initializer: function(cfg) {
         // If the user isn't logged in, override the show_assign_me value.
         if (!Y.Lang.isValue(LP.links.me)) {
-            this.set('show_assign_me_button', false); 
+            this.set('show_assign_me_button', false);
         }
+        this.set('new_team_template', this._new_team_template());
+        this.set('new_team_form', this._new_team_form());
+    },
+
+    _new_team_template: function() {
+        return [
+          '<div class="new-team-node">',
+          '<div class="step-on" style="width: 100%;"></div>',
+          '<div class="transparent important-notice-popup">',
+            '{{> new_team_form}}',
+            '<div class="extra-form-buttons">',
+              '<button class="yes_button" type="button"></button>',
+              '<button class="no_button" type="button"></button>',
+            '</div>',
+          '</div>',
+          '</div>'].join('');
+    },
+
+    _new_team_form: function() {
+        // TODO - get the form using ++form++
+        return [
+        "<table id='launchpad-form-widgets' class='form'>",
+        "<tbody><tr><td colspan='2'><div>",
+        "<label for='field.name'>Name:</label><div>",
+        "<input type='text' value='' size='20'",
+        "    name='field.name' id='field.name'",
+        "    class='lowerCaseText textType'></div>",
+        "<p class='formHelp'>",
+        "    A short unique name, beginning with a lower-case letter",
+        "    or number, and containing only letters, numbers, dots,",
+        "    hyphens, or plus signs.</p>",
+        "</div></td></tr><tr><td colspan='2'><div>",
+        "<label for='field.displayname'>Display Name:</label><div>",
+        "<input type='text' value='' size='20'",
+        "    name='field.displayname' id='field.displayname'",
+        "    class='textType'></div>",
+        "<p class='formHelp'>",
+        "    This team's name as you would like it displayed",
+        "    throughout Launchpad.</p>",
+        "</div></td></tr><tr><td colspan='2'><div>",
+        "<label for='field.visibility'>Visibility:</label>",
+        "<div><div><div class='value'>",
+        "<select size='1'",
+        "    name='field.visibility' id='field.visibility'>",
+        "<option value='PUBLIC' selected='selected'>Public</option>",
+        "<option value='PRIVATE'>Private</option></select></div>",
+        "</div></div><p class='formHelp'>",
+        "    Anyone can see a public team's data. Only team members",
+        "    and Launchpad admins can see private team data.",
+        "    Private teams cannot become public.</p>",
+        "</div></td></tr></tbody></table>"
+        ].join('');
     },
 
     hide: function() {
@@ -31,10 +83,11 @@
     },
 
     _update_button_text: function() {
+        var link_text;
         if (this.get('selected_value_metadata') === 'team') {
-            var link_text = this.get('remove_team_text');
+            link_text = this.get('remove_team_text');
         } else {
-            var link_text = this.get('remove_person_text');
+            link_text = this.get('remove_person_text');
         }
         this.remove_button.set('text', link_text);
     },
@@ -75,32 +128,106 @@
         });
     },
 
+    _cancel_new_team: function(picker) {
+        var node = picker.get('contentBox').one('.new-team-node');
+        picker.hide_extra_content(node);
+    },
+
+    _save_new_team: function(picker) {
+        var node = picker.get('contentBox').one('.new-team-node');
+        var team_name = Y.Node.getDOMNode(node.one('[id=field.name]')).value;
+        var team_display_name =
+            Y.Node.getDOMNode(node.one('[id=field.displayname]')).value;
+        picker.hide_extra_content(node);
+        // TODO - make back end call to save team
+        var value = {
+            "api_uri": "/~" + team_name,
+            "title": team_display_name,
+            "value": team_name,
+            "metadata": "team"};
+        picker.fire('validate', value);
+    },
+
+    new_team: function () {
+        var partials = {new_team_form: this.get('new_team_form')};
+        var html = Y.lp.mustache.to_html(
+            this.get('new_team_template'), {}, partials);
+        var self = this;
+        var button_callback = function(e, callback_fn) {
+            e.halt();
+            if (Y.Lang.isFunction(callback_fn) ) {
+                callback_fn(self);
+            }
+        };
+        var team_form_node = Y.Node.create(html);
+        team_form_node.one(".yes_button")
+            .set('text', 'Create Team')
+            .on('click', function(e) {
+                button_callback(e, self._save_new_team);
+            });
+
+        team_form_node.one(".no_button")
+            .set('text', 'Cancel')
+            .on('click', function(e) {
+                button_callback(e, self._cancel_new_team);
+            });
+        this.get('contentBox').one('.yui3-widget-bd')
+            .insert(team_form_node, 'before');
+        this.show_extra_content(
+            team_form_node.one(".important-notice-popup"),
+            "Enter new team details");
+    },
+
+    _assign_me_button_html: function() {
+        return [
+            '<a class="yui-picker-assign-me-button bg-image ',
+            'js-action" href="javascript:void(0)" ',
+            'style="background-image: url(/@@/person); ',
+            'padding-right: 1em">',
+            this.get('assign_me_text'),
+            '</a>'].join('');
+    },
+
+    _remove_button_html: function() {
+        return [
+            '<a class="yui-picker-remove-button bg-image js-action" ',
+            'href="javascript:void(0)" ',
+            'style="background-image: url(/@@/remove); ',
+            'padding-right: 1em">',
+            this.get('remove_person_text'),
+            '</a>'].join('');
+    },
+
+    _new_team_button_html: function() {
+        return [
+            '<a class="yui-picker-new-team-button sprite add ',
+            'js-action" href="javascript:void(0)">',
+            'New Team',
+            '</a>'].join('');
+    },
     renderUI: function() {
         Y.lazr.picker.Picker.prototype.renderUI.apply(this, arguments);
         var extra_buttons = this.get('extra_buttons');
-        var remove_button, assign_me_button;
+        var remove_button, assign_me_button, new_team_button;
 
         if (this.get('show_remove_button')) {
-            remove_button = Y.Node.create(
-                '<a class="yui-picker-remove-button bg-image" ' +
-                'href="javascript:void(0)" ' +
-                'style="background-image: url(/@@/remove); padding-right: ' +
-                '1em">' + this.get('remove_person_text') + '</a>');
+            remove_button = Y.Node.create(this._remove_button_html());
             remove_button.on('click', this.remove, this);
             extra_buttons.appendChild(remove_button);
             this.remove_button = remove_button;
         }
 
         if (this.get('show_assign_me_button')) {
-            assign_me_button = Y.Node.create(
-                '<a class="yui-picker-assign-me-button bg-image" ' +
-                'href="javascript:void(0)" ' +
-                'style="background-image: url(/@@/person)">' +
-                this.get('assign_me_text') + '</a>');
+            assign_me_button = Y.Node.create(this._assign_me_button_html());
             assign_me_button.on('click', this.assign_me, this);
             extra_buttons.appendChild(assign_me_button);
             this.assign_me_button = assign_me_button;
         }
+        if (this.get('show_create_team')) {
+            new_team_button = Y.Node.create(this._new_team_button_html());
+            new_team_button.on('click', this.new_team, this);
+            extra_buttons.appendChild(new_team_button);
+        }
         this._search_input.insert(
             extra_buttons, this._search_input.get('parentNode'));
         this._show_hide_buttons();
@@ -112,15 +239,18 @@
     ATTRS: {
         extra_buttons: {
             valueFn: function () {
-                return Y.Node.create('<div class="extra-form-buttons"/>')
-            } 
+                return Y.Node.create('<div class="extra-form-buttons"/>');
+            }
         },
         show_assign_me_button: { value: true },
         show_remove_button: {value: true },
         assign_me_text: {value: 'Pick me'},
         remove_person_text: {value: 'Remove person'},
         remove_team_text: {value: 'Remove team'},
-        min_search_chars: {value: 2}
+        min_search_chars: {value: 2},
+        show_create_team: {value: false},
+        new_team_template: {value: null},
+        new_team_form: {value: null}
     }
 });
-}, "0.1", {"requires": ["base", "node", "lazr.picker"]});
+}, "0.1", {"requires": ["base", "node", "lazr.picker", "lp.mustache"]});

=== modified file 'lib/lp/app/javascript/picker/picker.js'
--- lib/lp/app/javascript/picker/picker.js	2012-06-21 07:01:35 +0000
+++ lib/lp/app/javascript/picker/picker.js	2012-06-21 07:01:35 +0000
@@ -20,14 +20,14 @@
  */
 ns.Picker = Y.Base.create('picker', Y.lazr.PrettyOverlay, [], {
 
-    /** 
+    /**
      * The search input node.
      *
      * @property _search_button
      * @type Node
      * @private
      */
-    _search_input: null;
+    _search_input: null,
 
     /**
      * The search button node.
@@ -36,7 +36,7 @@
      * @type Node
      * @private
      */
-    _search_button: null;
+    _search_button: null,
 
     /**
      * The node containing filter options.
@@ -45,7 +45,7 @@
      * @type Node
      * @private
      */
-    _filter_box: null;
+    _filter_box: null,
 
     /**
      * The node containing search results.
@@ -54,7 +54,7 @@
      * @type Node
      * @private
      */
-    _results_box: null;
+    _results_box: null,
 
     /**
      * The node containing the extra form inputs.
@@ -63,7 +63,7 @@
      * @type Node
      * @private
      */
-    _search_slot_box: null;
+    _search_slot_box: null,
 
     /**
      * The node containing the batches.
@@ -72,7 +72,7 @@
      * @type Node
      * @private
      */
-     _batches_box: null;
+     _batches_box: null,
 
     /**
      * The node containing the previous batch button.
@@ -81,7 +81,7 @@
      * @type Node
      * @private
      */
-    _prev_button: null;
+    _prev_button: null,
 
     /**
      * The node containing the next batch button.
@@ -90,7 +90,7 @@
      * @type Node
      * @private
      */
-    _next_button: null;
+    _next_button: null,
 
     /**
      * The node containing an error message if any.
@@ -99,7 +99,7 @@
      * @type Node
      * @private
      */
-    _error_box: null;
+    _error_box: null,
 
     initializer: function(cfg) {
         /**
@@ -823,6 +823,61 @@
     },
 
     /*
+     * Insert the extra content into the form and animate its appearance.
+     */
+    show_extra_content: function(extra_content, header) {
+        if (Y.Lang.isValue(header)) {
+            this.set('picker_header', this.get('headerContent'));
+            this.set(
+                'headerContent',
+                Y.Node.create("<h2></h2>").set('text', header));
+        }
+        this.get('contentBox').one('.yui3-widget-bd').hide();
+        this.get('contentBox').all('.steps').hide();
+        var duration = 0;
+        if (this.get('use_animation')) {
+            duration = 0.9;
+        }
+        var fade_in = new Y.Anim({
+            node: extra_content,
+            to: {opacity: 1},
+            duration: duration
+        });
+        fade_in.run();
+    },
+
+    hide_extra_content: function(extra_content_node) {
+        var saved_header = this.get('picker_header');
+        if (Y.Lang.isValue(saved_header)) {
+            this.set('headerContent', saved_header);
+            this.set('picker_header', null);
+        }
+        this.get('contentBox').all('.steps').show();
+        var content_node = this.get('contentBox').one('.yui3-widget-bd');
+        if (extra_content_node !== null) {
+            extra_content_node.get('parentNode')
+                .removeChild(extra_content_node);
+            content_node.addClass('transparent');
+            content_node.setStyle('opacity', 0);
+            content_node.show();
+            var duration = 0;
+            if (this.get('use_animation')) {
+                duration = 0.6;
+            }
+            var content_fade_in = new Y.Anim({
+                node: content_node,
+                to: {opacity: 1},
+                duration: duration
+            });
+            content_fade_in.run();
+        } else {
+            content_node.removeClass('transparent');
+            content_node.setStyle('opacity', 1);
+            content_node.show();
+        }
+    },
+
+    /*
      * Clear all elements of the picker, resetting it to its original state.
      *
      * @method _clear
@@ -970,8 +1025,8 @@
         clear_on_cancel: { value: false },
 
         /**
-         * A CSS selector for the DOM element that will activate (show) the picker
-         * once clicked.
+         * A CSS selector for the DOM element that will activate (show) the
+         * picker once clicked.
          *
          * @attribute picker_activator
          * @type String
@@ -979,8 +1034,8 @@
         picker_activator: {},
 
         /**
-         * An extra CSS class to be added to the picker_activator, generally used
-         * to distinguish regular links from js-triggering ones.
+         * 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
@@ -998,8 +1053,8 @@
         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.
+         * 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
@@ -1015,8 +1070,8 @@
         current_filter_value: {value: null},
 
         /**
-         * A list of attribute name values used to construct the filtering options
-         * for this picker..
+         * A list of attribute name values used to construct the filtering
+         * options for this picker.
          *
          * @attribute filter_options
          * @type Object
@@ -1079,7 +1134,7 @@
          * 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' 
+         * the batch label) and value (used as additional details to 'search'
          * event).
          *
          * @attribute batches
@@ -1091,9 +1146,9 @@
          * 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.
+         * 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.
          *
@@ -1139,8 +1194,8 @@
         error: { value: null },
 
         /**
-         * The message to display when the search returned no results. This string
-         * can contain a 'query' placeholder
+         * The message to display when the search returned no results.
+         * This string can contain a 'query' placeholder
          *
          * @attribute no_results_search_message
          * @type String
@@ -1148,6 +1203,17 @@
          */
         no_results_search_message: {
             value: 'No items matched "{query}".'
+        },
+
+        /**
+         * Whether to use animations (fade in/out) for content rendering.
+         *
+         * @attribute use_animation
+         * @type Boolean
+         * @default true
+         */
+        use_animation: {
+            value: true
         }
     }
 });
@@ -1186,7 +1252,7 @@
             this.get('host').setAttrs({
                 selected_value_metadata: result.metadata,
                 selected_value: result.value
-            })
+            });
             input.set("value",  result.value || '');
             // If the search input isn't blurred before it is focused,
             // then the I-beam disappears.

=== modified file 'lib/lp/app/javascript/picker/picker_patcher.js'
--- lib/lp/app/javascript/picker/picker_patcher.js	2012-06-21 07:01:35 +0000
+++ lib/lp/app/javascript/picker/picker_patcher.js	2012-06-21 07:01:35 +0000
@@ -24,9 +24,21 @@
     if (show_widget_node.hasClass('js-action')) {
         return;
     }
-    show_widget_node.set('innerHTML', 'Choose&hellip;');
-    show_widget_node.addClass('js-action');
-    show_widget_node.get('parentNode').removeClass('unseen');
+    var picker_span = show_widget_node.get('parentNode');
+    if (config.enhanced_picker) {
+        var new_node = Y.Node.create('<span>(<a href="#"></a>)</span>');
+        show_widget_node = new_node.one('a');
+        show_widget_node
+            .set('id', show_widget_id)
+            .addClass('js-action')
+            .set('text', 'Choose\u2026');
+        picker_span.empty();
+        picker_span.appendChild(new_node);
+    } else {
+        show_widget_node.set('text', 'Choose\u2026');
+        show_widget_node.addClass('js-action');
+    }
+    picker_span.removeClass('unseen');
     show_widget_node.on('click', function (e) {
         if (picker === null) {
             picker = namespace.create(
@@ -261,7 +273,7 @@
 
     node.one(".validation-content-placeholder").replace(content);
     picker.get('contentBox').one('.yui3-widget-bd').insert(node, 'before');
-    animate_validation_content(picker, node.one(".important-notice-popup"));
+    picker.show_extra_content(node.one(".important-notice-popup"));
 };
 
 /*
@@ -298,42 +310,11 @@
 };
 
 /*
- * Insert the validation content into the form and animate its appearance.
- */
-function animate_validation_content(picker, validation_content) {
-    picker.get('contentBox').one('.yui3-widget-bd').hide();
-    picker.get('contentBox').all('.steps').hide();
-    var validation_fade_in = new Y.Anim({
-        node: validation_content,
-        to: {opacity: 1},
-        duration: 0.9
-    });
-    validation_fade_in.run();
-}
-
-/*
  * Restore a picker to its functional state after a validation operation.
  */
 function reset_form(picker) {
-    picker.get('contentBox').all('.steps').show();
     var validation_node = picker.get('contentBox').one('.validation-node');
-    var content_node = picker.get('contentBox').one('.yui3-widget-bd');
-    if (validation_node !== null) {
-        validation_node.get('parentNode').removeChild(validation_node);
-        content_node.addClass('transparent');
-        content_node.setStyle('opacity', 0);
-        content_node.show();
-        var content_fade_in = new Y.Anim({
-            node: content_node,
-            to: {opacity: 1},
-            duration: 0.6
-        });
-        content_fade_in.run();
-    } else {
-        content_node.removeClass('transparent');
-        content_node.setStyle('opacity', 1);
-        content_node.show();
-    }
+    picker.hide_extra_content(validation_node);
 }
 
 

=== modified file 'lib/lp/app/javascript/picker/tests/test_personpicker.html'
--- lib/lp/app/javascript/picker/tests/test_personpicker.html	2012-06-21 07:01:35 +0000
+++ lib/lp/app/javascript/picker/tests/test_personpicker.html	2012-06-21 07:01:35 +0000
@@ -36,6 +36,8 @@
       <script type="text/javascript"
           src="../../../../../../build/js/lp/app/lazr/lazr.js"></script>
       <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/mustache.js"></script>
+      <script type="text/javascript"
           src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
       <script type="text/javascript"
           src="../../../../../../build/js/lp/app/effects/effects.js"></script>

=== modified file 'lib/lp/app/javascript/picker/tests/test_personpicker.js'
--- lib/lp/app/javascript/picker/tests/test_personpicker.js	2012-02-06 18:16:06 +0000
+++ lib/lp/app/javascript/picker/tests/test_personpicker.js	2012-06-21 07:01:35 +0000
@@ -4,7 +4,7 @@
 
 YUI().use('test', 'console', 'plugin',
            'lazr.picker', 'lazr.person-picker', 'lp.app.picker',
-           'node-event-simulate', function(Y) {
+           'lp.app.mustache', 'node-event-simulate', function(Y) {
 
     var Assert = Y.Assert;
 
@@ -93,11 +93,12 @@
         },
 
         _picker_params: function(
-            show_assign_me_button, show_remove_button,
+            show_assign_me_button, show_remove_button, show_create_team,
             selected_value, selected_value_metadata) {
             return {
                 "show_assign_me_button": show_assign_me_button,
                 "show_remove_button": show_remove_button,
+                "show_create_team": show_create_team,
                 "selected_value": selected_value,
                 "selected_value_metadata": selected_value_metadata
             };
@@ -186,10 +187,8 @@
 
         test_picker_remove_person_button_text: function() {
             // The remove button text is correct.
-            this.create_picker(this._picker_params(true,
-                true,
-                "fred",
-                "person"));
+            this.create_picker(this._picker_params(
+                true, true, false, "fred", "person"));
             this.picker.render();
             var remove_button = Y.one('.yui-picker-remove-button');
             Assert.areEqual('Remove someone', remove_button.get('innerHTML'));
@@ -197,7 +196,8 @@
 
         test_picker_remove_team_button_text: function() {
             // The remove button text is correct.
-            this.create_picker(this._picker_params(true, true, "cats", "team"));
+            this.create_picker(this._picker_params(
+                true, true, false, "cats", "team"));
             this.picker.render();
             var remove_button = Y.one('.yui-picker-remove-button');
             Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
@@ -221,7 +221,8 @@
         test_picker_no_assign_me_button_if_value_is_me: function() {
             // The assign me button is not shown if the picker is created for a
             // field where the value is "me".
-            this.create_picker(this._picker_params(true, true, "me"), this.ME);
+            this.create_picker(this._picker_params(
+                true, true, false, "me"), this.ME);
             this.picker.render();
             this._check_assign_me_button_state(false);
         },
@@ -237,7 +238,8 @@
         test_picker_has_remove_button_if_value: function() {
             // The remove button is shown if the picker is created for a field
             // which has a value.
-            this.create_picker(this._picker_params(true, true, "me"), this.ME);
+            this.create_picker(this._picker_params(
+                true, true, false, "me"), this.ME);
             this.picker.render();
             this._check_remove_button_state(true);
         },
@@ -245,9 +247,151 @@
         test_picker_no_remove_button_unless_configured: function() {
             // The remove button is only rendered if show_remove_button
             // setting is true.
-            this.create_picker(this._picker_params(true, false, "me"), this.ME);
+            this.create_picker(this._picker_params(
+                true, false, false, "me"), this.ME);
             this.picker.render();
             Assert.isNull(Y.one('.yui-picker-remove-button'));
+        },
+
+        test_picker_assign_me_button_hide_on_save: function() {
+            // The assign me button is shown initially but hidden if the picker
+            // saves a value equal to 'me'.
+            this.create_picker(this._picker_params(true, true));
+            this._check_assign_me_button_state(true);
+            this.picker.set('results', this.vocabulary);
+            this.picker.render();
+            simulate(
+                this.picker.get('boundingBox').one('.yui3-picker-results'),
+                    'li:nth-child(1)', 'click');
+            this._check_assign_me_button_state(false);
+        },
+
+        test_picker_remove_button_clicked: function() {
+            // The remove button is hidden once a picker value has been removed.
+            // And the assign me button is shown.
+            this.create_picker(this._picker_params(
+                true, true, false, "me"), this.ME);
+            this.picker.render();
+            this._check_assign_me_button_state(false);
+            var remove = Y.one('.yui-picker-remove-button');
+            remove.simulate('click');
+            this._check_remove_button_state(false);
+            this._check_assign_me_button_state(true);
+        },
+
+        test_picker_assign_me_button_clicked: function() {
+            // The assign me button is hidden once it is clicked.
+            // And the remove button is shown.
+            this.create_picker(this._picker_params(true, true));
+            this.picker.render();
+            var assign_me = Y.one('.yui-picker-assign-me-button');
+            assign_me.simulate('click');
+            this._check_remove_button_state(true);
+            this._check_assign_me_button_state(false);
+        },
+
+        test_picker_assign_me_updates_remove_text: function() {
+            // When Assign me is used, the Remove button text is updated from
+            // the team removal text to the person removal text.
+            this.create_picker(this._picker_params(
+                true, true, false, "cats", "team"));
+            this.picker.render();
+            var remove_button = Y.one('.yui-picker-remove-button');
+            Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
+            var assign_me = Y.one('.yui-picker-assign-me-button');
+            assign_me.simulate('click');
+            Assert.areEqual('Remove someone', remove_button.get('innerHTML'));
+        },
+
+        test_picker_save_updates_remove_text: function() {
+            // When save is called, the Remove button text is updated
+            // according to the newly saved value.
+            this.create_picker(this._picker_params(
+                true, true, false, "me"), this.ME);
+            var remove_button = Y.one('.yui-picker-remove-button');
+            Assert.areEqual('Remove someone', remove_button.get('innerHTML'));
+            this.picker.set('results', this.vocabulary);
+            this.picker.render();
+            simulate(
+                this.picker.get('boundingBox').one('.yui3-picker-results'),
+                    'li:nth-child(2)', 'click');
+            Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
+        },
+
+        test_picker_no_team_button_unless_configured: function() {
+            // The new team button is only rendered if show_create_team
+            // setting is true.
+            this.create_picker(this._picker_params(true, false, false));
+            this.picker.render();
+            Assert.isNull(Y.one('.yui-picker-new-team-button'));
+        },
+
+        test_picker_new_team_button_click_shows_form: function() {
+            // Clicking the new team button displays the new team form.
+            this.create_picker(this._picker_params(true, true, true));
+            this.picker.render();
+            var new_team = this.picker.get('boundingBox')
+                .one('.yui-picker-new-team-button');
+            new_team.simulate('click');
+            Y.Assert.areEqual(
+                'Enter new team details',
+                this.picker.get('headerContent').get('text'));
+            Y.Assert.isNotNull(
+                this.picker.get('contentBox').one('[id=field.name]'));
+            Y.Assert.areEqual('none',
+                this.picker.get('contentBox').one('.yui3-widget-bd')
+                    .getStyle('display'));
+        },
+
+        test_picker_new_team_cancel: function() {
+            // Clicking the cancel button on the new team form reverts back to
+            // the normal picker.
+            this.create_picker(this._picker_params(true, true, true));
+            this.picker.render();
+            var new_team = this.picker.get('boundingBox')
+                .one('.yui-picker-new-team-button');
+            new_team.simulate('click');
+            Y.Assert.areEqual(
+                'Enter new team details',
+                this.picker.get('headerContent').get('text'));
+            var form_buttons = this.picker.get('contentBox')
+                .one('.extra-form-buttons');
+            simulate(
+                form_buttons, 'button:nth-child(2)', 'click');
+            Y.Assert.areEqual(
+                'Pick Someone',
+                this.picker.get('headerContent').get('text'));
+            Y.Assert.isNull(
+                this.picker.get('contentBox').one('[id=field.name]'));
+            Y.Assert.isNotNull(
+                this.picker.get('contentBox').one('.yui3-picker-search'));
+        },
+
+        test_picker_new_team_save: function() {
+            // Clicking the save button on the new team form fires a 'save'
+            // event with the expected data.
+            this.create_picker(this._picker_params(true, true, true));
+            this.picker.render();
+
+            var result_published = false;
+            this.picker.subscribe('save', function(e) {
+                var saved_value =
+                    e.details[Y.lazr.picker.Picker.SAVE_RESULT];
+                Y.Assert.areEqual('/~fred', saved_value.api_uri);
+                Y.Assert.areEqual('fred', saved_value.value);
+                result_published = true;
+            });
+
+            var picker_content = this.picker.get('boundingBox');
+            var new_team =
+                picker_content.one('.yui-picker-new-team-button');
+            new_team.simulate('click');
+            var team_name = picker_content.one('[id=field.name]');
+            Y.Node.getDOMNode(team_name).value = 'fred';
+            var form_buttons = picker_content.one('.extra-form-buttons');
+            simulate(
+                form_buttons, 'button:nth-child(1)', 'click');
+            Y.Assert.isTrue(result_published);
         }
     };
 
@@ -267,6 +411,7 @@
             }
 
             var config = {
+                "use_animation": false,
                 "picker_type": "person",
                 "step_title": "Choose someone",
                 "header": "Pick Someone",
@@ -276,6 +421,7 @@
                 "show_remove_button": params.show_remove_button,
                 "selected_value": params.selected_value,
                 "selected_value_metadata": params.selected_value_metadata,
+                "show_create_team": params.show_create_team,
                 "assign_me_text": "Assign Moi",
                 "remove_person_text": "Remove someone",
                 "remove_team_text": "Remove some team"
@@ -286,68 +432,6 @@
                     "test_link",
                     "picker_id",
                     config);
-        },
-
-        test_picker_assign_me_button_hide_on_save: function() {
-            // The assign me button is shown initially but hidden if the picker
-            // saves a value equal to 'me'.
-            this.create_picker(this._picker_params(true, true));
-            this._check_assign_me_button_state(true);
-            this.picker.set('results', this.vocabulary);
-            this.picker.render();
-            simulate(
-                this.picker.get('boundingBox').one('.yui3-picker-results'),
-                    'li:nth-child(1)', 'click');
-            this._check_assign_me_button_state(false);
-        },
-
-        test_picker_remove_button_clicked: function() {
-            // The remove button is hidden once a picker value has been removed.
-            // And the assign me button is shown.
-            this.create_picker(this._picker_params(true, true, "me"), this.ME);
-            this.picker.render();
-            this._check_assign_me_button_state(false);
-            var remove = Y.one('.yui-picker-remove-button');
-            remove.simulate('click');
-            this._check_remove_button_state(false);
-            this._check_assign_me_button_state(true);
-        },
-
-        test_picker_assign_me_button_clicked: function() {
-            // The assign me button is hidden once it is clicked.
-            // And the remove button is shown.
-            this.create_picker(this._picker_params(true, true));
-            this.picker.render();
-            var assign_me = Y.one('.yui-picker-assign-me-button');
-            assign_me.simulate('click');
-            this._check_remove_button_state(true);
-            this._check_assign_me_button_state(false);
-        },
-
-        test_picker_assign_me_updates_remove_text: function() {
-            // When Assign me is used, the Remove button text is updated from
-            // the team removal text to the person removal text.
-            this.create_picker(this._picker_params(true, true, "cats", "team"));
-            this.picker.render();
-            var remove_button = Y.one('.yui-picker-remove-button');
-            Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
-            var assign_me = Y.one('.yui-picker-assign-me-button');
-            assign_me.simulate('click');
-            Assert.areEqual('Remove someone', remove_button.get('innerHTML'));
-        },
-
-        test_picker_save_updates_remove_text: function() {
-            // When save is called, the Remove button text is updated
-            // according to the newly saved value.
-            this.create_picker(this._picker_params(true, true, "me"), this.ME);
-            var remove_button = Y.one('.yui-picker-remove-button');
-            Assert.areEqual('Remove someone', remove_button.get('innerHTML'));
-            this.picker.set('results', this.vocabulary);
-            this.picker.render();
-            simulate(
-                this.picker.get('boundingBox').one('.yui3-picker-results'),
-                    'li:nth-child(2)', 'click');
-            Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
         }
     };
 
@@ -377,9 +461,11 @@
             }
             var config = {
                 "picker_type": "person",
+                "header": "Pick Someone",
                 "associated_field_id": associated_field_id,
                 "show_assign_me_button": params.show_assign_me_button,
                 "show_remove_button": params.show_remove_button,
+                "show_create_team": params.show_create_team,
                 "selected_value": params.selected_value,
                 "selected_value_metadata": params.selected_value_metadata,
                 "assign_me_text": "Assign Moi",
@@ -388,68 +474,6 @@
                 };
             this.picker = Y.lp.app.picker.create(
                                 this.vocabulary, config, associated_field_id);
-        },
-
-        test_picker_assign_me_button_hide_on_save: function() {
-            // The assign me button is shown initially but hidden if the picker
-            // saves a value equal to 'me'.
-            this.create_picker(this._picker_params(true, true));
-            this._check_assign_me_button_state(true);
-            this.picker.set('results', this.vocabulary);
-            this.picker.render();
-            simulate(
-                this.picker.get('boundingBox').one('.yui3-picker-results'),
-                    'li:nth-child(1)', 'click');
-            this._check_assign_me_button_state(false);
-        },
-
-        test_picker_remove_button_clicked: function() {
-            // The remove button is hidden once a picker value has been removed.
-            // And the assign me button is shown.
-            this.create_picker(this._picker_params(true, true, "me"), this.ME);
-            this.picker.render();
-            this._check_assign_me_button_state(false);
-            var remove = Y.one('.yui-picker-remove-button');
-            remove.simulate('click');
-            this._check_remove_button_state(false);
-            this._check_assign_me_button_state(true);
-        },
-
-        test_picker_assign_me_button_clicked: function() {
-            // The assign me button is hidden once it is clicked.
-            // And the remove button is shown.
-            this.create_picker(this._picker_params(true, true));
-            this.picker.render();
-            var assign_me = Y.one('.yui-picker-assign-me-button');
-            assign_me.simulate('click');
-            this._check_remove_button_state(true);
-            this._check_assign_me_button_state(false);
-        },
-
-        test_picker_assign_me_updates_remove_text: function() {
-            // When Assign me is used, the Remove button text is updated from
-            // the team removal text to the person removal text.
-            this.create_picker(this._picker_params(true, true, "cats", "team"));
-            this.picker.render();
-            var remove_button = Y.one('.yui-picker-remove-button');
-            Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
-            var assign_me = Y.one('.yui-picker-assign-me-button');
-            assign_me.simulate('click');
-            Assert.areEqual('Remove someone', remove_button.get('innerHTML'));
-        },
-
-        test_picker_save_updates_remove_text: function() {
-            // When save is called, the Remove button text is updated
-            // according to the newly saved value.
-            this.create_picker(this._picker_params(true, true, "me"), this.ME);
-            var remove_button = Y.one('.yui-picker-remove-button');
-            Assert.areEqual('Remove someone', remove_button.get('innerHTML'));
-            this.picker.set('results', this.vocabulary);
-            this.picker.render();
-            simulate(
-                this.picker.get('boundingBox').one('.yui3-picker-results'),
-                    'li:nth-child(2)', 'click');
-            Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
         }
     };
 

=== modified file 'lib/lp/app/widgets/popup.py'
--- lib/lp/app/widgets/popup.py	2012-02-22 05:22:13 +0000
+++ lib/lp/app/widgets/popup.py	2012-06-21 07:01:35 +0000
@@ -23,6 +23,7 @@
     get_person_picker_entry_metadata,
     vocabulary_filters,
     )
+from lp.services.features import getFeatureFlag
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import canonical_url
 
@@ -41,6 +42,7 @@
     assign_me_text = 'Pick me'
     remove_person_text = 'Remove person'
     remove_team_text = 'Remove team'
+    show_create_team_link = False
 
     popup_name = 'popup-vocabulary-picker'
 
@@ -56,6 +58,12 @@
     # Defaults to self.vocabulary.displayname.
     header = None
 
+    @property
+    def enhanced_picker(self):
+        flag = getFeatureFlag(
+            "disclosure.add-team-person-picker.enabled")
+        return flag and self.show_create_team_link
+
     @cachedproperty
     def matches(self):
         """Return a list of matches (as ITokenizedTerm) to whatever the
@@ -143,7 +151,9 @@
             vocabulary_name=self.vocabulary_name,
             vocabulary_filters=self.vocabulary_filters,
             input_element=self.input_id,
-            show_widget_id=self.show_widget_id)
+            show_widget_id=self.show_widget_id,
+            enhanced_picker=self.enhanced_picker,
+            show_create_team=self.enhanced_picker)
 
     @property
     def json_config(self):
@@ -206,8 +216,12 @@
         else:
             css = ''
         return ('<span class="%s">(<a id="%s" href="%s">'
-                'Find&hellip;</a>)</span>') % (
-            css, self.show_widget_id, self.nonajax_uri or '#')
+                'Find&hellip;</a>)%s</span>') % (
+            css, self.show_widget_id, self.nonajax_uri or '#',
+            self.extraChooseLink() or '')
+
+    def extraChooseLink(self):
+        return None
 
     @property
     def nonajax_uri(self):
@@ -220,7 +234,6 @@
 
 class PersonPickerWidget(VocabularyPickerWidget):
 
-    include_create_team_link = False
     show_assign_me_button = True
     show_remove_button = False
     picker_type = 'person'
@@ -230,12 +243,11 @@
         val = self._getFormValue()
         return get_person_picker_entry_metadata(val)
 
-    def chooseLink(self):
-        link = super(PersonPickerWidget, self).chooseLink()
-        if self.include_create_team_link:
-            link += ('or (<a href="/people/+newteam">'
+    def extraChooseLink(self):
+        if self.show_create_team_link:
+            return ('or (<a href="/people/+newteam">'
                      'Create a new team&hellip;</a>)')
-        return link
+        return None
 
     @property
     def nonajax_uri(self):
@@ -251,10 +263,8 @@
         >Register an external bug tracker&hellip;</a>)
         """
 
-    def chooseLink(self):
-        link = super(BugTrackerPickerWidget, self).chooseLink()
-        link += self.link_template
-        return link
+    def extraChooseLink(self):
+        return self.link_template
 
     @property
     def nonajax_uri(self):

=== modified file 'lib/lp/app/widgets/tests/test_popup.py'
--- lib/lp/app/widgets/tests/test_popup.py	2012-02-03 06:27:17 +0000
+++ lib/lp/app/widgets/tests/test_popup.py	2012-06-21 07:01:35 +0000
@@ -1,5 +1,6 @@
 # Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
+from lp.services.features.testing import FeatureFixture
 
 __metaclass__ = type
 
@@ -168,6 +169,26 @@
         # But not the remove button.
         self.assertFalse(person_picker_widget.config['show_remove_button'])
 
+    def test_create_team_link(self):
+        # The person picker widget shows a create team link if the feature flag
+        # is on.
+        field = ITest['test_valid.item']
+        bound_field = field.bind(self.context)
+
+        with FeatureFixture(
+                {'disclosure.add-team-person-picker.enabled': 'true'}):
+            picker_widget = PersonPickerWidget(
+                bound_field, self.vocabulary, self.request)
+            picker_widget.show_create_team_link = True
+            self.assertTrue(picker_widget.config['show_create_team'])
+            self.assertTrue(picker_widget.config['enhanced_picker'])
+
+        picker_widget = PersonPickerWidget(
+            bound_field, self.vocabulary, self.request)
+        picker_widget.show_create_team_link = True
+        self.assertFalse(picker_widget.config['show_create_team'])
+        self.assertFalse(picker_widget.config['enhanced_picker'])
+
     def test_widget_personvalue_meta(self):
         # The person picker has the correct meta value for a person value.
         person = self.factory.makePerson()

=== modified file 'lib/lp/registry/browser/product.py'
--- lib/lp/registry/browser/product.py	2012-06-04 11:41:47 +0000
+++ lib/lp/registry/browser/product.py	2012-06-21 07:01:35 +0000
@@ -938,7 +938,7 @@
             self.context, IProduct['owner'],
             format_link(self.context.owner),
             header='Change maintainer', edit_view='+edit-people',
-            step_title='Select a new maintainer')
+            step_title='Select a new maintainer', show_create_team=True)
 
     @property
     def driver_widget(self):
@@ -946,7 +946,7 @@
             self.context, IProduct['driver'],
             format_link(self.context.driver, empty_value="Not yet selected"),
             header='Change driver', edit_view='+edit-people',
-            step_title='Select a new driver',
+            step_title='Select a new driver', show_create_team=True,
             null_display_value="Not yet selected",
             help_link="/+help-registry/driver.html")
 
@@ -2269,11 +2269,11 @@
     initial_values = {'transfer_to_registry': False}
 
     custom_widget('owner', PersonPickerWidget, header="Select the maintainer",
-                  include_create_team_link=True)
+                  show_create_team_link=True)
     custom_widget('transfer_to_registry', CheckBoxWidget,
                   widget_class='field subordinate')
     custom_widget('driver', PersonPickerWidget, header="Select the driver",
-                  include_create_team_link=True)
+                  show_create_team_link=True)
 
     @property
     def page_title(self):

=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py	2012-06-08 02:16:58 +0000
+++ lib/lp/services/features/flags.py	2012-06-21 07:01:35 +0000
@@ -223,6 +223,12 @@
      '',
      '',
      ''),
+    ('disclosure.add-team-person-picker.enabled',
+     'boolean',
+     'Allows users to add a new team directly from the person picker.',
+     '',
+     '',
+     ''),
     ('bugs.autoconfirm.enabled_distribution_names',
      'space delimited',
      ('Enables auto-confirming bugtasks for distributions (and their '


Follow ups