Merge lp:~wallyworld/launchpad/inline-multicheckbox-widget into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Aaron Bentley
Approved revision: no longer in the source branch.
Merged at revision: 12646
Proposed branch: lp:~wallyworld/launchpad/inline-multicheckbox-widget
Merge into: lp:launchpad
Diff against target: 885 lines (+803/-2)
9 files modified
lib/canonical/launchpad/icing/style-3-0.css.in (+7/-0)
lib/lp/app/browser/lazrjs.py (+113/-1)
lib/lp/app/browser/tests/test_inlinemulticheckboxwidget.py (+82/-0)
lib/lp/app/doc/lazr-js-widgets.txt (+92/-0)
lib/lp/app/javascript/multicheckbox.js (+232/-0)
lib/lp/app/javascript/tests/test_multicheckboxwidget.html (+42/-0)
lib/lp/app/javascript/tests/test_multicheckboxwidget.js (+184/-0)
lib/lp/app/templates/inline-multicheckbox-widget.pt (+50/-0)
versions.cfg (+1/-1)
To merge this branch: bzr merge lp:~wallyworld/launchpad/inline-multicheckbox-widget
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+52943@code.launchpad.net

Commit message

Add a multicheckbox widget which will PATCH a given attribute on a given resource.

Description of the change

Add a multicheckbox widget which will PATCH a given attribute on a given resource.

== Implementation ==

The widget allows the user to select one of more checkboxes derived from a vocabulary. The vocab is either comes from the field definition of the attribute being edited or can be user specified. When the widget is first rendered, it displays as text the currently selected values. When the user chooses to edit the attribute, a popup displays all available choices with the currently selected items ticked. The user can save/cancel using the tick/cross buttons in the top right of the popup, or click outside the popup to cancel. If the user chooses to save, the widget uses the lp client infrastructure to patch the attribute value on the resource with the new data and re-renders the widget value.

This branch provides the base widget implementation. The lp:~wallyworld/launchpad/inline-recipe-distro-series-edit branch uses the widget to edit the distroseries attribute of a source package recipe.

This branch requires a newer version (rev 206) of lazr-js since it relies on an improvement made to the Activator class.

== Demo and QA ==

A screenshot showing the placement of the edit button:
http://people.canonical.com/~ianb/distroseries-checkbox.png
The edit button is placed to the right of the attribute label.

A screenshot of the widget in action:
http://people.canonical.com/~ianb/distroseries-popup.png

== Tests ==

Added new yui tests for the javascript used by the widget:
lp/app/javascript/tests/test_multicheckboxwidget.js
lp/app/javascript/tests/test_multicheckboxwidget.html

Add new tests for the widget class setup:
lp/app/browser/tests/test_inlinemulticheckboxwidget.py
  test_items_for_field_vocabulary
  test_items_for_custom_vocabulary
  test_items_for_custom_vocabulary_name
  test_selected_items_checked

Add new documentation/tests to lazr-js-widgets.txt

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/icing/style-3-0.css.in
  lib/lp/app/browser/lazrjs.py
  lib/lp/app/browser/tests/test_inlinemulticheckboxwidget.py
  lib/lp/app/javascript/multicheckbox.js
  lib/lp/app/javascript/tests/test_multicheckboxwidget.html
  lib/lp/app/javascript/tests/test_multicheckboxwidget.js
  lib/lp/app/templates/inline-multicheckbox-widget.pt

./lib/canonical/launchpad/icing/style-3-0.css.in

./lib/lp/app/browser/lazrjs.py
     248: E501 line too long (80 characters)
     495: E501 line too long (80 characters)
     548: E501 line too long (80 characters)
     248: Line exceeds 78 characters.
     495: Line exceeds 78 characters.
     548: Line exceeds 78 characters.
./lib/lp/app/templates/inline-multicheckbox-widget.pt
       1: unbound prefix

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

This looks like useful functionality.

Were you aware the lp_save will detect races where the value has changed since being read? If so, why did you choose to forgo that protection by doing a raw patch?

How are logged-out users handled? Usually, we show them the edit icon, and require them to log in when they click it.

Did you consider accepting "linkify" as a boolean parameter rather than "attribute_type"? Seems simpler, and it's always hard to predict future extensions. You might even be able to eliminate "attribute_type" by calling canonical_url unconditionally and handling exceptions.

I think you could shorten your code by sticking your json attributes in a single dict, and accepting such a dict as the input to addMultiCheckboxPatcher would be perfectly consistent with YUI3 parameter style.

Usually, we indent by four spaces, but there are a bunch of places where you've done more, including: 94, 133, 194, 413, 514-517, 527, 535-537, 539-540, 652-653, 781, 792.

I don't know if we have a style recommendation for how many blank lines to spaces to use at the top level, but if we don't, I suggest using the python style of two blank lines.

According to deryck, who just tested on my behalf, function names are scoped to the closure by default, so I think "foo function()" is better than "var foo = function()". Is there a reason why you're using the latter?

You seem a bit inconsistent about spaces when using "+" for string concatenation. I suggest using a space, e.g. "foo" + "bar", not "foo"+"bar". This is more like python style, and I find it more readable.

On a related note, CHECKBOX_TEMPLATE should use the [str1, str2, ...].join("") form per https://dev.launchpad.net/JavaScriptReviewNotes

The expected_item generation in test_items_for_field_vocabulary, test_items_for_custom_vocabulary and test_items_for_custom_vocabulary_name looks duplicated, and should be extracted to a helper.

review: Needs Information
Revision history for this message
Ian Booth (wallyworld) wrote :
Download full text (3.9 KiB)

> This looks like useful functionality.
>
> Were you aware the lp_save will detect races where the value has changed since
> being read? If so, why did you choose to forgo that protection by doing a raw
> patch?
>

I didn't know that. I based that bit of the code on what was already done in
lp/app/javascript/picker.js, which uses client.patch().
I'll look at changing over to lp_save as see how that works out.

> How are logged-out users handled? Usually, we show them the edit icon, and
> require them to log in when they click it.
>

Yes, that's what happens here too.

> Did you consider accepting "linkify" as a boolean parameter rather than
> "attribute_type"? Seems simpler, and it's always hard to predict future
> extensions. You might even be able to eliminate "attribute_type" by calling
> canonical_url unconditionally and handling exceptions.
>

I considered it but thought it was too restrictive. I agree it's hard to predict the future, but passing in a "type" value defers the decision to what to do with it to the implementing code. It allows other possible behaviours based on attribute type to be transparently implemented. Having a linkify boolean exposes a specific implementation detail to the caller and precludes future work without requiring an api change.

The second idea - to call canonical_url unconditionally and handle exceptions - I also considered and it was 50/50 which approach I went with. I tend to steer away from using exceptions in this way though. To me they are to communicate errors not probe for functionality etc. I settled for the attribute_type parameter to allow for future flexibility but if you feel its a case of YAGNI, I'll happily use the exception approach. I do think the attribute_type approach is a bit ugly but it was the lesser of 2 evils for me.

> I think you could shorten your code by sticking your json attributes in a
> single dict, and accepting such a dict as the input to addMultiCheckboxPatcher
> would be perfectly consistent with YUI3 parameter style.
>

I used the same approach as already used for InlineEditPickerWidget in going with the individual parameters. Note that one of those parameters passed to addMultiCheckboxPatcher() and also addPickerPatcher() in the InlineEditPickerWidget case is actually a dict of config values. I'll find out the rational there and consider applying any improvements to both widgets so they are implemented consistently.

> Usually, we indent by four spaces, but there are a bunch of places where
> you've done more, including: 94, 133, 194, 413, 514-517, 527, 535-537,
> 539-540, 652-653, 781, 792.
>

Yeah, it's a personal preference - I prefer to try and keep the method parameters close to the method name instead of all the way over to the left. I'll fix them.

> I don't know if we have a style recommendation for how many blank lines to
> spaces to use at the top level, but if we don't, I suggest using the python
> style of two blank lines.
>

Ok.

> According to deryck, who just tested on my behalf, function names are scoped
> to the closure by default, so I think "foo function()" is better than "var foo
> = function()". Is there a reason why you're using th...

Read more...

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

I had another look at the javascript infrastructure....

> > Were you aware the lp_save will detect races where the value has changed
> since
> > being read? If so, why did you choose to forgo that protection by doing a
> raw
> > patch?
> >
>
> I didn't know that. I based that bit of the code on what was already done in
> lp/app/javascript/picker.js, which uses client.patch().
> I'll look at changing over to lp_save as see how that works out.
>

AFAICT, the lp_save is designed to be used in cases where the entire object being edited is modified. It relies on onchange events being used to mark attributes as dirty and calls patch() with the entire set of dirty attributes. Here. we are editing a single attribute - "distroseries" - but there is no distroseries DOM node as such that is called "distroseries", hence the dirty detection doesn't work. The current approach using patch() seems to be the correct one given this use case. And I think the web services infrastructure used to do the patch handles the etag stuff too.

> > How are logged-out users handled? Usually, we show them the edit icon, and
> > require them to log in when they click it.
> >
>
> Yes, that's what happens here too.
>

Actually, what happens for this new widget is the same as for the existing pickers - the edit button is not rendered if the user is not logged in.

Revision history for this message
Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

On 11-03-16 09:15 PM, Ian Booth wrote:
>> Did you consider accepting "linkify" as a boolean parameter rather than
>> "attribute_type"? Seems simpler, and it's always hard to predict future
>> extensions. You might even be able to eliminate "attribute_type" by calling
>> canonical_url unconditionally and handling exceptions.
>>
>
> I considered it but thought it was too restrictive. I agree it's hard to predict the future, but passing in a "type" value defers the decision to what to do with it to the implementing code. It allows other possible behaviours based on attribute type to be transparently implemented. Having a linkify boolean exposes a specific implementation detail to the caller and precludes future work without requiring an api change.
>
> The second idea - to call canonical_url unconditionally and handle exceptions - I also considered and it was 50/50 which approach I went with. I tend to steer away from using exceptions in this way though. To me they are to communicate errors not probe for functionality etc. I settled for the attribute_type parameter to allow for future flexibility but if you feel its a case of YAGNI, I'll happily use the exception approach. I do think the attribute_type approach is a bit ugly but it was the lesser of 2 evils for me.

I think it's a YAGNI, but I don't insist that you change it.

>> I think you could shorten your code by sticking your json attributes in a
>> single dict, and accepting such a dict as the input to addMultiCheckboxPatcher
>> would be perfectly consistent with YUI3 parameter style.
>>
>
> I used the same approach as already used for InlineEditPickerWidget in going with the individual parameters. Note that one of those parameters passed to addMultiCheckboxPatcher() and also addPickerPatcher() in the InlineEditPickerWidget case is actually a dict of config values. I'll find out the rational there and consider applying any improvements to both widgets so they are implemented consistently.

"config" in YUI3 seems to be a pretty loose term. They tend to use it
the same way python uses keyword arguments.

>> According to deryck, who just tested on my behalf, function names are scoped
>> to the closure by default, so I think "foo function()" is better than "var foo
>> = function()". Is there a reason why you're using the latter?
>>
>
> Depends on what existing code I was grepping to provide guidance on what to do. I tended to follow what had gone before.

I've been following what went before too, but the code I looked at used
named functions, so our existing code is probably inconsistent.

Aaron
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.10 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org/

iEYEARECAAYFAk2BfHUACgkQ0F+nu1YWqI0HvwCeMZVaCxkYCp7HeHhh7NArKIR5
m74AnjQv1VmpkonwN+rwMYkNO+a3Kx3L
=k9pc
-----END PGP SIGNATURE-----

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

>
> >> I think you could shorten your code by sticking your json attributes in a
> >> single dict, and accepting such a dict as the input to
> addMultiCheckboxPatcher
> >> would be perfectly consistent with YUI3 parameter style.
> >>
> >
> > I used the same approach as already used for InlineEditPickerWidget in going
> with the individual parameters. Note that one of those parameters passed to
> addMultiCheckboxPatcher() and also addPickerPatcher() in the
> InlineEditPickerWidget case is actually a dict of config values. I'll find out
> the rational there and consider applying any improvements to both widgets so
> they are implemented consistently.
>
> "config" in YUI3 seems to be a pretty loose term. They tend to use it
> the same way python uses keyword arguments.
>

I did some more digging and I think what's there now is appropriate. The addMultiCheckboxPatcher() function takes a combination of explicit parameters and a config dict(). The parameters are used by the addMultiCheckboxPatcher function itself to set things up. addMultiCheckboxPatcher is not a widget per se, but it does construct a widget to do the actual work, in this case PrettyOverlay. The config parameter passed to addMultiCheckboxPatcher is in turn augmented with additional attribute values and then passed to the constructor of PrettyOverlay in the manner expected for yui widgets.

So I think we should stick with the current implementation given the nature of the code. The other requested changes have been made.

Revision history for this message
Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

On 11-03-16 10:55 PM, Ian Booth wrote:
> AFAICT, the lp_save is designed to be used in cases where the entire object being edited is modified.

My belief, too.

> It relies on onchange events being used to mark attributes as dirty and calls patch() with the entire set of dirty attributes.

Okay.

> Here. we are editing a single attribute - "distroseries" - but there is no distroseries DOM node as such that is called "distroseries", hence the dirty detection doesn't work.

The web service doesn't deal in DOM nodes AFAIK. It deals in json
structures and Entities, and such.

But I guess you mean that there's no attribute called "distroseries" in
the API, rather a distroseries_collection_link?

Anyhow, are you saying that lp_save doesn't work with collections at all?

> The current approach using patch() seems to be the correct one given this use case.
> And I think the web services infrastructure used to do the patch
handles the etag stuff too.

The etag stuff was what I was talking about. I can't see anything
handling them, and I don't know how it would acquire information about
the previous etag of the distroseries_collection_link.

That said, if this is the only way that works, then clearly it's the
right way.

 status approved

Aaron
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.10 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org/

iEYEARECAAYFAk2CYDAACgkQ0F+nu1YWqI3LSACeI6IlWwYHeFrUXJrTuV9smh97
BdYAmQGbcmCbxeCJSHDi6vwHMugVIiEf
=gWkA
-----END PGP SIGNATURE-----

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/icing/style-3-0.css.in'
2--- lib/canonical/launchpad/icing/style-3-0.css.in 2011-03-08 00:40:50 +0000
3+++ lib/canonical/launchpad/icing/style-3-0.css.in 2011-03-18 07:43:37 +0000
4@@ -791,6 +791,13 @@
5 button {
6 padding:0;
7 }
8+button.overlay-close-button {
9+ float: right;
10+ width: 15px;
11+ height: 15px;
12+ display: block;
13+ margin-top: 4px;
14+ }
15 .fieldRequired, .fieldOptional {
16 color: #999;
17 }
18
19=== modified file 'lib/lp/app/browser/lazrjs.py'
20--- lib/lp/app/browser/lazrjs.py 2011-03-10 01:25:13 +0000
21+++ lib/lp/app/browser/lazrjs.py 2011-03-18 07:43:37 +0000
22@@ -8,6 +8,7 @@
23 'BooleanChoiceWidget',
24 'EnumChoiceWidget',
25 'InlineEditPickerWidget',
26+ 'InlineMultiCheckboxWidget',
27 'standard_text_html_representation',
28 'TextAreaEditorWidget',
29 'TextLineEditorWidget',
30@@ -19,7 +20,10 @@
31 from zope.app.pagetemplate.viewpagetemplatefile import ViewPageTemplateFile
32 from zope.component import getUtility
33 from zope.security.checker import canAccess, canWrite
34-from zope.schema.interfaces import IVocabulary
35+from zope.schema.interfaces import (
36+ ICollection,
37+ IVocabulary,
38+ )
39 from zope.schema.vocabulary import getVocabularyRegistry
40
41 from lazr.enum import IEnumeratedType
42@@ -301,6 +305,114 @@
43 return user and user in vocabulary
44
45
46+class InlineMultiCheckboxWidget(WidgetBase):
47+ """Wrapper for the lazr-js multicheckbox widget."""
48+
49+ __call__ = ViewPageTemplateFile(
50+ '../templates/inline-multicheckbox-widget.pt')
51+
52+ def __init__(self, context, exported_field,
53+ label, label_tag="span", attribute_type="default",
54+ vocabulary=None, header=None,
55+ empty_display_value="None", selected_items=list(),
56+ items_tag="span", items_style='',
57+ content_box_id=None, edit_view="+edit", edit_url=None,
58+ edit_title=''):
59+ """Create a widget wrapper.
60+
61+ :param context: The object that is being edited.
62+ :param exported_field: The attribute being edited. This should be
63+ a field from an interface of the form ISomeInterface['fieldname']
64+ :param label: The label text to display above the checkboxes
65+ :param label_tag: The tag in which to wrap the label text.
66+ :param attribute_type: The attribute type. Currently only "reference"
67+ is supported. Used to determine whether to linkify the selected
68+ checkbox item values. So ubuntu/hoary becomes
69+ http://launchpad.net/devel/api/ubuntu/hoary
70+ :param vocabulary: The name of the vocabulary which provides the
71+ items or a vocabulary instance.
72+ :param header: The text to display as the title of the popup form.
73+ :param empty_display_value: The text to display if no items are
74+ selected.
75+ :param selected_items: The currently selected items.
76+ :param items_tag: The tag in which to wrap the items checkboxes.
77+ :param items_style: The css style to use for each item checkbox.
78+ :param content_box_id: The HTML id to use for this widget.
79+ Automatically generated if this is not provided.
80+ :param edit_view: The view name to use to generate the edit_url if
81+ one is not specified.
82+ :param edit_url: The URL to use for editing when the user isn't logged
83+ in and when JS is off. Defaults to the edit_view on the context.
84+ :param edit_title: Used to set the title attribute of the anchor.
85+
86+ """
87+ super(InlineMultiCheckboxWidget, self).__init__(
88+ context, exported_field, content_box_id,
89+ edit_view, edit_url, edit_title)
90+
91+ linkify_items = attribute_type == "reference"
92+
93+ if header is None:
94+ header = self.exported_field.title+":"
95+ self.header = header,
96+ self.empty_display_value = empty_display_value
97+ self.label = label
98+ self.label_open_tag = "<%s>" % label_tag
99+ self.label_close_tag = "</%s>" % label_tag
100+ self.items = selected_items
101+ self.items_open_tag = ("<%s id='%s'>" %
102+ (items_tag, self.content_box_id+"-items"))
103+ self.items_close_tag = "</%s>" % items_tag
104+ self.linkify_items = linkify_items
105+
106+ if vocabulary is None:
107+ if ICollection.providedBy(exported_field):
108+ vocabulary = exported_field.value_type.vocabularyName
109+ else:
110+ vocabulary = exported_field.vocabularyName
111+
112+
113+ if isinstance(vocabulary, basestring):
114+ vocabulary = getVocabularyRegistry().get(context, vocabulary)
115+
116+ # Construct checkbox data dict for each item in the vocabulary.
117+ items = []
118+ style = ';'.join(['font-weight: normal', items_style])
119+ for item in vocabulary:
120+ item_value = item.value if safe_hasattr(item, 'value') else item
121+ checked = item_value in selected_items
122+ if linkify_items:
123+ save_value = canonical_url(item_value, force_local_path=True)
124+ else:
125+ save_value = item_value.name
126+ new_item = {
127+ 'name': item.title,
128+ 'token': item.token,
129+ 'style': style,
130+ 'checked': checked,
131+ 'value': save_value}
132+ items.append(new_item)
133+ self.has_choices = len(items)
134+
135+ # JSON encoded attributes.
136+ self.json_content_box_id = simplejson.dumps(self.content_box_id)
137+ self.json_attribute = simplejson.dumps(self.api_attribute)
138+ self.json_attribute_type = simplejson.dumps(attribute_type)
139+ self.json_items = simplejson.dumps(items)
140+ self.json_description = simplejson.dumps(
141+ self.exported_field.description)
142+
143+ @property
144+ def config(self):
145+ return dict(
146+ header=self.header,
147+ )
148+
149+ @property
150+ def json_config(self):
151+ return simplejson.dumps(self.config)
152+
153+
154 def vocabulary_to_choice_edit_items(
155 vocab, css_class_prefix=None, disabled_items=None, as_json=False,
156 name_fn=None, value_fn=None):
157
158=== added file 'lib/lp/app/browser/tests/test_inlinemulticheckboxwidget.py'
159--- lib/lp/app/browser/tests/test_inlinemulticheckboxwidget.py 1970-01-01 00:00:00 +0000
160+++ lib/lp/app/browser/tests/test_inlinemulticheckboxwidget.py 2011-03-18 07:43:37 +0000
161@@ -0,0 +1,82 @@
162+# Copyright 2010 Canonical Ltd. This software is licensed under the
163+# GNU Affero General Public License version 3 (see the file LICENSE).
164+
165+"""Tests for the InlineMultiCheckboxWidget."""
166+
167+__metaclass__ = type
168+
169+import simplejson
170+
171+from zope.interface import Interface
172+from zope.schema import List
173+from zope.schema._field import Choice
174+from zope.schema.vocabulary import getVocabularyRegistry
175+
176+from lazr.enum import EnumeratedType, Item
177+
178+from canonical.launchpad.webapp.publisher import canonical_url
179+from canonical.testing.layers import DatabaseFunctionalLayer
180+from lp.app.browser.lazrjs import InlineMultiCheckboxWidget
181+from lp.testing import (
182+ TestCaseWithFactory,
183+ )
184+
185+
186+class Alphabet(EnumeratedType):
187+ """A vocabulary for testing."""
188+ A = Item("A", "Letter A")
189+ B = Item("B", "Letter B")
190+
191+
192+class TestInlineMultiCheckboxWidget(TestCaseWithFactory):
193+
194+ layer = DatabaseFunctionalLayer
195+
196+ def _getWidget(self, **kwargs):
197+
198+ class ITest(Interface):
199+ test_field = List(
200+ Choice(vocabulary='BuildableDistroSeries'))
201+ return InlineMultiCheckboxWidget(
202+ None, ITest['test_field'], "Label", edit_url='fake', **kwargs)
203+
204+ def _makeExpectedItems(self, vocab, selected=list(), value_fn=None):
205+ if value_fn is None:
206+ value_fn = lambda item: item.value.name
207+ expected_items = []
208+ style = 'font-weight: normal;'
209+ for item in vocab:
210+ new_item = {
211+ 'name': item.title,
212+ 'token': item.token,
213+ 'style': style,
214+ 'checked': (item.value in selected),
215+ 'value': value_fn(item)}
216+ expected_items.append(new_item)
217+ return expected_items
218+
219+ def test_items_for_field_vocabulary(self):
220+ widget = self._getWidget(attribute_type="reference")
221+ vocab = getVocabularyRegistry().get(None, 'BuildableDistroSeries')
222+ value_fn = lambda item: canonical_url(
223+ item.value, force_local_path=True)
224+ expected_items = self._makeExpectedItems(vocab, value_fn=value_fn)
225+ self.assertEqual(simplejson.dumps(expected_items), widget.json_items)
226+
227+ def test_items_for_custom_vocabulary(self):
228+ widget = self._getWidget(vocabulary=Alphabet)
229+ expected_items = self._makeExpectedItems(Alphabet)
230+ self.assertEqual(simplejson.dumps(expected_items), widget.json_items)
231+
232+ def test_items_for_custom_vocabulary_name(self):
233+ widget = self._getWidget(vocabulary="CountryName")
234+ vocab = getVocabularyRegistry().get(None, "CountryName")
235+ expected_items = self._makeExpectedItems(vocab)
236+ self.assertEqual(simplejson.dumps(expected_items), widget.json_items)
237+
238+ def test_selected_items_checked(self):
239+ widget = self._getWidget(
240+ vocabulary=Alphabet, selected_items=[Alphabet.A])
241+ expected_items = self._makeExpectedItems(
242+ Alphabet, selected=[Alphabet.A])
243+ self.assertEqual(simplejson.dumps(expected_items), widget.json_items)
244
245=== modified file 'lib/lp/app/doc/lazr-js-widgets.txt'
246--- lib/lp/app/doc/lazr-js-widgets.txt 2011-03-10 00:40:15 +0000
247+++ lib/lp/app/doc/lazr-js-widgets.txt 2011-03-18 07:43:37 +0000
248@@ -399,3 +399,95 @@
249
250 The edit link can be changed in exactly the same way as for the
251 TextLineEditorWidget above.
252+
253+
254+InlineMultiCheckboxWidget
255+-------------------
256+
257+This widget is used to edit fields which are Lists or Sets. It displays the
258+current items in the collection when the page is rendered and provides the
259+ability to edit the selected items via a popup overlay. The popup has a set of
260+checkboxes for selecting one or more items from a vocabulary. The vocabulary
261+defaults to that associated with the field being edited but can be user
262+defined.
263+
264+ >>> from lp.app.browser.lazrjs import InlineMultiCheckboxWidget
265+
266+The bare minimum that you need to provide the widget is the object that you
267+are editing, and the exported field that is being edited, and the label to
268+display for the set of checkboxes.
269+
270+The surrounding tag for the label and set of checkboxes are both customisable,
271+and a prefix may be given. The prefix is rendered as part of the widget, but
272+isn't updated when the value changes.
273+
274+Other customisable parameters include the popup header text (defaults to the
275+field title suffixed by ":"), the string to render when the field contains no
276+selected items (defaults to "None"), and a CSS style to add to each checkbox
277+node (defaults to '').
278+
279+If the user does not have edit rights, the widget just renders the text based
280+on the current value of the field on the object:
281+
282+ >>> login(ANONYMOUS)
283+ >>> from lp.code.interfaces.sourcepackagerecipe import (
284+ ... ISourcePackageRecipe,
285+ ... )
286+ >>> distroseries = ISourcePackageRecipe['distroseries']
287+ >>> recipe = factory.makeSourcePackageRecipe(
288+ ... owner=eric, name=u'cake_recipe', description=u'Yummy.')
289+ >>> widget = InlineMultiCheckboxWidget(
290+ ... recipe, distroseries, 'Recipe distro series',
291+ ... header='Select distroseries:', vocabulary='BuildableDistroSeries',
292+ ... label_tag='dt', items_tag='dl',
293+ ... selected_items=recipe.distroseries)
294+ >>> print widget()
295+ <span id="edit-distroseries">
296+ <dt>
297+ Recipe distro series
298+ </dt>
299+ <span class="yui3-activator-data-box">
300+ <dl id='edit-distroseries-items'>
301+ ...
302+ </span>
303+ <div class="yui3-activator-message-box yui3-activator-hidden" />
304+ </span>
305+
306+If the user has edit rights, an edit icon is rendered and some javascript is
307+rendered to hook up the widget.
308+
309+ >>> login_person(eric)
310+ >>> print widget()
311+ <span id="edit-distroseries">
312+ <BLANKLINE>
313+ <dt>
314+ Recipe distro series
315+ <BLANKLINE>
316+ <button class="lazr-btn yui3-activator-act yui3-activator-hidden"
317+ id="edit-distroseries-btn">
318+ Edit
319+ </button>
320+ <BLANKLINE>
321+ <noscript>
322+ <a class="sprite edit"
323+ href="http://code.launchpad.dev/~eric/+recipe/cake_recipe/+edit"
324+ title=""></a>
325+ </noscript>
326+ </dt>
327+ <BLANKLINE>
328+ <span class="yui3-activator-data-box">
329+ <dl id='edit-distroseries-items'>
330+ ...
331+ <div class="yui3-activator-message-box yui3-activator-hidden" />
332+ </span>
333+ <script>
334+ LPS.use('lp.app.multicheckbox', function(Y) {
335+ ...
336+ </script>
337+
338+
339+Changing the edit link
340+**********************
341+
342+The edit link can be changed in exactly the same way as for the
343+TextLineEditorWidget above.
344
345=== added file 'lib/lp/app/javascript/multicheckbox.js'
346--- lib/lp/app/javascript/multicheckbox.js 1970-01-01 00:00:00 +0000
347+++ lib/lp/app/javascript/multicheckbox.js 2011-03-18 07:43:37 +0000
348@@ -0,0 +1,232 @@
349+YUI.add('lp.app.multicheckbox', function(Y) {
350+
351+var namespace = Y.namespace('lp.app.multicheckbox');
352+
353+/* Add a multicheckbox widget which will PATCH a given attribute on
354+ * a given resource.
355+ *
356+ * @method addMultiCheckboxPatcher
357+ * @param {Array} items The items which to display as checkboxes.
358+ * @param {String} help_text The text to display beneath the checkboxes.
359+ * @param {String} resource_uri The object being modified.
360+ * @param {String} attribute_name The attribute on the resource being
361+ * modified.
362+ * @param {String} attribute_type The attribute type.
363+ * "reference": the items are object references
364+ * Other values are currently ignored.
365+ * @param {String} content_box_id
366+ * @param {Object} config Object literal of config name/value pairs.
367+ * config.header: a line of text at the top of the widget.
368+ * config.step_title: overrides the subtitle.
369+ */
370+namespace.addMultiCheckboxPatcher = function (
371+ items, help_text, resource_uri, attribute_name, attribute_type,
372+ content_box_id, config, client) {
373+
374+
375+ if (Y.UA.ie) {
376+ return;
377+ }
378+
379+ // We may have been passed a mock client for testing but if not, create
380+ // a proper one.
381+ if (client === undefined)
382+ client = new Y.lp.client.Launchpad();
383+
384+ var content_box = Y.one('#'+content_box_id);
385+ var result_node = Y.one('#'+content_box_id+'-items');
386+ var widget_node = Y.one('#'+attribute_name);
387+ var activator = new Y.lazr.activator.Activator(
388+ {contentBox: content_box, animationNode: widget_node});
389+
390+ var failure_handler = function (id, response, args) {
391+ activator.renderFailure(
392+ Y.Node.create(
393+ '<div>' + response.statusText +
394+ '<pre>' + response.responseText + '</pre>' +
395+ '</div>'));
396+ };
397+
398+ // The function called to save the selected items.
399+ function save(editform, item_value_mapping) {
400+ var choice_nodes = Y.one('[id="'+attribute_name+'.items"]');
401+ var result = namespace.getSelectedItems(
402+ choice_nodes, item_value_mapping, attribute_type);
403+ activator.renderProcessing();
404+ var success_handler = function (entry) {
405+ var xhtml = entry.getHTML(attribute_name);
406+ result_node.set('innerHTML', xhtml);
407+ activator.renderSuccess(result_node);
408+ };
409+
410+ var patch_payload = {};
411+ patch_payload[attribute_name] = result;
412+ client.patch(editform._resource_uri, patch_payload, {
413+ accept: 'application/json;include=lp_html',
414+ on: {
415+ success: success_handler,
416+ failure: failure_handler
417+ }
418+ });
419+ }
420+
421+ config.save = save;
422+ config.content_box_id = content_box_id;
423+ var editform = namespace.create(attribute_name, items, help_text, config);
424+ editform._resource_uri = resource_uri;
425+ var extra_buttons = Y.Node.create(
426+ '<div style="text-align: center; height: 3em; ' +
427+ 'white-space: nowrap"/>');
428+ activator.subscribe('act', function (e) {
429+ editform.show();
430+ });
431+ activator.render();
432+ return editform;
433+};
434+
435+
436+/**
437+ * Creates a multicheckbox widget that has already been rendered and hidden.
438+ *
439+ * @requires dom, lazr.activator, lazr.overlay
440+ * @method create
441+ * @param {String} attribute_name The attribute on the resource being
442+ * modified.
443+ * @param {Array} items Items for which to create checkbox elements.
444+ * @param {String} help_text text display below the checkboxes.
445+ * @param {Object} config Optional Object literal of config name/value pairs.
446+ * config.header is a line of text at the top of
447+ * the widget.
448+ * config.save is a Function (optional) which takes
449+ * a single string argument.
450+ */
451+namespace.create = function (attribute_name, items, help_text, config) {
452+ if (Y.UA.ie) {
453+ return;
454+ }
455+
456+ if (config !== undefined) {
457+ var header = 'Choose an item.';
458+ if (config.header !== undefined) {
459+ header = config.header;
460+ }
461+ }
462+
463+ var new_config = Y.merge(config, {
464+ align: {
465+ points: [Y.WidgetPositionAlign.CC,
466+ Y.WidgetPositionAlign.CC]
467+ },
468+ progressbar: true,
469+ progress: 100,
470+ headerContent: "<h2>" + header + "</h2>",
471+ centered: true,
472+ zIndex: 1000,
473+ visible: false
474+ });
475+
476+ // We use a pretty overlay to display the checkboxes.
477+ var editform = new Y.lazr.PrettyOverlay(new_config);
478+
479+ // The html for each checkbox.
480+ var CHECKBOX_TEMPLATE =
481+ ['<label style="{item_style}" for="{field_name}.{field_index}">',
482+ '<input id="{field_name}.{field_index}" ',
483+ 'name="{field_name}.{field_index}" ',
484+ 'class="checkboxType" type="checkbox" value="{field_value}" ',
485+ '{item_checked}>&nbsp;{field_text}</label>'].join("");
486+
487+ var content = Y.Node.create("<div/>");
488+ var header_node = Y.Node.create(
489+ "<div class='yui3-lazr-formoverlay-form-header'/>");
490+ content.appendChild(header_node);
491+ var body = Y.Node.create("<div class='yui3-widget-bd'/>");
492+
493+ // Set up the nodes for each checkbox.
494+ var choices_nodes = Y.Node.create('<ul id="'+attribute_name+'.items"/>');
495+ // A mapping from checkbox value attributes (data token) -> data values
496+ var item_value_mapping = {};
497+ Y.Array.each(items, function(data, i) {
498+ var checked_html = '';
499+ if (data.checked)
500+ checked_html = 'checked="checked"';
501+ var checkbox_html = Y.Lang.substitute(
502+ CHECKBOX_TEMPLATE,
503+ {field_name: "field."+attribute_name, field_index:i,
504+ field_value: data.token, field_text: data.name,
505+ item_style: data.style, item_checked: checked_html});
506+
507+ var choice_item = Y.Node.create("<li/>");
508+ choice_item.set("innerHTML", checkbox_html);
509+ choices_nodes.appendChild(choice_item);
510+ item_value_mapping[data.token] = data.value;
511+ }, this);
512+ body.appendChild(choices_nodes);
513+ content.appendChild(body);
514+ var help_node = Y.Node.create("<p class='formHelp'>"+help_text+"</p>");
515+ content.appendChild(help_node);
516+ editform.set('bodyContent', content);
517+
518+ // We replace the default Close button (x) with our own save/cancel ones.
519+ var close_node = editform.get('boundingBox').one('div.close');
520+ var orig_close_button = close_node.one('a');
521+ orig_close_button.setAttribute('style', 'display: none');
522+ var save_button = Y.Node.create(
523+ '<button id="'+config.content_box_id+'-save" ' +
524+ 'class="overlay-close-button lazr-pos lazr-btn">Ok</button>');
525+ var close_button = Y.Node.create(
526+ '<button class="overlay-close-button lazr-neg lazr-btn">Cancel' +
527+ '</button>');
528+ save_button.on('click', function(e) {
529+ e.halt();
530+ editform.hide();
531+ config.save(editform, item_value_mapping);
532+ });
533+ close_button.on('click', function(e) {
534+ e.halt();
535+ editform.fire('cancel');
536+ });
537+ close_node.appendChild(close_button);
538+ close_node.appendChild(save_button);
539+ editform.render();
540+ editform.hide();
541+ return editform;
542+};
543+
544+
545+/*
546+ * Return a list of the selected checkbox values.
547+ * Exposed via the namespace so it is accessible to tests.
548+ */
549+namespace.getSelectedItems = function(choice_nodes, item_value_mapping,
550+ attribute_type) {
551+ var result = [];
552+ choice_nodes.all('.checkboxType').each(function(item) {
553+ if (item.get("checked")) {
554+ var item_token = item.getAttribute("value");
555+ var item_value = item_value_mapping[item_token];
556+ var marshalled_value = marshall(item_value, attribute_type);
557+ result.push(marshalled_value);
558+ }
559+ });
560+ return result;
561+};
562+
563+
564+/*
565+ * Transform the selected value according to the attribute type we are editing
566+ */
567+function marshall(value, attribute_type) {
568+ switch (attribute_type) {
569+ case "reference":
570+ var item_value = Y.lp.client.normalize_uri(value);
571+ return Y.lp.client.get_absolute_uri(item_value);
572+ break;
573+ default:
574+ return value;
575+ }
576+}
577+
578+}, "0.1", {"requires": [
579+ "dom", "lazr.overlay", "lazr.activator", "lp.client"
580+ ]});
581
582=== added file 'lib/lp/app/javascript/tests/test_multicheckboxwidget.html'
583--- lib/lp/app/javascript/tests/test_multicheckboxwidget.html 1970-01-01 00:00:00 +0000
584+++ lib/lp/app/javascript/tests/test_multicheckboxwidget.html 2011-03-18 07:43:37 +0000
585@@ -0,0 +1,42 @@
586+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
587+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
588+<html>
589+ <head>
590+ <title>Multicheckbox Widget</title>
591+
592+ <!-- YUI 3.0 Setup -->
593+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
594+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
595+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
596+ <link rel="stylesheet" href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
597+ <link rel="stylesheet" href="../../../../canonical/launchpad/javascript/test.css" />
598+
599+ <!-- Some required dependencies -->
600+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
601+ <script type="text/javascript" src="../client.js"></script>
602+
603+ <!-- The module under test -->
604+ <script type="text/javascript" src="../multicheckbox.js"></script>
605+
606+ <!-- The test suite -->
607+ <script type="text/javascript" src="test_multicheckboxwidget.js"></script>
608+</head>
609+<body class="yui3-skin-sam">
610+ <span id="edit-test">
611+ <span id="multicheckboxtest">
612+ <span>
613+ A multicheckbox widget test
614+ <button id="edit-multicheckboxtest-btn"
615+ class="lazr-btn yui3-activator-act yui3-activator-hidden">
616+ Edit
617+ </button>
618+ </span>
619+ <span class="yui3-activator-data-box">
620+ <span id="edit-test-items"/>
621+ </span>
622+ <div class="yui3-activator-message-box yui3-activator-hidden"/>
623+ </span>
624+ </span>
625+ <div id="log"></div>
626+</body>
627+</html>
628\ No newline at end of file
629
630=== added file 'lib/lp/app/javascript/tests/test_multicheckboxwidget.js'
631--- lib/lp/app/javascript/tests/test_multicheckboxwidget.js 1970-01-01 00:00:00 +0000
632+++ lib/lp/app/javascript/tests/test_multicheckboxwidget.js 2011-03-18 07:43:37 +0000
633@@ -0,0 +1,184 @@
634+/* Copyright (c) 2011, Canonical Ltd. All rights reserved. */
635+
636+YUI({
637+ base: '../../../../canonical/launchpad/icing/yui/',
638+ filter: 'raw',
639+ combine: false,
640+ fetchCSS: false
641+ }).use('test', 'console', 'dom', 'event', 'event-simulate',
642+ 'lazr.overlay', 'lazr.activator',
643+ 'lp.client', 'lp.app.multicheckbox',
644+ function(Y) {
645+
646+var suite = new Y.Test.Suite("lp.app.multicheckbox Tests");
647+
648+/*
649+ * A wrapper for the Y.Event.simulate() function. The wrapper accepts
650+ * CSS selectors and Node instances instead of raw nodes.
651+ */
652+function simulate(widget, selector, evtype, options) {
653+ var rawnode = Y.Node.getDOMNode(widget.one(selector));
654+ Y.Event.simulate(rawnode, evtype, options);
655+}
656+
657+/* Helper function to clean up a dynamically added widget instance. */
658+function cleanup_widget(widget) {
659+ // Nuke the boundingBox, but only if we've touched the DOM.
660+ if (widget.get('rendered')) {
661+ var bb = widget.get('boundingBox');
662+ bb.get('parentNode').removeChild(bb);
663+ }
664+ // Kill the widget itself.
665+ widget.destroy();
666+}
667+
668+var MockClient = function() {
669+ /* A mock to provide the result of a patch operation. */
670+};
671+
672+MockClient.prototype = {
673+ 'patch': function(uri, representation, config, headers) {
674+ var patch_content = new Y.lp.client.Entry();
675+ var html = representation['multicheckboxtest'];
676+ patch_content.set('lp_html', {'multicheckboxtest': html});
677+ config.on.success(patch_content);
678+ }
679+};
680+
681+suite.add(new Y.Test.Case({
682+ name: "lp.app.multicheckbox",
683+
684+ createWidget: function(header) {
685+ // Create a widget with some default data
686+ var config = {"empty_display_value": "None"};
687+ if( header != 'None' ) {
688+ if( header == null )
689+ header = 'Test multicheckbox widget:';
690+ config['header'] = header;
691+ }
692+
693+ var mock_client = new MockClient();
694+ this.widget = Y.lp.app.multicheckbox.addMultiCheckboxPatcher(
695+ [{"token": "0", "style": "font-weight: normal;", "checked": true,
696+ "name": "Item 0", "value": "item1"},
697+ {"token": "1", "style": "font-weight: normal;", "checked": false,
698+ "name": "Item 1", "value": "item2"}],
699+ 'A test',
700+ '/~fred/+recipe/a-recipe',
701+ 'multicheckboxtest',
702+ 'reference',
703+ 'edit-test',
704+ config, mock_client);
705+ },
706+
707+ tearDown: function() {
708+ cleanup_widget(this.widget);
709+ },
710+
711+ test_widget_can_be_instantiated: function() {
712+ this.createWidget();
713+ Y.Assert.isInstanceOf(
714+ Y.lazr.PrettyOverlay, this.widget,
715+ "Widget failed to be instantiated");
716+ },
717+
718+ test_header_value: function() {
719+ // Check the header text value.
720+ this.createWidget();
721+ var header = Y.one('.yui3-widget-hd');
722+ Y.Assert.areEqual(
723+ header.get('innerHTML'), '<h2>Test multicheckbox widget:</h2>');
724+ },
725+
726+ test_default_header_value: function() {
727+ // Check the default header text value.
728+ this.createWidget(header='None');
729+ var header = Y.one('.yui3-widget-hd');
730+ Y.Assert.areEqual(
731+ header.get('innerHTML'), '<h2>Choose an item.</h2>');
732+ },
733+
734+ test_help_text_value: function() {
735+ // Check the help text value.
736+ this.createWidget();
737+ var header = Y.one('.yui3-widget-bd p.formHelp');
738+ Y.Assert.areEqual(
739+ header.get('innerHTML'), 'A test');
740+ },
741+
742+ test_widget_has_correct_choice_data: function() {
743+ // Make sure the checkboxes are rendered with expected data values..
744+ this.createWidget();
745+ for( var x=0; x<2; x++ ) {
746+ var item = Y.one(
747+ 'input[id="field.multicheckboxtest.'+x+'][type=checkbox]');
748+ Y.Assert.areEqual(item.getAttribute('value'), x);
749+ Y.Assert.areEqual(item.get('checked'), x==0);
750+ }
751+ },
752+
753+ test_widget_has_correct_choice_text: function() {
754+ // Make sure the checkboxes are rendered with expected label text.
755+ this.createWidget();
756+ for( var x=0; x<2; x++ ) {
757+ var item = Y.one('label[for="field.multicheckboxtest.'+x+']');
758+ var txt = item.get('textContent');
759+ //remove any &nbsp in the text
760+ txt = txt.replace(/^[\s\xA0]+/g,'').replace(/[\s(&nbsp;)]+$/g,'');
761+ Y.Assert.areEqual(txt, 'Item '+ x);
762+ }
763+ },
764+
765+ test_getSelectedItems: function() {
766+ // Test that getSelectedItems returns the correct values.
767+ this.createWidget();
768+ var items = Y.one('[id="multicheckboxtest.items"]');
769+ var mapping = {0: 'Item0', 1: 'Item1'};
770+ var selected = Y.lp.app.multicheckbox.getSelectedItems(
771+ items, mapping, '');
772+ Y.ArrayAssert.itemsAreEqual(['Item0'], selected);
773+ },
774+
775+ test_marshall_references: function() {
776+ // Test that getSelectedItems returns the correct values for reference
777+ // values which are links to domain objects.
778+ this.createWidget();
779+ var items = Y.one('[id="multicheckboxtest.items"]');
780+ var mapping = {0: '/ubuntu/Item0', 1: '/ubuntu/Item1'};
781+ var selected = Y.lp.app.multicheckbox.getSelectedItems(
782+ items, mapping, 'reference');
783+ var item_value = Y.lp.client.normalize_uri(selected[0]);
784+ var link = Y.lp.client.get_absolute_uri(item_value);
785+ Y.ArrayAssert.itemsAreEqual([link], selected);
786+ },
787+
788+ test_widget_content_from_patch_success: function() {
789+ // Test that when the user clicks "save" and the result comes back,
790+ // the DOM is correctly updated.
791+ this.createWidget();
792+ simulate(this.widget.get('boundingBox'), '#edit-test-save', 'click');
793+ var test_content = 'file:///api/devel/item1';
794+ var items = Y.one('[id="edit-test-items"]');
795+ Y.Assert.areEqual(test_content, items.get('innerHTML'));
796+ }
797+
798+}));
799+
800+// Lock, stock, and two smoking barrels.
801+var handle_complete = function(data) {
802+ status_node = Y.Node.create(
803+ '<p id="complete">Test status: complete</p>');
804+ Y.one('body').appendChild(status_node);
805+ };
806+Y.Test.Runner.on('complete', handle_complete);
807+Y.Test.Runner.add(suite);
808+
809+var yui_console = new Y.Console({
810+ newestOnTop: false
811+});
812+yui_console.render('#log');
813+
814+Y.on('domready', function() {
815+ Y.Test.Runner.run();
816+});
817+});
818
819=== added file 'lib/lp/app/templates/inline-multicheckbox-widget.pt'
820--- lib/lp/app/templates/inline-multicheckbox-widget.pt 1970-01-01 00:00:00 +0000
821+++ lib/lp/app/templates/inline-multicheckbox-widget.pt 2011-03-18 07:43:37 +0000
822@@ -0,0 +1,50 @@
823+<span tal:define="items view/items|nothing"
824+ tal:attributes="id view/content_box_id">
825+ <tal:label-open-tag replace="structure view/label_open_tag"/>
826+ <span tal:replace="structure view/label"/>
827+ <tal:has_choices condition="python:view.has_choices and view.can_write">
828+ <button tal:attributes="id string:${view/content_box_id}-btn"
829+ class="lazr-btn yui3-activator-act yui3-activator-hidden">
830+ Edit
831+ </button>
832+ </tal:has_choices>
833+ <noscript tal:condition="view/can_write">
834+ <a tal:attributes="href view/edit_url;
835+ title view/edit_title"
836+ class="sprite edit"></a>
837+ </noscript>
838+ <tal:label-close-tag replace="structure view/label_close_tag"/>
839+ <span class="yui3-activator-data-box">
840+ <tal:items-open-tag replace="structure view/items_open_tag"/>
841+ <span tal:condition="not:items" tal:content="structure view/empty_display_value"/>
842+ <ul tal:condition="items">
843+ <li tal:condition="view/linkify_items"
844+ tal:repeat="item items"
845+ tal:content="structure item/fmt:link"/>
846+ <li tal:condition="not:view/linkify_items"
847+ tal:repeat="item items"
848+ tal:content="structure item"/>
849+ </ul>
850+ <tal:items-close-tag replace="structure view/items_close_tag"/>
851+ </span>
852+ <div class="yui3-activator-message-box yui3-activator-hidden"/>
853+</span>
854+<script tal:condition="view/can_write"
855+ tal:content="structure string:
856+LPS.use('lp.app.multicheckbox', function(Y) {
857+ if (Y.UA.ie) {
858+ return;
859+ }
860+
861+ Y.on('load', function(e) {
862+ Y.lp.app.multicheckbox.addMultiCheckboxPatcher(
863+ ${view/json_items},
864+ ${view/json_description},
865+ ${view/json_resource_uri},
866+ ${view/json_attribute},
867+ ${view/json_attribute_type},
868+ ${view/json_content_box_id},
869+ ${view/json_config});
870+ }, window);
871+});
872+"/>
873
874=== modified file 'versions.cfg'
875--- versions.cfg 2011-03-15 21:09:44 +0000
876+++ versions.cfg 2011-03-18 07:43:37 +0000
877@@ -39,7 +39,7 @@
878 lazr.smtptest = 1.1
879 lazr.testing = 0.1.1
880 lazr.uri = 1.0.2
881-lazr-js = 1.6DEV-r202
882+lazr-js = 1.6DEV-r206
883 manuel = 1.1.1
884 martian = 0.11
885 mechanize = 0.1.11