Merge lp:~wallyworld/launchpad/new-team-picker-simple-form into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Ian Booth
Approved revision: no longer in the source branch.
Merged at revision: 15531
Proposed branch: lp:~wallyworld/launchpad/new-team-picker-simple-form
Merge into: lp:launchpad
Prerequisite: lp:~wallyworld/launchpad/new-team-picker-load-form
Diff against target: 840 lines (+492/-156)
9 files modified
lib/lp/app/javascript/picker/person_picker.js (+31/-137)
lib/lp/app/javascript/picker/team.js (+218/-0)
lib/lp/app/javascript/picker/tests/test_personpicker.html (+2/-0)
lib/lp/app/javascript/picker/tests/test_personpicker.js (+22/-18)
lib/lp/app/javascript/picker/tests/test_team.html (+50/-0)
lib/lp/app/javascript/picker/tests/test_team.js (+110/-0)
lib/lp/registry/browser/configure.zcml (+6/-0)
lib/lp/registry/browser/team.py (+27/-1)
lib/lp/registry/browser/tests/test_team.py (+26/-0)
To merge this branch: bzr merge lp:~wallyworld/launchpad/new-team-picker-simple-form
Reviewer Review Type Date Requested Status
Richard Harding (community) Approve
Curtis Hovey (community) code Needs Fixing
Review via email: mp+111781@code.launchpad.net

Commit message

Add simple team creation form and support for saving new team to enhanced person picker.

Description of the change

== Implementation ==

This branch makes the new team form accessible via selected person pickers functional. It renders a smaller team creation form, showing just name, displayname, visibility, subscription policy. When the details are entered and Create Team is clicked, the new team is created, the picker is closed, and the field's value is update just the same as is an existing person or team were chosen.

If there are any errors saving, eg team already exists, the form stays open and the errors are displayed as per other launchpad forms. This is done using the recently added ajax form validation infrastructure.

Part of the implementation involved ripping code out of the person_picker widget and putting it inside a new CreateTeamForm widget. This accounts for the large chunks of red in the diff.

Next step - enhance the form so that the drop down for subscription policy is implemented using a choice source popup.

== Tests ==

Add yui tests for the new team.js
Add test for new SimpleTeamAddView
Add person_picker yui tests

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/javascript/picker/person_picker.js
  lib/lp/app/javascript/picker/team.js
  lib/lp/app/javascript/picker/tests/test_personpicker.html
  lib/lp/app/javascript/picker/tests/test_personpicker.js
  lib/lp/app/javascript/picker/tests/test_team.html
  lib/lp/app/javascript/picker/tests/test_team.js
  lib/lp/registry/browser/configure.zcml
  lib/lp/registry/browser/team.py
  lib/lp/registry/browser/tests/test_team.py

To post a comment you must log in.
Revision history for this message
Richard Harding (rharding) wrote :
Download full text (3.7 KiB)

Ian, thanks for the work. In light of the recent discussions of our JS being a bit complicated and difficult to reuse, I've gotten very picky in this review. I apologize up front, but I hope that by simplifying and working on standardizing some we can make life a bit easier and point out more reusable bits as we come across code with names that match up.

The overall summary is that, while you're creating a team form specific class, that naming convention doesn't need to carry over through all of the code inside that class. It's simpler to drop it. It then starts to point out common method conventions, like render, errorHandler, etc.

Another is that there's a lot of use of Y.bind() when it's not necessary. It makes the code a bit less readable as it's carrying a lot of params for Y.bind() to function. I've written out one of the methods below to show how it could be done without any of the Y.bind() calls.

#221
    why not just stick the TEAM_CREATED and CANCEL_TEAM directly on the ns back after line 214?

#232
    In YUI there are some usual naming conventions. There's things like contentBox, etc. In 3.5 there's a standard 'container' used for things like this new_team_form. Could this be updated to be 'container' in this object?

    In a similar way can the _new_team_template just be a generic _get_template? You're in an object called CreateTeamForm so the new team bit is known and understood.

#233
    Along the same lines, we don't really need to prefix things as team_form_error_handler, but merely error_handler or maybe form_error_handler if required.

#237
    Since you've created the namespace for lp.app.picker.team and this is a form in that namespace, I'd suggest not creating a new namespace, but working off the current one. ns.form = {};
    You'd not need to store the namespace on the object instance since it's not really an instance variable. It's a namespace and global to all YUI code in this block.
    Then the #238 would just be callbacks = ns.form.callbacks;

#283
    I'd prefer for the event to just get a callback and that the event handler didn't do work for the widget. I'd move the stuff in on_success to CreateTeamForm._handle_success/_handle_failure and have the logic enclosed in them. Here it's difficult to find and test these handlers because they're defined within the _load_new_team_form method and doing too much.

    The on_success method might look something more like:
        success: function (id, response) {
            Y.Array.each(ns.form.callbacks, function (cb) {
                 cb._handle_success.call(cb, responseText);
            }
        }

    You'd not need to pass the callback method in #247, and the new _handle_success method would be able to bind events/etc. You'd only be pushing instance onto the callback stack.

        callbacks.push(this);

#314
    /render_new_team_form/render

#352
s/SubmitSpinner/Spinner

#402
    Setting a node = to form_data.id isn't clicking for me. Should it be the id of that node or is it meant to be the node itself?

#407
    None of those calls in your callback require the scope of the local instance. You can just call the methods. You can use the typical JS trick...

Read more...

review: Needs Fixing
Revision history for this message
Curtis Hovey (sinzui) wrote :

I suspect lines 512 and 680 will fail in IE 8 because it does not support nth-child(). We fixed a few cases of this recently.

There are two choices:
1. use('selector-css3') to add support for IE8
2. replace :nth-child(1) with :first-child which I think is easier to read.

review: Needs Fixing (code)
Revision history for this message
Richard Harding (rharding) wrote :

> I suspect lines 512 and 680 will fail in IE 8 because it does not support nth-
> child(). We fixed a few cases of this recently.
>
> There are two choices:
> 1. use('selector-css3') to add support for IE8
> 2. replace :nth-child(1) with :first-child which I think is easier to read.

Or perhaps it could just grab the button based on the css class. It looks like it has a class you can search instead.

Revision history for this message
Ian Booth (wallyworld) wrote :
Download full text (4.9 KiB)

Hi Rick

Thanks for the very thorough review. I do appreciate it.

>
> The overall summary is that, while you're creating a team form specific class, that naming convention doesn't need to carry over through all of the code inside that class. It's simpler to drop it. It then starts to point out common method conventions, like render, errorHandler, etc.
>

[1] I lot of the naming issues (well almost all) are because I
originally had this code inside the picker widget and moved it out into
a new, separate widget. When the code lived in it's original home, the
variable and method names made more sense.

> Another is that there's a lot of use of Y.bind() when it's not necessary. It makes the code a bit less readable as it's carrying a lot of params for Y.bind() to function. I've written out one of the methods below to show how it could be done without any of the Y.bind() calls.
>
> #221
> why not just stick the TEAM_CREATED and CANCEL_TEAM directly on the ns back after line 214?
>

I was following existing convention used in most of the lazr-js
codebase. I am guessing, but believe the reason for doing it this wy is
so that the event names can be used internally without having to
dereference off the namespace. I can see the reasoning for doing it this
way.

> #232
> In YUI there are some usual naming conventions. There's things like contentBox, etc. In 3.5 there's a standard 'container' used for things like this new_team_form. Could this be updated to be 'container' in this object?
>
> In a similar way can the _new_team_template just be a generic _get_template? You're in an object called CreateTeamForm so the new team bit is known and understood.
>

Yes, will fix. See [1].

> #233
> Along the same lines, we don't really need to prefix things as team_form_error_handler, but merely error_handler or maybe form_error_handler if required.
>

Yes, will fix. See [1].

> #237
> Since you've created the namespace for lp.app.picker.team and this is a form in that namespace, I'd suggest not creating a new namespace, but working off the current one. ns.form = {};
> You'd not need to store the namespace on the object instance since it's not really an instance variable. It's a namespace and global to all YUI code in this block.
> Then the #238 would just be callbacks = ns.form.callbacks;
>

Yes, will fix. See [1].

> #283
> I'd prefer for the event to just get a callback and that the event handler didn't do work for the widget. I'd move the stuff in on_success to CreateTeamForm._handle_success/_handle_failure and have the logic enclosed in them. Here it's difficult to find and test these handlers because they're defined within the _load_new_team_form method and doing too much.
>
> The on_success method might look something more like:
> success: function (id, response) {
> Y.Array.each(ns.form.callbacks, function (cb) {
> cb._handle_success.call(cb, responseText);
> }
> }
>
> You'd not need to pass the callback method in #247, and the new _handle_success method would be able to bind events/etc. You'd only be pushing instance onto the callback stack.
>
> ...

Read more...

Revision history for this message
Ian Booth (wallyworld) wrote :

On 26/06/12 23:55, Curtis Hovey wrote:
> Review: Needs Fixing code
>
> I suspect lines 512 and 680 will fail in IE 8 because it does not support nth-child(). We fixed a few cases of this recently.
>
> There are two choices:
> 1. use('selector-css3') to add support for IE8
> 2. replace :nth-child(1) with :first-child which I think is easier to read.
>

Ah, right you are. And nth-child is used in the picker validation stuff
too from memory. I'll fix that also.

Revision history for this message
Richard Harding (rharding) wrote :

> > #283
> > I'd prefer for the event to just get a callback and that the event
> handler didn't do work for the widget. I'd move the stuff in on_success to
> CreateTeamForm._handle_success/_handle_failure and have the logic enclosed in
> them. Here it's difficult to find and test these handlers because they're
> defined within the _load_new_team_form method and doing too much.
> >
> > The on_success method might look something more like:
> > success: function (id, response) {
> > Y.Array.each(ns.form.callbacks, function (cb) {
> > cb._handle_success.call(cb, responseText);
> > }
> > }
> >
> > You'd not need to pass the callback method in #247, and the new
> _handle_success method would be able to bind events/etc. You'd only be pushing
> instance onto the callback stack.
> >
> > callbacks.push(this);
> >
>
>
> In general I do try and keep event handlers lean and mean and move out
> the work to methods for testability. I didn't think the callbacks were
> too complicated in this case. Nevertheless, I'll fix them up.

I think the sign here was that you were sending the callback stack both the object and a method on that object to be used by the callback bits. Only one should do and you can treat it more like a list, etc.

> > #407
> > None of those calls in your callback require the scope of the local
> instance. You can just call the methods. You can use the typical JS trick of
> storing that = this; at the top of the method to aid in getting access to the
> methods themselves.
> >
>
> Till this branch I have been doing the that = this thing in my work but
> thought that the approach was a bit kludgy since my reading of the
> Y.bind docs seemed to indicate that using bind and passing in 'this'
> seemed more kosher. If you tell me it is definitely the preferred
> approach to use that = this, I'll revert to that pattern. When I've been
> doing it, I use self = this as that naming seems more logical to me.

The that = this is just standard JS. I think it goes back to JS the Good Parts book, but I'd have to go look it up. Y almost never use Y.bind and it's purely selfish. I find the that=this format easier to read, less to type, etc.

Really the one time I do use Y.bind is when the scope isn't this, but some other arbitrary object. It could be anything in there and sometimes happens I want to do some event magic with the scope of some other object I know about (combining Views and such).

> > So I'd write the _save_new_team method something like this:
> https://pastebin.canonical.com/68867/
> >
> > #606
> > Why do you wrap simulate and fire the event that way vs getting the node
> and calling node.simulate()? That's what the module node-event-simulate
> allows.
> >
>
> Most/all of our existing codebase does it that way. Perhaps it was due
> to earlier limitations in YUI. Not sure.

It might be. Perhaps it was before the node-event-simulate module.

Revision history for this message
Ian Booth (wallyworld) wrote :

I've made the requested changes, so this should be good for another look.

Revision history for this message
Richard Harding (rharding) wrote :

Thanks for the update Ian. This looks and reads much smoother. A few small items below, but the code looks good. Approving per these.

#215
There's no need for the NAME. It's never used. Just put that string into the Base.create call. The two events don't need to be defined before hand. Just do ns.TEAM_CREATED and adjust the calls in the code to use ns.

#237
There's no need for the intermediate widgets reference. It can be condensed to just:
    var perform_load = false;
    if (!Y.Lang.isArray(ns.widgets)) {
        perform_load = true;
        ns.widgets = [];
    }
    ns.widgets.push(this);
    this._load_form(perform_load);

#271
Same as above, just reference ns.team_form.

#356
It's JS convention to use the Y.Lang.isNull() check here.
https://dev.launchpad.net/JavaScriptReviewNotes?highlight=%28Y.Lang%29

#381
There's an extra indentation level.

#393
The that = this; should be hoisted to the top of the method and then used in the #378 method:

this.error_handler.showError = function (error_msg) {
    that._hideSpinner(submit_link);
    that.error_handler.handleFormValidationError(error_msg, [], []);

}

#659/660
The inputs should be self closing tags with />

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/app/javascript/picker/person_picker.js'
--- lib/lp/app/javascript/picker/person_picker.js 2012-06-26 17:24:46 +0000
+++ lib/lp/app/javascript/picker/person_picker.js 2012-07-02 04:32:06 +0000
@@ -20,48 +20,31 @@
20 }20 }
21 if (this.get('show_create_team')) {21 if (this.get('show_create_team')) {
22 // We need to provide the 'New team' link.22 // We need to provide the 'New team' link.
23 // There could be several pickers and we only want to make the XHR23 // Set up the widget to provide the form.
24 // call to get the form once. So first one gets to do the call and24 var ctns = Y.lp.app.picker.team;
25 // subsequent ones register the to be notified of the result.25 this.new_team_widget = new ctns.CreateTeamForm({
26 this.team_form_node = Y.Node.create(this._new_team_template());26 io_provider: cfg.io_provider
27 this.form_namespace = Y.namespace('lp.app.picker.teamform');27 });
28 var form_callbacks = this.form_namespace.form_callbacks;28 this.new_team_widget.subscribe(
29 var perform_load = false;29 ctns.CANCEL_TEAM, function(e) {
30 if (!Y.Lang.isArray(form_callbacks)) {30 this.hide_new_team_form();
31 perform_load = true;31 }, this);
32 form_callbacks = [];32 this.new_team_widget.subscribe(
33 this.form_namespace.form_callbacks = form_callbacks;33 ctns.TEAM_CREATED, function(e) {
34 }34 this.fire('save', e.details[0]);
35 form_callbacks.push({35 }, this);
36 picker: this,
37 callback: this._render_new_team_form});
38 this._load_new_team_form(perform_load);
39 }36 }
40 },37 },
4138
42 _new_team_template: function() {
43 return [
44 '<div class="new-team-node">',
45 '<div id=new-team-form-placeholder ',
46 'class="yui3-overlay-indicator-content">',
47 '<img src="/@@/spinner-big/">',
48 '</div>',
49 '<div class="extra-form-buttons hidden">',
50 '<button class="yes_button" type="button">',
51 'Create Team</button>',
52 '<button class="no_button" type="button">Cancel</button>',
53 '</div>',
54 '</div>',
55 '</div>'].join('');
56 },
57
58 hide: function() {39 hide: function() {
59 this.get('boundingBox').setStyle('display', 'none');40 this.get('boundingBox').setStyle('display', 'none');
60 // We want to cancel the new team form is there is one rendered.41 // We want to cancel the new team form is there is one rendered.
61 var node = this.get('contentBox').one('.new-team-node');42 if (Y.Lang.isValue(this.new_team_widget)) {
62 if (Y.Lang.isValue(node) && !node.hasClass('hidden')) {43 this.new_team_widget.hide();
63 this.hide_extra_content(node, false);44 this.hide_extra_content(
45 this.new_team_widget.get('container'), false);
64 }46 }
47
65 Y.lazr.picker.Picker.prototype.hide.call(this);48 Y.lazr.picker.Picker.prototype.hide.call(this);
66 },49 },
6750
@@ -116,97 +99,18 @@
116 });99 });
117 },100 },
118101
119 _cancel_new_team: function() {
120 var node = this.get('contentBox').one('.new-team-node');
121 this.hide_extra_content(node);
122 },
123
124 _save_new_team: function() {
125 var node = this.get('contentBox').one('.new-team-node');
126 var team_name = node.one('[id="field.name"]').get('value');
127 var team_display_name = node.one('[id="field.displayname"]')
128 .get('value');
129 this.hide_extra_content(node, false);
130 // TODO - make back end call to save team
131 var value = {
132 "api_uri": "/~" + team_name,
133 "title": team_display_name,
134 "value": team_name,
135 "metadata": "team"};
136 this.fire('validate', value);
137 },
138
139 _load_new_team_form: function (perform_load) {
140 // Load the new team form from the model using an XHR call.
141 // If perform_load is true, this is the first invocation of this method
142 // across all pickers so we do the XHR call and send the result to all
143 // registered pickers.
144 // If perform_load is false, another picker is making the XNR call and
145 // all we want to do is receive and render the preloaded_team_form.
146 // We first check though that the result hasn't arrived already.
147 var preloaded_team_form = this.form_namespace.team_form;
148 if (Y.Lang.isValue(preloaded_team_form)) {
149 this._render_new_team_form(preloaded_team_form, true);
150 return;
151 }
152 if (!perform_load) {
153 return;
154 }
155
156 var on_success = function(id, response, picker) {
157 Y.Array.each(picker.form_namespace.form_callbacks,
158 function(callback_info) {
159 Y.bind(
160 callback_info.callback, callback_info.picker,
161 response.responseText, true)();
162 });
163 picker.form_namespace.team_form = response.responseText;
164 };
165 var on_failure = function(id, response, picker) {
166 Y.Array.each(picker.form_namespace.form_callbacks,
167 function(callback_info) {
168 Y.bind(
169 callback_info.callback, callback_info.picker,
170 'Sorry, an error occurred while loading the form.',
171 false)();
172 });
173 };
174 var cfg = {
175 on: {success: on_success, failure: on_failure},
176 "arguments": this
177 };
178 var uri = Y.lp.client.get_absolute_uri('people/+newteam/++form++');
179 uri = uri.replace('api/devel', '');
180 this.get("io_provider").io(uri, cfg);
181 },
182
183 _render_new_team_form: function(form_html, show_submit) {
184 // Poke the actual team form into the DOM and wire up the save and
185 // cancel buttons.
186 this.team_form_node.one('#new-team-form-placeholder')
187 .replace(form_html);
188 var button_callback = function(e, callback_fn) {
189 e.halt();
190 if (Y.Lang.isFunction(callback_fn) ) {
191 Y.bind(callback_fn, this)();
192 }
193 };
194 var submit_button = this.team_form_node.one(".yes_button");
195 if (show_submit) {
196 submit_button.on(
197 'click', button_callback, this, this._save_new_team);
198 } else {
199 submit_button.addClass('hidden');
200 }
201 this.team_form_node.one(".no_button")
202 .on('click', button_callback, this, this._cancel_new_team);
203 this.team_form_node.one('.extra-form-buttons')
204 .removeClass('hidden');
205 },
206
207 show_new_team_form: function() {102 show_new_team_form: function() {
208 this.show_extra_content(103 var form = this.new_team_widget.get('container');
209 this.team_form_node, "Enter new team details");104 this.show_extra_content(form, "Enter new team details");
105 this.new_team_widget.show();
106 this.set('centered', true);
107 },
108
109 hide_new_team_form: function() {
110 this.new_team_widget.hide();
111 var form = this.new_team_widget.get('container');
112 this.hide_extra_content(form);
113 this.set('centered', true);
210 },114 },
211115
212 _assign_me_button_html: function() {116 _assign_me_button_html: function() {
@@ -277,18 +181,8 @@
277 remove_person_text: {value: 'Remove person'},181 remove_person_text: {value: 'Remove person'},
278 remove_team_text: {value: 'Remove team'},182 remove_team_text: {value: 'Remove team'},
279 min_search_chars: {value: 2},183 min_search_chars: {value: 2},
280 show_create_team: {value: false},184 show_create_team: {value: false}
281 new_team_template: {value: null},
282 new_team_form: {value: null},
283 /**
284 * The object that provides the io function for doing XHR requests.
285 *
286 * @attribute io_provider
287 * @type object
288 * @default Y
289 */
290 io_provider: {value: Y}
291 }185 }
292});186});
293}, "0.1", {"requires": [187}, "0.1", {"requires": [
294 "base", "node", "lazr.picker"]});188 "base", "node", "lazr.picker", "lp.app.picker.team"]});
295189
=== added file 'lib/lp/app/javascript/picker/team.js'
--- lib/lp/app/javascript/picker/team.js 1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/picker/team.js 2012-07-02 04:32:06 +0000
@@ -0,0 +1,218 @@
1/* Copyright 2012 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 *
4 * @namespace Y.app.picker.team
5 * @requires lazr.picker, lazr.person-picker
6 */
7YUI.add('lp.app.picker.team', function(Y) {
8
9var ns = Y.namespace('lp.app.picker.team');
10ns.TEAM_CREATED = 'teamCreated';
11ns.CANCEL_TEAM = 'cancelTeam';
12
13ns.CreateTeamForm = Y.Base.create("createTeamWidget", Y.Base, [], {
14 initializer: function(cfg) {
15 this.publish(ns.TEAM_CREATED);
16 this.publish(ns.CANCEL_TEAM);
17 // We need to provide the 'New team' link functionality.
18 // There could be several pickers and we only want to make the XHR
19 // call to get the form once. So first one gets to do the call and
20 // subsequent ones register the to be notified of the result.
21 this.get('container').appendChild(this._get_template());
22 this.error_handler =
23 new Y.lp.client.FormErrorHandler({
24 form: this.get('container')
25 });
26 var perform_load = false;
27 if (!Y.Lang.isArray(ns.widgets)) {
28 perform_load = true;
29 ns.widgets = [];
30 }
31 ns.widgets.push(this);
32 this._load_form(perform_load);
33 },
34
35 _get_template: function() {
36 return [
37 '<div id=form-placeholder ',
38 'class="yui3-overlay-indicator-content">',
39 '<img src="/@@/spinner-big/">',
40 '</div>',
41 '<div class="extra-form-buttons hidden">',
42 '<button class="yes_button" type="submit" ',
43 'name="field.actions.create" value="Create Team">',
44 'Create Team</button>',
45 '<button class="no_button" type="button">Cancel</button>',
46 '</div>',
47 '</div>'].join('');
48 },
49
50 _load_form: function (perform_load) {
51 // Load the new team form from the model using an XHR call.
52 // If perform_load is true, this is the first invocation of this method
53 // across all pickers so we do the XHR call and send the result to all
54 // registered pickers.
55 // If perform_load is false, another picker is making the XNR call and
56 // all we want to do is receive and render the preloaded_team_form.
57 // We first check though that the result hasn't arrived already.
58 if (Y.Lang.isValue(ns.team_form)) {
59 this.render(ns.team_form, true);
60 return;
61 }
62 if (!perform_load) {
63 return;
64 }
65
66 var on_success = function(id, response) {
67 ns.team_form = response.responseText;
68 Y.Array.each(ns.widgets,
69 function(widget) {
70 widget.render(ns.team_form, true);
71 });
72 };
73 var on_failure = function(id, response) {
74 Y.Array.each(ns.widgets,
75 function(widget) {
76 widget.render(
77 'Sorry, an error occurred while loading the form.',
78 false);
79 } );
80 };
81 var cfg = {
82 on: {success: on_success, failure: on_failure}
83 };
84 var uri = Y.lp.client.get_absolute_uri(
85 'people/+simplenewteam/++form++');
86 uri = uri.replace('api/devel', '');
87 this.get("io_provider").io(uri, cfg);
88 },
89
90 render: function(form_html, show_submit) {
91 // Poke the actual team form into the DOM and wire up the save and
92 // cancel buttons.
93 var container = this.get('container');
94 container.one('#form-placeholder').replace(form_html);
95 var submit_button = container.one(".yes_button");
96 if (show_submit) {
97 container.on('submit', function(e) {
98 e.halt();
99 this._save_new_team();
100 }, this);
101 } else {
102 submit_button.addClass('hidden');
103 }
104 container.one(".no_button")
105 .on('click', function(e) {
106 e.halt();
107 this.fire(ns.CANCEL_TEAM);
108 }, this);
109 container.one('.extra-form-buttons').removeClass('hidden');
110 },
111
112 show: function() {
113 var form_elements = this.get('container').get('elements');
114 if (form_elements.size() > 0) {
115 form_elements.item(0).focus();
116 }
117 },
118
119 hide: function() {
120 this.error_handler.clearFormErrors();
121 },
122
123 /**
124 * Show the submit spinner.
125 *
126 * @method _showSpinner
127 */
128 _showSpinner: function(submit_link) {
129 var spinner_node = Y.Node.create(
130 '<img class="spinner" src="/@@/spinner" alt="Creating..." />');
131 submit_link.insert(spinner_node, 'after');
132 },
133
134 /**
135 * Hide the submit spinner.
136 *
137 * @method _hideSpinner
138 */
139 _hideSpinner: function(submit_link) {
140 var spinner = submit_link.get('parentNode').one('.spinner');
141 if (!Y.Lang.isNull(spinner)) {
142 spinner.remove(true);
143 }
144 },
145
146 _save_team_success: function(response, team_data) {
147 var value = {
148 "api_uri": "/~" + team_data['field.name'],
149 "title": team_data['field.displayname'],
150 "value": team_data['field.name'],
151 "metadata": "team"};
152 this.fire(ns.TEAM_CREATED, value);
153 var container = this.get('container');
154 container.all('button').detachAll();
155 container.all('.spinner').remove(true);
156 container.empty();
157 container.appendChild(this._get_template());
158 this.render(ns.team_form, true);
159 },
160
161 _save_new_team: function() {
162 var that = this;
163 var submit_link = Y.one("[name='field.actions.create']");
164 this.error_handler.showError = function (error_msg) {
165 that._hideSpinner(submit_link);
166 that.error_handler.handleFormValidationError(error_msg, [], []);
167 };
168 var uri = Y.lp.client.get_absolute_uri('people/+simplenewteam');
169 uri = uri.replace('api/devel', '');
170 var form_data = {};
171 var container = this.get('container');
172 container.all("[name^='field.']").each(function(field) {
173 form_data[field.get('name')] = field.get('value');
174 });
175 form_data.id = container;
176 var y_config = {
177 method: "POST",
178 headers: {'Accept': 'application/json;'},
179 on: {
180 start: function() {
181 that.error_handler.clearFormErrors();
182 that._showSpinner(submit_link);
183 },
184 end: function () {
185 that._hideSpinner(submit_link);
186 },
187 failure: this.error_handler.getFailureHandler(),
188 success: function (id, response, team_data) {
189 that._save_team_success(response, team_data);
190 }
191 },
192 'arguments': form_data
193 };
194 y_config.form = form_data;
195 this.get("io_provider").io(uri, y_config);
196 }
197}, {
198 ATTRS: {
199 /**
200 * The form used to enter the new team details.
201 */
202 container: {
203 valueFn: function() {return Y.Node.create('<form/>');}
204 },
205 /**
206 * The object that provides the io function for doing XHR requests.
207 *
208 * @attribute io_provider
209 * @type object
210 * @default Y
211 */
212 io_provider: {value: Y}
213 }
214});
215
216
217}, "0.1", {"requires": ["base", "node"]});
218
0219
=== modified file 'lib/lp/app/javascript/picker/tests/test_personpicker.html'
--- lib/lp/app/javascript/picker/tests/test_personpicker.html 2012-06-26 00:15:11 +0000
+++ lib/lp/app/javascript/picker/tests/test_personpicker.html 2012-07-02 04:32:06 +0000
@@ -48,6 +48,8 @@
48 <script type="text/javascript"48 <script type="text/javascript"
49 src="../../../../../../build/js/lp/app/picker/picker_patcher.js"></script>49 src="../../../../../../build/js/lp/app/picker/picker_patcher.js"></script>
50 <script type="text/javascript"50 <script type="text/javascript"
51 src="../../../../../../build/js/lp/app/picker/team.js"></script>
52 <script type="text/javascript"
51 src="../../../../../../build/js/lp/app/testing/mockio.js"></script>53 src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
5254
5355
5456
=== modified file 'lib/lp/app/javascript/picker/tests/test_personpicker.js'
--- lib/lp/app/javascript/picker/tests/test_personpicker.js 2012-06-26 17:24:46 +0000
+++ lib/lp/app/javascript/picker/tests/test_personpicker.js 2012-07-02 04:32:06 +0000
@@ -2,9 +2,9 @@
2 * GNU Affero General Public License version 3 (see the file LICENSE).2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 */3 */
44
5YUI().use('test', 'console', 'plugin',5YUI().use('test', 'console', 'plugin', 'node-event-simulate',
6 'lazr.picker', 'lazr.person-picker', 'lp.app.picker',6 'lazr.picker', 'lazr.person-picker', 'lp.app.picker',
7 'lp.testing.mockio', 'node-event-simulate', function(Y) {7 'lp.app.picker.team', 'lp.testing.mockio', function(Y) {
88
9 var Assert = Y.Assert;9 var Assert = Y.Assert;
1010
@@ -89,9 +89,9 @@
8989
90 tearDown: function() {90 tearDown: function() {
91 cleanup_widget(this.picker);91 cleanup_widget(this.picker);
92 var form_namespace = Y.namespace('lp.app.picker.teamform');92 var ns = Y.namespace('lp.app.picker.team');
93 form_namespace.form_callbacks = null;93 ns.widgets = null;
94 form_namespace.team_form = null;94 ns.team_form = null;
95 delete this.mockio;95 delete this.mockio;
96 delete window.LP;96 delete window.LP;
97 },97 },
@@ -266,7 +266,7 @@
266 this.picker.render();266 this.picker.render();
267 simulate(267 simulate(
268 this.picker.get('boundingBox').one('.yui3-picker-results'),268 this.picker.get('boundingBox').one('.yui3-picker-results'),
269 'li:nth-child(1)', 'click');269 'li:first-child', 'click');
270 this._check_assign_me_button_state(false);270 this._check_assign_me_button_state(false);
271 },271 },
272272
@@ -318,7 +318,7 @@
318 this.picker.render();318 this.picker.render();
319 simulate(319 simulate(
320 this.picker.get('boundingBox').one('.yui3-picker-results'),320 this.picker.get('boundingBox').one('.yui3-picker-results'),
321 'li:nth-child(2)', 'click');321 'li + li', 'click');
322 Assert.areEqual('Remove some team', remove_button.get('innerHTML'));322 Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
323 },323 },
324324
@@ -331,9 +331,10 @@
331 },331 },
332332
333 _simple_team_form: function() {333 _simple_team_form: function() {
334 return '<table><tr><td></td>' +334 return '<table><tr><td>' +
335 '<input id="field.name">' +335 '<input id="field.name" name="field.name">' +
336 '<input id="field.displayname"></td></tr></table>';336 '<input id="field.displayname" ' +
337 'name="field.displayname"></td></tr></table>';
337 },338 },
338339
339 test_picker_new_team_xhr_calls: function() {340 test_picker_new_team_xhr_calls: function() {
@@ -387,13 +388,13 @@
387 var form_buttons = this.picker.get('contentBox')388 var form_buttons = this.picker.get('contentBox')
388 .one('.extra-form-buttons');389 .one('.extra-form-buttons');
389 simulate(390 simulate(
390 form_buttons, 'button:nth-child(2)', 'click');391 form_buttons, 'button.no_button', 'click');
391 Y.Assert.areEqual(392 Y.Assert.areEqual(
392 'Pick Someone',393 'Pick Someone',
393 this.picker.get('headerContent').get('text'));394 this.picker.get('headerContent').get('text'));
394 Y.Assert.isNotNull(395 Y.Assert.isNotNull(
395 this.picker.get('contentBox').one('input[id="field.name"]')396 this.picker.get('contentBox').one('input[id="field.name"]')
396 .ancestor('div.hidden'));397 .ancestor('form.hidden'));
397 Y.Assert.isNotNull(398 Y.Assert.isNotNull(
398 this.picker.get('contentBox').one('.yui3-picker-search'));399 this.picker.get('contentBox').one('.yui3-picker-search'));
399 },400 },
@@ -421,7 +422,10 @@
421 team_name.set('value', 'fred');422 team_name.set('value', 'fred');
422 var form_buttons = picker_content.one('.extra-form-buttons');423 var form_buttons = picker_content.one('.extra-form-buttons');
423 simulate(424 simulate(
424 form_buttons, 'button:nth-child(1)', 'click');425 form_buttons, 'button.yes_button', 'click');
426 this.mockio.success({
427 responseText: '',
428 responseHeaders: {'Content-Type': 'application/jaon'}});
425 Y.Assert.isTrue(result_published);429 Y.Assert.isTrue(result_published);
426 }430 }
427 };431 };
@@ -467,7 +471,7 @@
467 config);471 config);
468 if (params.show_create_team) {472 if (params.show_create_team) {
469 Y.Assert.areEqual(473 Y.Assert.areEqual(
470 'file:////people/+newteam/++form++',474 'file:////people/+simplenewteam/++form++',
471 this.mockio.last_request.url);475 this.mockio.last_request.url);
472 this.mockio.success({476 this.mockio.success({
473 responseText: this._simple_team_form(),477 responseText: this._simple_team_form(),
@@ -486,9 +490,9 @@
486490
487 tearDown: function() {491 tearDown: function() {
488 cleanup_widget(this.picker);492 cleanup_widget(this.picker);
489 var form_namespace = Y.namespace('lp.app.picker.teamform');493 var ns = Y.namespace('lp.app.picker.team');
490 form_namespace.form_callbacks = null;494 ns.widgets = null;
491 form_namespace.team_form = null;495 ns.team_form = null;
492 this.search_input.remove();496 this.search_input.remove();
493 delete window.LP;497 delete window.LP;
494 },498 },
@@ -523,7 +527,7 @@
523 this.vocabulary, config, associated_field_id);527 this.vocabulary, config, associated_field_id);
524 if (params.show_create_team) {528 if (params.show_create_team) {
525 Y.Assert.areEqual(529 Y.Assert.areEqual(
526 'file:////people/+newteam/++form++',530 'file:////people/+simplenewteam/++form++',
527 this.mockio.last_request.url);531 this.mockio.last_request.url);
528 this.mockio.success({532 this.mockio.success({
529 responseText: this._simple_team_form(),533 responseText: this._simple_team_form(),
530534
=== added file 'lib/lp/app/javascript/picker/tests/test_team.html'
--- lib/lp/app/javascript/picker/tests/test_team.html 1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/picker/tests/test_team.html 2012-07-02 04:32:06 +0000
@@ -0,0 +1,50 @@
1<!DOCTYPE html>
2<!--
3Copyright 2012 Canonical Ltd. This software is licensed under the
4GNU Affero General Public License version 3 (see the file LICENSE).
5-->
6
7<html>
8 <head>
9 <title>test team widget</title>
10
11 <!-- YUI and test setup -->
12 <script type="text/javascript"
13 src="../../../../../../build/js/yui/yui/yui.js">
14 </script>
15 <link rel="stylesheet"
16 href="../../../../../../build/js/yui/console/assets/console-core.css" />
17 <link rel="stylesheet"
18 href="../../../../../../build/js/yui/console/assets/skins/sam/console.css" />
19 <link rel="stylesheet"
20 href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
21
22 <script type="text/javascript"
23 src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
24
25 <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
26
27 <!-- Dependencies -->
28 <script type="text/javascript"
29 src="../../../../../../build/js/lp/app/client.js"></script>
30 <script type="text/javascript"
31 src="../../../../../../build/js/lp/app/lp.js"></script>
32 <script type="text/javascript"
33 src="../../../../../../build/js/lp/app/lazr/lazr.js"></script>
34 <script type="text/javascript"
35 src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
36
37 <!-- The module under test. -->
38 <script type="text/javascript" src="../team.js"></script>
39
40 <!-- The test suite. -->
41 <script type="text/javascript" src="test_team.js"></script>
42
43 </head>
44 <body class="yui3-skin-sam">
45 <ul id="suites">
46 <li>lp.app.picker.team.test</li>
47 </ul>
48 <div id="fixture"></div>
49 </body>
50</html>
051
=== added file 'lib/lp/app/javascript/picker/tests/test_team.js'
--- lib/lp/app/javascript/picker/tests/test_team.js 1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/picker/tests/test_team.js 2012-07-02 04:32:06 +0000
@@ -0,0 +1,110 @@
1/* Copyright 2011 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 */
4
5YUI.add('lp.app.picker.team.test', function (Y) {
6 var tests = Y.namespace('lp.app.picker.team.test');
7 tests.suite = new Y.Test.Suite(
8 'lp.app.picker.team Tests');
9
10 tests.suite.add(new Y.Test.Case({
11 name: 'lp.app.picker.team_tests',
12
13
14 setUp: function() {
15 },
16
17 tearDown: function() {
18 delete this.mockio;
19 if (this.fixture !== undefined) {
20 this.fixture.empty(true);
21 }
22 delete this.fixture;
23 var ns = Y.namespace('lp.app.picker.team');
24 ns.widgets = null;
25 ns.team_form = null;
26 },
27
28 _simple_team_form: function() {
29 return '<table><tr><td>' +
30 '<input id="field.name" name="field.name"/>' +
31 '<input id="field.displayname" ' +
32 'name="field.displayname"/></td></tr></table>';
33 },
34
35 create_widget: function() {
36 this.mockio = new Y.lp.testing.mockio.MockIo();
37 this.widget = new Y.lp.app.picker.team.CreateTeamForm({
38 "io_provider": this.mockio
39 });
40 Y.Assert.areEqual(
41 'file:////people/+simplenewteam/++form++',
42 this.mockio.last_request.url);
43 this.mockio.success({
44 responseText: this._simple_team_form(),
45 responseHeaders: {'Content-Type': 'text/html'}});
46 this.fixture = Y.one('#fixture');
47 this.fixture.appendChild(this.widget.get('container'));
48 },
49
50 test_library_exists: function () {
51 Y.Assert.isObject(Y.lp.app.picker.team,
52 "Could not locate the lp.app.team module");
53 },
54
55 test_widget_can_be_instantiated: function() {
56 this.create_widget();
57 Y.Assert.isInstanceOf(
58 Y.lp.app.picker.team.CreateTeamForm, this.widget,
59 "Create Team Form failed to be instantiated");
60 },
61
62 test_new_team_save: function() {
63 // Clicking the save button on the new team form creates the team.
64 this.create_widget();
65
66 var save_success_called = false;
67 this.widget._save_team_success = function(response, team_data) {
68 Y.Assert.areEqual('fred', team_data['field.name']);
69 save_success_called = true;
70 };
71 var team_name = Y.one("input[id='field.name']");
72 team_name.set('value', 'fred');
73 var form_buttons = Y.one('.extra-form-buttons');
74 form_buttons.one('button.yes_button').simulate('click');
75 this.mockio.success({
76 responseText: '',
77 responseHeaders: {'Content-Type': 'application/json'}});
78 Y.Assert.isTrue(save_success_called);
79 },
80
81 test_save_team_success: function() {
82 // The save team success callback publishes the expected event and
83 // clears the form.
84 this.create_widget();
85 var event_publishd = false;
86 this.widget.subscribe(Y.lp.app.picker.team.TEAM_CREATED,
87 function(e) {
88 var data = e.details[0];
89 Y.Assert.areEqual('fred', data.value);
90 Y.Assert.areEqual('Fred', data.title);
91 Y.Assert.areEqual('/~fred', data.api_uri);
92 Y.Assert.areEqual('team', data.metadata);
93 event_publishd = true;
94 });
95 var ns = Y.namespace('lp.app.picker.team');
96 ns.team_form = '<p>test</p>';
97 var team_data = {
98 'field.name': 'fred',
99 'field.displayname': 'Fred'
100 };
101 this.widget._save_team_success('', team_data);
102 Y.Assert.isTrue(event_publishd);
103 Y.Assert.areEqual('test', Y.one('form p').get('text'));
104 }
105 }));
106
107}, '0.1', {
108 'requires': ['test', 'console', 'event', 'node-event-simulate',
109 'lp.client', 'lp.testing.mockio', 'lp.app.picker.team']
110});
0111
=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml 2012-05-17 07:46:56 +0000
+++ lib/lp/registry/browser/configure.zcml 2012-07-02 04:32:06 +0000
@@ -1216,6 +1216,12 @@
1216 class="lp.registry.browser.team.TeamAddView"1216 class="lp.registry.browser.team.TeamAddView"
1217 permission="launchpad.AnyPerson"1217 permission="launchpad.AnyPerson"
1218 template="../templates/people-newteam.pt"/>1218 template="../templates/people-newteam.pt"/>
1219 <browser:page
1220 name="+simplenewteam"
1221 for="lp.registry.interfaces.person.IPersonSet"
1222 class="lp.registry.browser.team.SimpleTeamAddView"
1223 permission="launchpad.AnyPerson"
1224 template="../../app/templates/generic-edit.pt"/>
1219 <browser:pages1225 <browser:pages
1220 for="lp.registry.interfaces.person.IPersonSet"1226 for="lp.registry.interfaces.person.IPersonSet"
1221 permission="zope.Public"1227 permission="zope.Public"
12221228
=== modified file 'lib/lp/registry/browser/team.py'
--- lib/lp/registry/browser/team.py 2012-04-20 05:00:49 +0000
+++ lib/lp/registry/browser/team.py 2012-07-02 04:32:06 +0000
@@ -84,6 +84,7 @@
84from lp.app.validators.validation import validate_new_team_email84from lp.app.validators.validation import validate_new_team_email
85from lp.app.widgets.itemswidgets import (85from lp.app.widgets.itemswidgets import (
86 LabeledMultiCheckBoxWidget,86 LabeledMultiCheckBoxWidget,
87 LaunchpadDropdownWidget,
87 LaunchpadRadioWidget,88 LaunchpadRadioWidget,
88 LaunchpadRadioWidgetWithDescription,89 LaunchpadRadioWidgetWithDescription,
89 )90 )
@@ -1013,7 +1014,8 @@
1013 super(TeamAddView, self).setUpFields()1014 super(TeamAddView, self).setUpFields()
1014 self.setUpVisibilityField()1015 self.setUpVisibilityField()
10151016
1016 @action('Create Team', name='create')1017 @action('Create Team', name='create',
1018 failure=LaunchpadFormView.ajax_failure_handler)
1017 def create_action(self, action, data):1019 def create_action(self, action, data):
1018 name = data.get('name')1020 name = data.get('name')
1019 displayname = data.get('displayname')1021 displayname = data.get('displayname')
@@ -1040,6 +1042,8 @@
1040 "provider might use 'greylisting', which could delay the "1042 "provider might use 'greylisting', which could delay the "
1041 "message for up to an hour or two.)" % email)1043 "message for up to an hour or two.)" % email)
10421044
1045 if self.request.is_ajax:
1046 return ''
1043 self.next_url = canonical_url(team)1047 self.next_url = canonical_url(team)
10441048
1045 def _validateVisibilityConsistency(self, value):1049 def _validateVisibilityConsistency(self, value):
@@ -1059,6 +1063,28 @@
1059 return None1063 return None
10601064
10611065
1066class SimpleTeamAddView(TeamAddView):
1067 """View for adding a new team using a Javascript form.
1068
1069 This view is used to render a form used to create a new team. The form is
1070 displayed in a popup overlay and submission is done using an XHR call.
1071 """
1072
1073 for_input = True
1074 schema = ITeam
1075 next_url = None
1076
1077 field_names = [
1078 "name", "displayname", "visibility", "subscriptionpolicy",
1079 "teamowner"]
1080
1081 # Use a dropdown - Javascript will be used to change this to a choice
1082 # popup widget.
1083 custom_widget(
1084 'subscriptionpolicy', LaunchpadDropdownWidget,
1085 orientation='vertical')
1086
1087
1062class ProposedTeamMembersEditView(LaunchpadFormView):1088class ProposedTeamMembersEditView(LaunchpadFormView):
1063 schema = Interface1089 schema = Interface
1064 label = 'Proposed team members'1090 label = 'Proposed team members'
10651091
=== modified file 'lib/lp/registry/browser/tests/test_team.py'
--- lib/lp/registry/browser/tests/test_team.py 2012-06-13 10:17:35 +0000
+++ lib/lp/registry/browser/tests/test_team.py 2012-07-02 04:32:06 +0000
@@ -559,6 +559,32 @@
559 browser.getControl(name="field.visibility").value)559 browser.getControl(name="field.visibility").value)
560560
561561
562class TestSimpleTeamAddView(TestCaseWithFactory):
563
564 layer = LaunchpadFunctionalLayer
565 view_name = '+simplenewteam'
566
567 def test_create_team(self):
568 personset = getUtility(IPersonSet)
569 team_name = self.factory.getUniqueString()
570 form = {
571 'field.name': team_name,
572 'field.displayname': 'New Team',
573 'field.visibility': 'PRIVATE',
574 'field.subscriptionpolicy': 'RESTRICTED',
575 'field.actions.create': 'Create',
576 }
577 login_celebrity('admin')
578 create_initialized_view(
579 personset, name=self.view_name, form=form)
580 team = personset.getByName(team_name)
581 self.assertIsNotNone(team)
582 self.assertEqual('New Team', team.displayname)
583 self.assertEqual(PersonVisibility.PRIVATE, team.visibility)
584 self.assertEqual(
585 TeamSubscriptionPolicy.RESTRICTED, team.subscriptionpolicy)
586
587
562class TestTeamMenu(TestCaseWithFactory):588class TestTeamMenu(TestCaseWithFactory):
563589
564 layer = DatabaseFunctionalLayer590 layer = DatabaseFunctionalLayer