Merge lp:~wallyworld/launchpad/new-team-picker-simple-form into lp:launchpad
- new-team-picker-simple-form
- Merge into devel
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 | ||||
Related bugs: |
|
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/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
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-
2. replace :nth-child(1) with :first-child which I think is easier to read.
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-
> 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.
Ian Booth (wallyworld) wrote : | # |
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_
>
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.
>
> The on_success method might look something more like:
> success: function (id, response) {
> Y.Array.
> cb._handle_
> }
> }
>
> 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.
>
> ...
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-
> 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.
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.
> 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.
> > cb._handle_
> > }
> > }
> >
> > 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.
> >
>
>
> 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:/
> >
> > #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.
Ian Booth (wallyworld) wrote : | # |
I've made the requested changes, so this should be good for another look.
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.
ns.widgets = [];
}
ns.
this.
#271
Same as above, just reference ns.team_form.
#356
It's JS convention to use the Y.Lang.isNull() check here.
https:/
#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_
that.
that.
}
#659/660
The inputs should be self closing tags with />
Preview Diff
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 |
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 error_handler, but merely error_handler or maybe form_error_handler if required.
Along the same lines, we don't really need to prefix things as team_form_
#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 _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.
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.
The on_success method might look something more like:
Y. Array.each( ns.form. callbacks, function (cb) {
cb._ handle_ success. call(cb, responseText);
success: function (id, response) {
}
}
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.
#314 new_team_ form/render
/render_
#352 /Spinner
s/SubmitSpinner
#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...