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
1=== modified file 'lib/lp/app/javascript/picker/person_picker.js'
2--- lib/lp/app/javascript/picker/person_picker.js 2012-06-26 17:24:46 +0000
3+++ lib/lp/app/javascript/picker/person_picker.js 2012-07-02 04:32:06 +0000
4@@ -20,48 +20,31 @@
5 }
6 if (this.get('show_create_team')) {
7 // We need to provide the 'New team' link.
8- // There could be several pickers and we only want to make the XHR
9- // call to get the form once. So first one gets to do the call and
10- // subsequent ones register the to be notified of the result.
11- this.team_form_node = Y.Node.create(this._new_team_template());
12- this.form_namespace = Y.namespace('lp.app.picker.teamform');
13- var form_callbacks = this.form_namespace.form_callbacks;
14- var perform_load = false;
15- if (!Y.Lang.isArray(form_callbacks)) {
16- perform_load = true;
17- form_callbacks = [];
18- this.form_namespace.form_callbacks = form_callbacks;
19- }
20- form_callbacks.push({
21- picker: this,
22- callback: this._render_new_team_form});
23- this._load_new_team_form(perform_load);
24+ // Set up the widget to provide the form.
25+ var ctns = Y.lp.app.picker.team;
26+ this.new_team_widget = new ctns.CreateTeamForm({
27+ io_provider: cfg.io_provider
28+ });
29+ this.new_team_widget.subscribe(
30+ ctns.CANCEL_TEAM, function(e) {
31+ this.hide_new_team_form();
32+ }, this);
33+ this.new_team_widget.subscribe(
34+ ctns.TEAM_CREATED, function(e) {
35+ this.fire('save', e.details[0]);
36+ }, this);
37 }
38 },
39
40- _new_team_template: function() {
41- return [
42- '<div class="new-team-node">',
43- '<div id=new-team-form-placeholder ',
44- 'class="yui3-overlay-indicator-content">',
45- '<img src="/@@/spinner-big/">',
46- '</div>',
47- '<div class="extra-form-buttons hidden">',
48- '<button class="yes_button" type="button">',
49- 'Create Team</button>',
50- '<button class="no_button" type="button">Cancel</button>',
51- '</div>',
52- '</div>',
53- '</div>'].join('');
54- },
55-
56 hide: function() {
57 this.get('boundingBox').setStyle('display', 'none');
58 // We want to cancel the new team form is there is one rendered.
59- var node = this.get('contentBox').one('.new-team-node');
60- if (Y.Lang.isValue(node) && !node.hasClass('hidden')) {
61- this.hide_extra_content(node, false);
62+ if (Y.Lang.isValue(this.new_team_widget)) {
63+ this.new_team_widget.hide();
64+ this.hide_extra_content(
65+ this.new_team_widget.get('container'), false);
66 }
67+
68 Y.lazr.picker.Picker.prototype.hide.call(this);
69 },
70
71@@ -116,97 +99,18 @@
72 });
73 },
74
75- _cancel_new_team: function() {
76- var node = this.get('contentBox').one('.new-team-node');
77- this.hide_extra_content(node);
78- },
79-
80- _save_new_team: function() {
81- var node = this.get('contentBox').one('.new-team-node');
82- var team_name = node.one('[id="field.name"]').get('value');
83- var team_display_name = node.one('[id="field.displayname"]')
84- .get('value');
85- this.hide_extra_content(node, false);
86- // TODO - make back end call to save team
87- var value = {
88- "api_uri": "/~" + team_name,
89- "title": team_display_name,
90- "value": team_name,
91- "metadata": "team"};
92- this.fire('validate', value);
93- },
94-
95- _load_new_team_form: function (perform_load) {
96- // Load the new team form from the model using an XHR call.
97- // If perform_load is true, this is the first invocation of this method
98- // across all pickers so we do the XHR call and send the result to all
99- // registered pickers.
100- // If perform_load is false, another picker is making the XNR call and
101- // all we want to do is receive and render the preloaded_team_form.
102- // We first check though that the result hasn't arrived already.
103- var preloaded_team_form = this.form_namespace.team_form;
104- if (Y.Lang.isValue(preloaded_team_form)) {
105- this._render_new_team_form(preloaded_team_form, true);
106- return;
107- }
108- if (!perform_load) {
109- return;
110- }
111-
112- var on_success = function(id, response, picker) {
113- Y.Array.each(picker.form_namespace.form_callbacks,
114- function(callback_info) {
115- Y.bind(
116- callback_info.callback, callback_info.picker,
117- response.responseText, true)();
118- });
119- picker.form_namespace.team_form = response.responseText;
120- };
121- var on_failure = function(id, response, picker) {
122- Y.Array.each(picker.form_namespace.form_callbacks,
123- function(callback_info) {
124- Y.bind(
125- callback_info.callback, callback_info.picker,
126- 'Sorry, an error occurred while loading the form.',
127- false)();
128- });
129- };
130- var cfg = {
131- on: {success: on_success, failure: on_failure},
132- "arguments": this
133- };
134- var uri = Y.lp.client.get_absolute_uri('people/+newteam/++form++');
135- uri = uri.replace('api/devel', '');
136- this.get("io_provider").io(uri, cfg);
137- },
138-
139- _render_new_team_form: function(form_html, show_submit) {
140- // Poke the actual team form into the DOM and wire up the save and
141- // cancel buttons.
142- this.team_form_node.one('#new-team-form-placeholder')
143- .replace(form_html);
144- var button_callback = function(e, callback_fn) {
145- e.halt();
146- if (Y.Lang.isFunction(callback_fn) ) {
147- Y.bind(callback_fn, this)();
148- }
149- };
150- var submit_button = this.team_form_node.one(".yes_button");
151- if (show_submit) {
152- submit_button.on(
153- 'click', button_callback, this, this._save_new_team);
154- } else {
155- submit_button.addClass('hidden');
156- }
157- this.team_form_node.one(".no_button")
158- .on('click', button_callback, this, this._cancel_new_team);
159- this.team_form_node.one('.extra-form-buttons')
160- .removeClass('hidden');
161- },
162-
163 show_new_team_form: function() {
164- this.show_extra_content(
165- this.team_form_node, "Enter new team details");
166+ var form = this.new_team_widget.get('container');
167+ this.show_extra_content(form, "Enter new team details");
168+ this.new_team_widget.show();
169+ this.set('centered', true);
170+ },
171+
172+ hide_new_team_form: function() {
173+ this.new_team_widget.hide();
174+ var form = this.new_team_widget.get('container');
175+ this.hide_extra_content(form);
176+ this.set('centered', true);
177 },
178
179 _assign_me_button_html: function() {
180@@ -277,18 +181,8 @@
181 remove_person_text: {value: 'Remove person'},
182 remove_team_text: {value: 'Remove team'},
183 min_search_chars: {value: 2},
184- show_create_team: {value: false},
185- new_team_template: {value: null},
186- new_team_form: {value: null},
187- /**
188- * The object that provides the io function for doing XHR requests.
189- *
190- * @attribute io_provider
191- * @type object
192- * @default Y
193- */
194- io_provider: {value: Y}
195+ show_create_team: {value: false}
196 }
197 });
198 }, "0.1", {"requires": [
199- "base", "node", "lazr.picker"]});
200+ "base", "node", "lazr.picker", "lp.app.picker.team"]});
201
202=== added file 'lib/lp/app/javascript/picker/team.js'
203--- lib/lp/app/javascript/picker/team.js 1970-01-01 00:00:00 +0000
204+++ lib/lp/app/javascript/picker/team.js 2012-07-02 04:32:06 +0000
205@@ -0,0 +1,218 @@
206+/* Copyright 2012 Canonical Ltd. This software is licensed under the
207+ * GNU Affero General Public License version 3 (see the file LICENSE).
208+ *
209+ * @namespace Y.app.picker.team
210+ * @requires lazr.picker, lazr.person-picker
211+ */
212+YUI.add('lp.app.picker.team', function(Y) {
213+
214+var ns = Y.namespace('lp.app.picker.team');
215+ns.TEAM_CREATED = 'teamCreated';
216+ns.CANCEL_TEAM = 'cancelTeam';
217+
218+ns.CreateTeamForm = Y.Base.create("createTeamWidget", Y.Base, [], {
219+ initializer: function(cfg) {
220+ this.publish(ns.TEAM_CREATED);
221+ this.publish(ns.CANCEL_TEAM);
222+ // We need to provide the 'New team' link functionality.
223+ // There could be several pickers and we only want to make the XHR
224+ // call to get the form once. So first one gets to do the call and
225+ // subsequent ones register the to be notified of the result.
226+ this.get('container').appendChild(this._get_template());
227+ this.error_handler =
228+ new Y.lp.client.FormErrorHandler({
229+ form: this.get('container')
230+ });
231+ var perform_load = false;
232+ if (!Y.Lang.isArray(ns.widgets)) {
233+ perform_load = true;
234+ ns.widgets = [];
235+ }
236+ ns.widgets.push(this);
237+ this._load_form(perform_load);
238+ },
239+
240+ _get_template: function() {
241+ return [
242+ '<div id=form-placeholder ',
243+ 'class="yui3-overlay-indicator-content">',
244+ '<img src="/@@/spinner-big/">',
245+ '</div>',
246+ '<div class="extra-form-buttons hidden">',
247+ '<button class="yes_button" type="submit" ',
248+ 'name="field.actions.create" value="Create Team">',
249+ 'Create Team</button>',
250+ '<button class="no_button" type="button">Cancel</button>',
251+ '</div>',
252+ '</div>'].join('');
253+ },
254+
255+ _load_form: function (perform_load) {
256+ // Load the new team form from the model using an XHR call.
257+ // If perform_load is true, this is the first invocation of this method
258+ // across all pickers so we do the XHR call and send the result to all
259+ // registered pickers.
260+ // If perform_load is false, another picker is making the XNR call and
261+ // all we want to do is receive and render the preloaded_team_form.
262+ // We first check though that the result hasn't arrived already.
263+ if (Y.Lang.isValue(ns.team_form)) {
264+ this.render(ns.team_form, true);
265+ return;
266+ }
267+ if (!perform_load) {
268+ return;
269+ }
270+
271+ var on_success = function(id, response) {
272+ ns.team_form = response.responseText;
273+ Y.Array.each(ns.widgets,
274+ function(widget) {
275+ widget.render(ns.team_form, true);
276+ });
277+ };
278+ var on_failure = function(id, response) {
279+ Y.Array.each(ns.widgets,
280+ function(widget) {
281+ widget.render(
282+ 'Sorry, an error occurred while loading the form.',
283+ false);
284+ } );
285+ };
286+ var cfg = {
287+ on: {success: on_success, failure: on_failure}
288+ };
289+ var uri = Y.lp.client.get_absolute_uri(
290+ 'people/+simplenewteam/++form++');
291+ uri = uri.replace('api/devel', '');
292+ this.get("io_provider").io(uri, cfg);
293+ },
294+
295+ render: function(form_html, show_submit) {
296+ // Poke the actual team form into the DOM and wire up the save and
297+ // cancel buttons.
298+ var container = this.get('container');
299+ container.one('#form-placeholder').replace(form_html);
300+ var submit_button = container.one(".yes_button");
301+ if (show_submit) {
302+ container.on('submit', function(e) {
303+ e.halt();
304+ this._save_new_team();
305+ }, this);
306+ } else {
307+ submit_button.addClass('hidden');
308+ }
309+ container.one(".no_button")
310+ .on('click', function(e) {
311+ e.halt();
312+ this.fire(ns.CANCEL_TEAM);
313+ }, this);
314+ container.one('.extra-form-buttons').removeClass('hidden');
315+ },
316+
317+ show: function() {
318+ var form_elements = this.get('container').get('elements');
319+ if (form_elements.size() > 0) {
320+ form_elements.item(0).focus();
321+ }
322+ },
323+
324+ hide: function() {
325+ this.error_handler.clearFormErrors();
326+ },
327+
328+ /**
329+ * Show the submit spinner.
330+ *
331+ * @method _showSpinner
332+ */
333+ _showSpinner: function(submit_link) {
334+ var spinner_node = Y.Node.create(
335+ '<img class="spinner" src="/@@/spinner" alt="Creating..." />');
336+ submit_link.insert(spinner_node, 'after');
337+ },
338+
339+ /**
340+ * Hide the submit spinner.
341+ *
342+ * @method _hideSpinner
343+ */
344+ _hideSpinner: function(submit_link) {
345+ var spinner = submit_link.get('parentNode').one('.spinner');
346+ if (!Y.Lang.isNull(spinner)) {
347+ spinner.remove(true);
348+ }
349+ },
350+
351+ _save_team_success: function(response, team_data) {
352+ var value = {
353+ "api_uri": "/~" + team_data['field.name'],
354+ "title": team_data['field.displayname'],
355+ "value": team_data['field.name'],
356+ "metadata": "team"};
357+ this.fire(ns.TEAM_CREATED, value);
358+ var container = this.get('container');
359+ container.all('button').detachAll();
360+ container.all('.spinner').remove(true);
361+ container.empty();
362+ container.appendChild(this._get_template());
363+ this.render(ns.team_form, true);
364+ },
365+
366+ _save_new_team: function() {
367+ var that = this;
368+ var submit_link = Y.one("[name='field.actions.create']");
369+ this.error_handler.showError = function (error_msg) {
370+ that._hideSpinner(submit_link);
371+ that.error_handler.handleFormValidationError(error_msg, [], []);
372+ };
373+ var uri = Y.lp.client.get_absolute_uri('people/+simplenewteam');
374+ uri = uri.replace('api/devel', '');
375+ var form_data = {};
376+ var container = this.get('container');
377+ container.all("[name^='field.']").each(function(field) {
378+ form_data[field.get('name')] = field.get('value');
379+ });
380+ form_data.id = container;
381+ var y_config = {
382+ method: "POST",
383+ headers: {'Accept': 'application/json;'},
384+ on: {
385+ start: function() {
386+ that.error_handler.clearFormErrors();
387+ that._showSpinner(submit_link);
388+ },
389+ end: function () {
390+ that._hideSpinner(submit_link);
391+ },
392+ failure: this.error_handler.getFailureHandler(),
393+ success: function (id, response, team_data) {
394+ that._save_team_success(response, team_data);
395+ }
396+ },
397+ 'arguments': form_data
398+ };
399+ y_config.form = form_data;
400+ this.get("io_provider").io(uri, y_config);
401+ }
402+}, {
403+ ATTRS: {
404+ /**
405+ * The form used to enter the new team details.
406+ */
407+ container: {
408+ valueFn: function() {return Y.Node.create('<form/>');}
409+ },
410+ /**
411+ * The object that provides the io function for doing XHR requests.
412+ *
413+ * @attribute io_provider
414+ * @type object
415+ * @default Y
416+ */
417+ io_provider: {value: Y}
418+ }
419+});
420+
421+
422+}, "0.1", {"requires": ["base", "node"]});
423+
424
425=== modified file 'lib/lp/app/javascript/picker/tests/test_personpicker.html'
426--- lib/lp/app/javascript/picker/tests/test_personpicker.html 2012-06-26 00:15:11 +0000
427+++ lib/lp/app/javascript/picker/tests/test_personpicker.html 2012-07-02 04:32:06 +0000
428@@ -48,6 +48,8 @@
429 <script type="text/javascript"
430 src="../../../../../../build/js/lp/app/picker/picker_patcher.js"></script>
431 <script type="text/javascript"
432+ src="../../../../../../build/js/lp/app/picker/team.js"></script>
433+ <script type="text/javascript"
434 src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
435
436
437
438=== modified file 'lib/lp/app/javascript/picker/tests/test_personpicker.js'
439--- lib/lp/app/javascript/picker/tests/test_personpicker.js 2012-06-26 17:24:46 +0000
440+++ lib/lp/app/javascript/picker/tests/test_personpicker.js 2012-07-02 04:32:06 +0000
441@@ -2,9 +2,9 @@
442 * GNU Affero General Public License version 3 (see the file LICENSE).
443 */
444
445-YUI().use('test', 'console', 'plugin',
446+YUI().use('test', 'console', 'plugin', 'node-event-simulate',
447 'lazr.picker', 'lazr.person-picker', 'lp.app.picker',
448- 'lp.testing.mockio', 'node-event-simulate', function(Y) {
449+ 'lp.app.picker.team', 'lp.testing.mockio', function(Y) {
450
451 var Assert = Y.Assert;
452
453@@ -89,9 +89,9 @@
454
455 tearDown: function() {
456 cleanup_widget(this.picker);
457- var form_namespace = Y.namespace('lp.app.picker.teamform');
458- form_namespace.form_callbacks = null;
459- form_namespace.team_form = null;
460+ var ns = Y.namespace('lp.app.picker.team');
461+ ns.widgets = null;
462+ ns.team_form = null;
463 delete this.mockio;
464 delete window.LP;
465 },
466@@ -266,7 +266,7 @@
467 this.picker.render();
468 simulate(
469 this.picker.get('boundingBox').one('.yui3-picker-results'),
470- 'li:nth-child(1)', 'click');
471+ 'li:first-child', 'click');
472 this._check_assign_me_button_state(false);
473 },
474
475@@ -318,7 +318,7 @@
476 this.picker.render();
477 simulate(
478 this.picker.get('boundingBox').one('.yui3-picker-results'),
479- 'li:nth-child(2)', 'click');
480+ 'li + li', 'click');
481 Assert.areEqual('Remove some team', remove_button.get('innerHTML'));
482 },
483
484@@ -331,9 +331,10 @@
485 },
486
487 _simple_team_form: function() {
488- return '<table><tr><td></td>' +
489- '<input id="field.name">' +
490- '<input id="field.displayname"></td></tr></table>';
491+ return '<table><tr><td>' +
492+ '<input id="field.name" name="field.name">' +
493+ '<input id="field.displayname" ' +
494+ 'name="field.displayname"></td></tr></table>';
495 },
496
497 test_picker_new_team_xhr_calls: function() {
498@@ -387,13 +388,13 @@
499 var form_buttons = this.picker.get('contentBox')
500 .one('.extra-form-buttons');
501 simulate(
502- form_buttons, 'button:nth-child(2)', 'click');
503+ form_buttons, 'button.no_button', 'click');
504 Y.Assert.areEqual(
505 'Pick Someone',
506 this.picker.get('headerContent').get('text'));
507 Y.Assert.isNotNull(
508 this.picker.get('contentBox').one('input[id="field.name"]')
509- .ancestor('div.hidden'));
510+ .ancestor('form.hidden'));
511 Y.Assert.isNotNull(
512 this.picker.get('contentBox').one('.yui3-picker-search'));
513 },
514@@ -421,7 +422,10 @@
515 team_name.set('value', 'fred');
516 var form_buttons = picker_content.one('.extra-form-buttons');
517 simulate(
518- form_buttons, 'button:nth-child(1)', 'click');
519+ form_buttons, 'button.yes_button', 'click');
520+ this.mockio.success({
521+ responseText: '',
522+ responseHeaders: {'Content-Type': 'application/jaon'}});
523 Y.Assert.isTrue(result_published);
524 }
525 };
526@@ -467,7 +471,7 @@
527 config);
528 if (params.show_create_team) {
529 Y.Assert.areEqual(
530- 'file:////people/+newteam/++form++',
531+ 'file:////people/+simplenewteam/++form++',
532 this.mockio.last_request.url);
533 this.mockio.success({
534 responseText: this._simple_team_form(),
535@@ -486,9 +490,9 @@
536
537 tearDown: function() {
538 cleanup_widget(this.picker);
539- var form_namespace = Y.namespace('lp.app.picker.teamform');
540- form_namespace.form_callbacks = null;
541- form_namespace.team_form = null;
542+ var ns = Y.namespace('lp.app.picker.team');
543+ ns.widgets = null;
544+ ns.team_form = null;
545 this.search_input.remove();
546 delete window.LP;
547 },
548@@ -523,7 +527,7 @@
549 this.vocabulary, config, associated_field_id);
550 if (params.show_create_team) {
551 Y.Assert.areEqual(
552- 'file:////people/+newteam/++form++',
553+ 'file:////people/+simplenewteam/++form++',
554 this.mockio.last_request.url);
555 this.mockio.success({
556 responseText: this._simple_team_form(),
557
558=== added file 'lib/lp/app/javascript/picker/tests/test_team.html'
559--- lib/lp/app/javascript/picker/tests/test_team.html 1970-01-01 00:00:00 +0000
560+++ lib/lp/app/javascript/picker/tests/test_team.html 2012-07-02 04:32:06 +0000
561@@ -0,0 +1,50 @@
562+<!DOCTYPE html>
563+<!--
564+Copyright 2012 Canonical Ltd. This software is licensed under the
565+GNU Affero General Public License version 3 (see the file LICENSE).
566+-->
567+
568+<html>
569+ <head>
570+ <title>test team widget</title>
571+
572+ <!-- YUI and test setup -->
573+ <script type="text/javascript"
574+ src="../../../../../../build/js/yui/yui/yui.js">
575+ </script>
576+ <link rel="stylesheet"
577+ href="../../../../../../build/js/yui/console/assets/console-core.css" />
578+ <link rel="stylesheet"
579+ href="../../../../../../build/js/yui/console/assets/skins/sam/console.css" />
580+ <link rel="stylesheet"
581+ href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
582+
583+ <script type="text/javascript"
584+ src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
585+
586+ <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
587+
588+ <!-- Dependencies -->
589+ <script type="text/javascript"
590+ src="../../../../../../build/js/lp/app/client.js"></script>
591+ <script type="text/javascript"
592+ src="../../../../../../build/js/lp/app/lp.js"></script>
593+ <script type="text/javascript"
594+ src="../../../../../../build/js/lp/app/lazr/lazr.js"></script>
595+ <script type="text/javascript"
596+ src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
597+
598+ <!-- The module under test. -->
599+ <script type="text/javascript" src="../team.js"></script>
600+
601+ <!-- The test suite. -->
602+ <script type="text/javascript" src="test_team.js"></script>
603+
604+ </head>
605+ <body class="yui3-skin-sam">
606+ <ul id="suites">
607+ <li>lp.app.picker.team.test</li>
608+ </ul>
609+ <div id="fixture"></div>
610+ </body>
611+</html>
612
613=== added file 'lib/lp/app/javascript/picker/tests/test_team.js'
614--- lib/lp/app/javascript/picker/tests/test_team.js 1970-01-01 00:00:00 +0000
615+++ lib/lp/app/javascript/picker/tests/test_team.js 2012-07-02 04:32:06 +0000
616@@ -0,0 +1,110 @@
617+/* Copyright 2011 Canonical Ltd. This software is licensed under the
618+ * GNU Affero General Public License version 3 (see the file LICENSE).
619+ */
620+
621+YUI.add('lp.app.picker.team.test', function (Y) {
622+ var tests = Y.namespace('lp.app.picker.team.test');
623+ tests.suite = new Y.Test.Suite(
624+ 'lp.app.picker.team Tests');
625+
626+ tests.suite.add(new Y.Test.Case({
627+ name: 'lp.app.picker.team_tests',
628+
629+
630+ setUp: function() {
631+ },
632+
633+ tearDown: function() {
634+ delete this.mockio;
635+ if (this.fixture !== undefined) {
636+ this.fixture.empty(true);
637+ }
638+ delete this.fixture;
639+ var ns = Y.namespace('lp.app.picker.team');
640+ ns.widgets = null;
641+ ns.team_form = null;
642+ },
643+
644+ _simple_team_form: function() {
645+ return '<table><tr><td>' +
646+ '<input id="field.name" name="field.name"/>' +
647+ '<input id="field.displayname" ' +
648+ 'name="field.displayname"/></td></tr></table>';
649+ },
650+
651+ create_widget: function() {
652+ this.mockio = new Y.lp.testing.mockio.MockIo();
653+ this.widget = new Y.lp.app.picker.team.CreateTeamForm({
654+ "io_provider": this.mockio
655+ });
656+ Y.Assert.areEqual(
657+ 'file:////people/+simplenewteam/++form++',
658+ this.mockio.last_request.url);
659+ this.mockio.success({
660+ responseText: this._simple_team_form(),
661+ responseHeaders: {'Content-Type': 'text/html'}});
662+ this.fixture = Y.one('#fixture');
663+ this.fixture.appendChild(this.widget.get('container'));
664+ },
665+
666+ test_library_exists: function () {
667+ Y.Assert.isObject(Y.lp.app.picker.team,
668+ "Could not locate the lp.app.team module");
669+ },
670+
671+ test_widget_can_be_instantiated: function() {
672+ this.create_widget();
673+ Y.Assert.isInstanceOf(
674+ Y.lp.app.picker.team.CreateTeamForm, this.widget,
675+ "Create Team Form failed to be instantiated");
676+ },
677+
678+ test_new_team_save: function() {
679+ // Clicking the save button on the new team form creates the team.
680+ this.create_widget();
681+
682+ var save_success_called = false;
683+ this.widget._save_team_success = function(response, team_data) {
684+ Y.Assert.areEqual('fred', team_data['field.name']);
685+ save_success_called = true;
686+ };
687+ var team_name = Y.one("input[id='field.name']");
688+ team_name.set('value', 'fred');
689+ var form_buttons = Y.one('.extra-form-buttons');
690+ form_buttons.one('button.yes_button').simulate('click');
691+ this.mockio.success({
692+ responseText: '',
693+ responseHeaders: {'Content-Type': 'application/json'}});
694+ Y.Assert.isTrue(save_success_called);
695+ },
696+
697+ test_save_team_success: function() {
698+ // The save team success callback publishes the expected event and
699+ // clears the form.
700+ this.create_widget();
701+ var event_publishd = false;
702+ this.widget.subscribe(Y.lp.app.picker.team.TEAM_CREATED,
703+ function(e) {
704+ var data = e.details[0];
705+ Y.Assert.areEqual('fred', data.value);
706+ Y.Assert.areEqual('Fred', data.title);
707+ Y.Assert.areEqual('/~fred', data.api_uri);
708+ Y.Assert.areEqual('team', data.metadata);
709+ event_publishd = true;
710+ });
711+ var ns = Y.namespace('lp.app.picker.team');
712+ ns.team_form = '<p>test</p>';
713+ var team_data = {
714+ 'field.name': 'fred',
715+ 'field.displayname': 'Fred'
716+ };
717+ this.widget._save_team_success('', team_data);
718+ Y.Assert.isTrue(event_publishd);
719+ Y.Assert.areEqual('test', Y.one('form p').get('text'));
720+ }
721+ }));
722+
723+}, '0.1', {
724+ 'requires': ['test', 'console', 'event', 'node-event-simulate',
725+ 'lp.client', 'lp.testing.mockio', 'lp.app.picker.team']
726+});
727
728=== modified file 'lib/lp/registry/browser/configure.zcml'
729--- lib/lp/registry/browser/configure.zcml 2012-05-17 07:46:56 +0000
730+++ lib/lp/registry/browser/configure.zcml 2012-07-02 04:32:06 +0000
731@@ -1216,6 +1216,12 @@
732 class="lp.registry.browser.team.TeamAddView"
733 permission="launchpad.AnyPerson"
734 template="../templates/people-newteam.pt"/>
735+ <browser:page
736+ name="+simplenewteam"
737+ for="lp.registry.interfaces.person.IPersonSet"
738+ class="lp.registry.browser.team.SimpleTeamAddView"
739+ permission="launchpad.AnyPerson"
740+ template="../../app/templates/generic-edit.pt"/>
741 <browser:pages
742 for="lp.registry.interfaces.person.IPersonSet"
743 permission="zope.Public"
744
745=== modified file 'lib/lp/registry/browser/team.py'
746--- lib/lp/registry/browser/team.py 2012-04-20 05:00:49 +0000
747+++ lib/lp/registry/browser/team.py 2012-07-02 04:32:06 +0000
748@@ -84,6 +84,7 @@
749 from lp.app.validators.validation import validate_new_team_email
750 from lp.app.widgets.itemswidgets import (
751 LabeledMultiCheckBoxWidget,
752+ LaunchpadDropdownWidget,
753 LaunchpadRadioWidget,
754 LaunchpadRadioWidgetWithDescription,
755 )
756@@ -1013,7 +1014,8 @@
757 super(TeamAddView, self).setUpFields()
758 self.setUpVisibilityField()
759
760- @action('Create Team', name='create')
761+ @action('Create Team', name='create',
762+ failure=LaunchpadFormView.ajax_failure_handler)
763 def create_action(self, action, data):
764 name = data.get('name')
765 displayname = data.get('displayname')
766@@ -1040,6 +1042,8 @@
767 "provider might use 'greylisting', which could delay the "
768 "message for up to an hour or two.)" % email)
769
770+ if self.request.is_ajax:
771+ return ''
772 self.next_url = canonical_url(team)
773
774 def _validateVisibilityConsistency(self, value):
775@@ -1059,6 +1063,28 @@
776 return None
777
778
779+class SimpleTeamAddView(TeamAddView):
780+ """View for adding a new team using a Javascript form.
781+
782+ This view is used to render a form used to create a new team. The form is
783+ displayed in a popup overlay and submission is done using an XHR call.
784+ """
785+
786+ for_input = True
787+ schema = ITeam
788+ next_url = None
789+
790+ field_names = [
791+ "name", "displayname", "visibility", "subscriptionpolicy",
792+ "teamowner"]
793+
794+ # Use a dropdown - Javascript will be used to change this to a choice
795+ # popup widget.
796+ custom_widget(
797+ 'subscriptionpolicy', LaunchpadDropdownWidget,
798+ orientation='vertical')
799+
800+
801 class ProposedTeamMembersEditView(LaunchpadFormView):
802 schema = Interface
803 label = 'Proposed team members'
804
805=== modified file 'lib/lp/registry/browser/tests/test_team.py'
806--- lib/lp/registry/browser/tests/test_team.py 2012-06-13 10:17:35 +0000
807+++ lib/lp/registry/browser/tests/test_team.py 2012-07-02 04:32:06 +0000
808@@ -559,6 +559,32 @@
809 browser.getControl(name="field.visibility").value)
810
811
812+class TestSimpleTeamAddView(TestCaseWithFactory):
813+
814+ layer = LaunchpadFunctionalLayer
815+ view_name = '+simplenewteam'
816+
817+ def test_create_team(self):
818+ personset = getUtility(IPersonSet)
819+ team_name = self.factory.getUniqueString()
820+ form = {
821+ 'field.name': team_name,
822+ 'field.displayname': 'New Team',
823+ 'field.visibility': 'PRIVATE',
824+ 'field.subscriptionpolicy': 'RESTRICTED',
825+ 'field.actions.create': 'Create',
826+ }
827+ login_celebrity('admin')
828+ create_initialized_view(
829+ personset, name=self.view_name, form=form)
830+ team = personset.getByName(team_name)
831+ self.assertIsNotNone(team)
832+ self.assertEqual('New Team', team.displayname)
833+ self.assertEqual(PersonVisibility.PRIVATE, team.visibility)
834+ self.assertEqual(
835+ TeamSubscriptionPolicy.RESTRICTED, team.subscriptionpolicy)
836+
837+
838 class TestTeamMenu(TestCaseWithFactory):
839
840 layer = DatabaseFunctionalLayer