Merge lp:~thumper/launchpad/refactor-lazrjs-text-widgets into lp:launchpad

Proposed by Tim Penhey on 2011-01-27
Status: Merged
Approved by: Tim Penhey on 2011-01-30
Approved revision: 12297
Merged at revision: 12285
Proposed branch: lp:~thumper/launchpad/refactor-lazrjs-text-widgets
Merge into: lp:launchpad
Diff against target: 1643 lines (+602/-536) 22 files modified
To merge this branch: bzr merge lp:~thumper/launchpad/refactor-lazrjs-text-widgets
Reviewer Review Type Date Requested Status
Francis J. Lacoste (community) 2011-01-27 Approve on 2011-01-28
Review via email: mp+47634@code.launchpad.net

Commit Message

[r=flacoste][ui=none][bug=708267,708980] Refactor the text lazrjs widgets.

Description of the Change

This branch started out as a quick refactoring of the TextLineEditorWidget and the TextAreaEditorWidget.

It rapidly grew to tearing the widgets to shreds and remaking them.

The code that is now needed in the view classes to create the widgets is greatly reduced, and rendering the widgets is trivial regardless of their type.

The documentation was updated significantly, and is now actual documentation on use of the widgets.

To post a comment you must log in.
Francis J. Lacoste (flacoste) wrote :
Download full text (6.3 KiB)

Hi Tim,

This is a very nice consolidation of the widget infrastructure! I have a
couple of questions and suggestion for enhancements.

Cheers

> === modified file 'lib/canonical/widgets/lazrjs.py'
> +class WidgetBase:
> + """Useful methods for all widgets."""
>
> + def __init__(self, context, exported_field, content_box_id):
> + self.context = context
> + self.exported_field = exported_field
> +
> + self.request = get_current_browser_request()

That makes us depend on global state. To unit test widget python code, it will
require setting the interaction.

Maybe it's not that much of a problem...

> + self.attribute_name = exported_field.__name__
> +
> + if content_box_id is None:
> + content_box_id = "edit-%s" % self.attribute_name

We need to cater for the use-case when we'll have multiple edit widget on the
page, so I'd suggest putting in a counter there. (That's why we were using
_generate_id() before)

> + self.content_box_id = content_box_id
> +
> + self.mutator_method_name = None
> + ws_stack = exported_field.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED)
> + if ws_stack is None:
> + # The field may be a copy, or similarly named to one we care
> + # about.
> + self.api_attribute = self.attribute_name
> + else:
> + self.api_attribute = ws_stack['as']
> + mutator_info = ws_stack.get('mutator_annotations')
> + if mutator_info is not None:
> + mutator_method, mutator_extra = mutator_info
> + self.mutator_method_name = mutator_method.__name__

Why do we need to care about the mutator method name? It should be
transparent: you simply PATCH the field to the new value and lazr.restful will
call the mutator appropriately automatically. The 'as' stuff is required
though.

> +
> + @property
> + def resource_uri(self):
> + return canonical_url(self.context, force_local_path=True)
> +

Why force_local_path? (Subtle hint to add a comment there.)

> + @property
> + def json_resource_uri(self):
> + return simplejson.dumps(self.resource_uri)
> +
> + @classmethod
> + def _generate_id(cls):
> + """Return a presumably unique id for this widget."""
> + cls.last_id += 1
> + return '%s-id-%d' % (cls.widget_type, cls.last_id)
> +
> + @property
> + def can_write(self):
> + if canWrite(self.context, self.attribute_name):
> + return True
> + elif self.mutator_method_name is not None:
> + # The user may not have write access on the attribute itself, but
> + # the REST API may have a mutator method configured, such as
> + # transitionToAssignee.
> + return canAccess(self.context, self.mutator_method_name)
> + else:
> + return False

Hmm, I see why you need to look at the mutator then. Might want to add a
comment to explain it.

> +
> +
> +class TextWidgetBase(WidgetBase):
> +
> + def __init__(self, context, exported_field, content_box_id,
> + accept_empty, title, edit_view, edit_url):
> + super(TextWidgetBase, self)....

Read more...

review: Needs Information
Tim Penhey (thumper) wrote :
Download full text (3.2 KiB)

On Fri, 28 Jan 2011 05:43:28 Francis J. Lacoste wrote:
> > === modified file 'lib/canonical/widgets/lazrjs.py'
> > +class WidgetBase:
> > + """Useful methods for all widgets."""
> >
> > + def __init__(self, context, exported_field, content_box_id):
> > + self.context = context
> > + self.exported_field = exported_field
> > +
> > + self.request = get_current_browser_request()
>
> That makes us depend on global state. To unit test widget python code, it
> will require setting the interaction.

The widgets have always depended on global state as they call the
canonical_url method. This just makes that more dependency explicit.

> Maybe it's not that much of a problem...
>
> > + self.attribute_name = exported_field.__name__
> > +
> > + if content_box_id is None:
> > + content_box_id = "edit-%s" % self.attribute_name
>
> We need to cater for the use-case when we'll have multiple edit widget on
> the page, so I'd suggest putting in a counter there. (That's why we were
> using _generate_id() before)

As we discussed, the _generate_id method was never called from any call-site
as they almost all used "edit-foo" where foo was the attribute being edited.
I updated the default to do this, and removed the extra parameter from the
call sites to make them simpler.

If for some reason there are multiple widgets on a page for editing a single
attribute name (perhaps like bug task assignees), then the call site will need
to specify the id.

> Why do we need to care about the mutator method name? It should be
> transparent: you simply PATCH the field to the new value and lazr.restful
> will call the mutator appropriately automatically. The 'as' stuff is
> required though.

Comment added.

> > + @property
> > + def resource_uri(self):
> > + return canonical_url(self.context, force_local_path=True)
> > +
>
> Why force_local_path? (Subtle hint to add a comment there.)

Docstring added.

> > +class TextWidgetBase(WidgetBase):
> > +
> > + def __init__(self, context, exported_field, content_box_id,
> > + accept_empty, title, edit_view, edit_url):
> > + super(TextWidgetBase, self).__init__(
> > + context, exported_field, content_box_id)
> > + if edit_url is None:
> > + edit_url = canonical_url(self.context, view_name=edit_view)
>
> If edit_url can be None, why not make it an optional argument? I see that
> you made it so in the descendant class.

No I don't want it optional here. TextWidgetBase is an abstract base class
for the text editor widgets. Even though they have the paramter as optional,
it is explicitly passed through to the base class constructor.

> > + resource_uri = LP.client.normalize_uri(resource_uri)
>
> Aren't you missing a var here?

Nope. It is altering the function parameter.

> WHy do we have 'Set description' links in addition to the widget? If it's
> for graceful degradation, could we make that part of the widget API? Like
> it is for the inline textline widget?

This is due to the behaviour on the merge proposal page for empty text areas
not showing, and a link appearing. As we discussed it may be...

Read more...

Francis J. Lacoste (flacoste) wrote :

Really nice!

Thanks for the extensive rewrite of the documentation!

review: Approve
12298. By Tim Penhey on 2011-01-30

Actually check the class, not the tag representation.

12299. By Tim Penhey on 2011-01-30

Update id.

12300. By Tim Penhey on 2011-01-30

Merge devel.

12301. By Tim Penhey on 2011-01-30

Update widget ids.

12302. By Tim Penhey on 2011-01-30

Update ppa displayname edit id.

Preview Diff

1=== renamed file 'lib/canonical/widgets/lazrjs.py' => 'lib/lp/app/browser/lazrjs.py'
2--- lib/canonical/widgets/lazrjs.py 2011-01-21 04:32:26 +0000
3+++ lib/lp/app/browser/lazrjs.py 2011-01-30 20:24:55 +0000
4@@ -1,19 +1,18 @@
5-# Copyright 2009 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Wrappers for lazr-js widgets."""
10
11 __metaclass__ = type
12 __all__ = [
13+ 'InlineEditPickerWidget',
14+ 'standard_text_html_representation',
15 'TextAreaEditorWidget',
16- 'InlineEditPickerWidget',
17+ 'TextLineEditorWidget',
18 'vocabulary_to_choice_edit_items',
19- 'TextLineEditorWidget',
20 ]
21
22-import cgi
23 import simplejson
24-from textwrap import dedent
25
26 from zope.app.pagetemplate.viewpagetemplatefile import ViewPageTemplateFile
27 from zope.component import getUtility
28@@ -21,255 +20,209 @@
29 from zope.schema.interfaces import IVocabulary
30 from zope.schema.vocabulary import getVocabularyRegistry
31
32-from lazr.restful.interfaces import IWebServiceClientRequest
33+from lazr.restful.declarations import LAZR_WEBSERVICE_EXPORTED
34+from canonical.lazr.utils import get_current_browser_request
35 from canonical.lazr.utils import safe_hasattr
36
37 from canonical.launchpad.webapp.interfaces import ILaunchBag
38 from canonical.launchpad.webapp.publisher import canonical_url
39 from canonical.launchpad.webapp.vocabulary import IHugeVocabulary
40+from lp.app.browser.stringformatter import FormattersAPI
41 from lp.services.propertycache import cachedproperty
42
43
44-class TextLineEditorWidget:
45+class WidgetBase:
46+ """Useful methods for all widgets."""
47+
48+ def __init__(self, context, exported_field, content_box_id):
49+ self.context = context
50+ self.exported_field = exported_field
51+
52+ self.request = get_current_browser_request()
53+ self.attribute_name = exported_field.__name__
54+ self.optional_field = not exported_field.required
55+
56+ if content_box_id is None:
57+ content_box_id = "edit-%s" % self.attribute_name
58+ self.content_box_id = content_box_id
59+
60+ # The mutator method name is used to determine whether or not the
61+ # current user has permission to alter the attribute if the attribute
62+ # is using a mutator function.
63+ self.mutator_method_name = None
64+ ws_stack = exported_field.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED)
65+ if ws_stack is None:
66+ # The field may be a copy, or similarly named to one we care
67+ # about.
68+ self.api_attribute = self.attribute_name
69+ else:
70+ self.api_attribute = ws_stack['as']
71+ mutator_info = ws_stack.get('mutator_annotations')
72+ if mutator_info is not None:
73+ mutator_method, mutator_extra = mutator_info
74+ self.mutator_method_name = mutator_method.__name__
75+
76+ @property
77+ def resource_uri(self):
78+ """A local path to the context object.
79+
80+ The javascript uses the normalize_uri method that adds the appropriate
81+ prefix to the uri. Doing it this way avoids needing to adapt the
82+ current request into a webservice request in order to get an api url.
83+ """
84+ return canonical_url(self.context, force_local_path=True)
85+
86+ @property
87+ def json_resource_uri(self):
88+ return simplejson.dumps(self.resource_uri)
89+
90+ @property
91+ def can_write(self):
92+ """Can the current user write to the attribute."""
93+ if canWrite(self.context, self.attribute_name):
94+ return True
95+ elif self.mutator_method_name is not None:
96+ # The user may not have write access on the attribute itself, but
97+ # the REST API may have a mutator method configured, such as
98+ # transitionToAssignee.
99+ return canAccess(self.context, self.mutator_method_name)
100+ else:
101+ return False
102+
103+
104+class TextWidgetBase(WidgetBase):
105+ """Abstract base for the single and multiline text editor widgets."""
106+
107+ def __init__(self, context, exported_field, title, content_box_id,
108+ edit_view, edit_url):
109+ super(TextWidgetBase, self).__init__(
110+ context, exported_field, content_box_id)
111+ if edit_url is None:
112+ edit_url = canonical_url(self.context, view_name=edit_view)
113+ self.edit_url = edit_url
114+ self.accept_empty = simplejson.dumps(self.optional_field)
115+ self.title = title
116+ self.json_attribute = simplejson.dumps(self.api_attribute)
117+ self.widget_css_selector = simplejson.dumps('#' + self.content_box_id)
118+
119+ @property
120+ def json_attribute_uri(self):
121+ return simplejson.dumps(self.resource_uri + '/' + self.api_attribute)
122+
123+
124+class TextLineEditorWidget(TextWidgetBase):
125 """Wrapper for the lazr-js inlineedit/editor.js widget."""
126
127- # Class variable used to generate a unique per-page id for the widget
128- # in case it's not provided.
129- last_id = 0
130-
131- # The HTML template used to render the widget.
132- # Replacements:
133- # activation_script: the JS script to active the widget
134- # attribute: the name of the being edited
135- # context_url: the url to the current context
136- # edit_url: the URL used to edit the value when JS is turned off
137- # id: the widget unique id
138- # title: the widget title
139- # trigger: the trigger (button) HTML code
140- # value: the current field value
141- WIDGET_TEMPLATE = dedent(u"""\
142- <%(tag)s id="%(id)s"><span
143- class="yui3-editable_text-text">%(value)s</span>
144- %(trigger)s
145- </%(tag)s>
146- %(activation_script)s
147- """)
148-
149- # Template for the trigger button.
150- TRIGGER_TEMPLATE = dedent(u"""\
151- <a href="%(edit_url)s" class="yui3-editable_text-trigger sprite edit"
152- ></a>
153- """)
154-
155- # Template for the activation script.
156- ACTIVATION_TEMPLATE = dedent(u"""\
157- <script>
158- LPS.use('lazr.editor', 'lp.client.plugins', function (Y) {
159- var widget = new Y.EditableText({
160- contentBox: '#%(id)s',
161- accept_empty: %(accept_empty)s,
162- width: '%(width)s',
163- initial_value_override: %(initial_value_override)s
164- });
165- widget.editor.plug({
166- fn: Y.lp.client.plugins.PATCHPlugin, cfg: {
167- patch: '%(public_attribute)s',
168- resource: '%(context_url)s'}});
169- widget.render();
170- });
171- </script>
172- """)
173-
174- def __init__(self, context, attribute, edit_url, id=None, title="Edit",
175- tag='h1', public_attribute=None, accept_empty=False,
176+ __call__ = ViewPageTemplateFile('../templates/text-line-editor.pt')
177+
178+ def __init__(self, context, exported_field, title, tag,
179+ content_box_id=None, edit_view="+edit", edit_url=None,
180 default_text=None, initial_value_override=None, width=None):
181 """Create a widget wrapper.
182
183 :param context: The object that is being edited.
184- :param attribute: The name of the attribute being edited.
185- :param edit_url: The URL to use for editing when the user isn't logged
186- in and when JS is off.
187- :param id: The HTML id to use for this widget. Automatically
188- generated if this is not provided.
189- :param title: The string to use as the link title. Defaults to 'Edit'.
190+ :param exported_field: The attribute being edited. This should be
191+ a field from an interface of the form ISomeInterface['fieldname']
192+ :param title: The string to use as the link title.
193 :param tag: The HTML tag to use.
194- :param public_attribute: If given, the name of the attribute in the
195- public webservice API.
196- :param accept_empty: Whether the field accepts empty input or not.
197+ :param content_box_id: The HTML id to use for this widget.
198+ Defaults to edit-<attribute name>.
199+ :param edit_view: The view name to use to generate the edit_url if
200+ one is not specified.
201+ :param edit_url: The URL to use for editing when the user isn't logged
202+ in and when JS is off. Defaults to the edit_view on the context.
203 :param default_text: Text to show in the unedited field, if the
204 parameter value is missing or None.
205 :param initial_value_override: Use this text for the initial edited
206 field value instead of the attribute's current value.
207 :param width: Initial widget width.
208 """
209- self.context = context
210- self.attribute = attribute
211- self.edit_url = edit_url
212+ super(TextLineEditorWidget, self).__init__(
213+ context, exported_field, title, content_box_id,
214+ edit_view, edit_url)
215 self.tag = tag
216- if accept_empty:
217- self.accept_empty = 'true'
218- else:
219- self.accept_empty = 'false'
220- if public_attribute is None:
221- self.public_attribute = attribute
222- else:
223- self.public_attribute = public_attribute
224- if id is None:
225- self.id = self._generate_id()
226- else:
227- self.id = id
228- self.title = title
229 self.default_text = default_text
230- self.initial_value_override = initial_value_override
231- self.width = width
232-
233- @classmethod
234- def _generate_id(cls):
235- """Return a presumably unique id for this widget."""
236- cls.last_id += 1
237- return 'inline-textline-editor-id%d' % cls.last_id
238-
239- def __call__(self):
240- """Return the HTML to include to render the widget."""
241- # We can't use the value None because of the cgi.escape() and because
242- # that wouldn't look very good in the ui!
243- value = getattr(self.context, self.attribute, self.default_text)
244- if value is None:
245- value = self.default_text
246- params = {
247- 'activation_script': '',
248- 'trigger': '',
249- 'edit_url': self.edit_url,
250- 'id': self.id,
251- 'title': self.title,
252- 'value': cgi.escape(value),
253- 'context_url': canonical_url(
254- self.context, path_only_if_possible=True),
255- 'attribute': self.attribute,
256- 'tag': self.tag,
257- 'public_attribute': self.public_attribute,
258- 'accept_empty': self.accept_empty,
259- 'initial_value_override': simplejson.dumps(
260- self.initial_value_override),
261- 'width': self.width,
262- }
263- # Only display the trigger link and the activation script if
264- # the user can write the attribute.
265- if canWrite(self.context, self.attribute):
266- params['trigger'] = self.TRIGGER_TEMPLATE % params
267- params['activation_script'] = self.ACTIVATION_TEMPLATE % params
268- return self.WIDGET_TEMPLATE % params
269-
270-
271-class TextAreaEditorWidget(TextLineEditorWidget):
272+ self.initial_value_override = simplejson.dumps(initial_value_override)
273+ self.width = simplejson.dumps(width)
274+
275+ @property
276+ def open_tag(self):
277+ return '<%s id="%s">' % (self.tag, self.content_box_id)
278+
279+ @property
280+ def close_tag(self):
281+ return '</%s>' % self.tag
282+
283+ @property
284+ def value(self):
285+ text = getattr(self.context, self.attribute_name, self.default_text)
286+ if text is None:
287+ text = self.default_text
288+ return text
289+
290+
291+class TextAreaEditorWidget(TextWidgetBase):
292 """Wrapper for the multine-line lazr-js inlineedit/editor.js widget."""
293
294- def __init__(self, *args, **kwds):
295- """Create the widget wrapper."""
296- if 'value' in kwds:
297- self.value = kwds.get('value', '')
298- kwds.pop('value')
299- super(TextAreaEditorWidget, self).__init__(*args, **kwds)
300-
301- # The HTML template used to render the widget.
302- # Replacements:
303- # activation_script: the JS script to active the widget
304- # attribute: the name of the being edited
305- # context_url: the url to the current context
306- # edit_url: the URL used to edit the value when JS is turned off
307- # id: the widget unique id
308- # title: the widget title
309- # trigger: the trigger (button) HTML code
310- # value: the current field value
311- WIDGET_TEMPLATE = dedent(u"""\
312- <div id="multi-text-editor">
313- <div class="clearfix">
314- %(edit_controls)s
315- <h2>%(title)s</h2>
316- </div>
317- <div class="yui3-editable_text-text">%(value)s</div>
318- </div>
319- %(activation_script)s
320- """)
321-
322- CONTROLS_TEMPLATE = dedent(u"""\
323- <div class="edit-controls">
324- &nbsp;
325- %(trigger)s
326- </div>
327- """)
328-
329- ACTIVATION_TEMPLATE = dedent(u"""\
330- <script>
331- LPS.use('lazr.editor', 'lp.client.plugins', function (Y) {
332- var widget = new Y.EditableText({
333- contentBox: '#%(id)s',
334- accept_empty: %(accept_empty)s,
335- multiline: true,
336- buttons: 'top'
337- });
338- widget.editor.plug({
339- fn: Y.lp.client.plugins.PATCHPlugin, cfg: {
340- patch: '%(attribute)s',
341- resource: '%(context_url)s/%(attribute)s',
342- patch_field: true,
343- accept: 'application/xhtml+xml'
344- }});
345- if (!Y.UA.opera) {
346- widget.render();
347- }
348- var lpns = Y.namespace('lp');
349- if (!lpns.widgets) {
350- lpns.widgets = {};
351- }
352- lpns.widgets['%(id)s'] = widget;
353- });
354- </script>
355- """)
356-
357- def __call__(self):
358- """Return the HTML to include to render the widget."""
359- params = {
360- 'activation_script': '',
361- 'trigger': '',
362- 'edit_url': self.edit_url,
363- 'id': self.id,
364- 'title': self.title,
365- 'value': self.value,
366- 'context_url': canonical_url(
367- self.context, path_only_if_possible=True),
368- 'attribute': self.attribute,
369- 'accept_empty': self.accept_empty,
370- 'edit_controls': '',
371- }
372- # Only display the trigger link and the activation script if
373- # the user can write the attribute.
374- if canWrite(self.context, self.attribute):
375- params['trigger'] = self.TRIGGER_TEMPLATE % params
376- params['activation_script'] = self.ACTIVATION_TEMPLATE % params
377- params['edit_controls'] = self.CONTROLS_TEMPLATE % params
378- return self.WIDGET_TEMPLATE % params
379-
380-
381-class InlineEditPickerWidget:
382+ __call__ = ViewPageTemplateFile('../templates/text-area-editor.pt')
383+
384+ def __init__(self, context, exported_field, title, content_box_id=None,
385+ edit_view="+edit", edit_url=None,
386+ hide_empty=True, linkify_text=True):
387+ """Create the widget wrapper.
388+
389+ :param context: The object that is being edited.
390+ :param exported_field: The attribute being edited. This should be
391+ a field from an interface of the form ISomeInterface['fieldname']
392+ :param title: The string to use as the link title.
393+ :param content_box_id: The HTML id to use for this widget.
394+ Defaults to edit-<attribute name>.
395+ :param edit_view: The view name to use to generate the edit_url if
396+ one is not specified.
397+ :param edit_url: The URL to use for editing when the user isn't logged
398+ in and when JS is off. Defaults to the edit_view on the context.
399+ :param hide_empty: If the attribute has no value, or is empty, then
400+ hide the editor by adding the "unseen" CSS class.
401+ :param linkify_text: If True the HTML version of the text will have
402+ things that look like links made into anchors.
403+ """
404+ super(TextAreaEditorWidget, self).__init__(
405+ context, exported_field, title, content_box_id,
406+ edit_view, edit_url)
407+ self.hide_empty = hide_empty
408+ self.linkify_text = linkify_text
409+
410+ @property
411+ def tag_class(self):
412+ """The CSS class for the widget."""
413+ classes = ['lazr-multiline-edit']
414+ if self.hide_empty and not self.value:
415+ classes.append('unseen')
416+ return ' '.join(classes)
417+
418+ @cachedproperty
419+ def value(self):
420+ text = getattr(self.context, self.attribute_name, None)
421+ return standard_text_html_representation(text, self.linkify_text)
422+
423+
424+class InlineEditPickerWidget(WidgetBase):
425 """Wrapper for the lazr-js picker widget.
426
427 This widget is not for editing form values like the
428 VocabularyPickerWidget.
429 """
430
431- last_id = 0
432- __call__ = ViewPageTemplateFile('templates/inline-picker.pt')
433+ __call__ = ViewPageTemplateFile('../templates/inline-picker.pt')
434
435- def __init__(self, context, request, interface_attribute, default_html,
436+ def __init__(self, context, exported_field, default_html,
437 content_box_id=None, header='Select an item',
438 step_title='Search', remove_button_text='Remove',
439 null_display_value='None'):
440 """Create a widget wrapper.
441
442 :param context: The object that is being edited.
443- :param request: The request object.
444- :param interface_attribute: The attribute being edited. This should be
445+ :param exported_field: The attribute being edited. This should be
446 a field from an interface of the form ISomeInterface['fieldname']
447 :param default_html: Default display of attribute.
448 :param content_box_id: The HTML id to use for this widget. Automatically
449@@ -279,17 +232,9 @@
450 :param remove_button_text: Override default button text: "Remove"
451 :param null_display_value: This will be shown for a missing value
452 """
453- self.context = context
454- self.request = request
455+ super(InlineEditPickerWidget, self).__init__(
456+ context, exported_field, content_box_id)
457 self.default_html = default_html
458- self.interface_attribute = interface_attribute
459- self.attribute_name = interface_attribute.__name__
460-
461- if content_box_id is None:
462- self.content_box_id = self._generate_id()
463- else:
464- self.content_box_id = content_box_id
465-
466 self.header = header
467 self.step_title = step_title
468 self.remove_button_text = remove_button_text
469@@ -297,26 +242,29 @@
470
471 # JSON encoded attributes.
472 self.json_content_box_id = simplejson.dumps(self.content_box_id)
473- self.json_attribute = simplejson.dumps(self.attribute_name + '_link')
474+ self.json_attribute = simplejson.dumps(self.api_attribute + '_link')
475 self.json_vocabulary_name = simplejson.dumps(
476- self.interface_attribute.vocabularyName)
477- self.show_remove_button = not self.interface_attribute.required
478+ self.exported_field.vocabularyName)
479
480 @property
481 def config(self):
482- return simplejson.dumps(
483- dict(header=self.header, step_title=self.step_title,
484- remove_button_text=self.remove_button_text,
485- null_display_value=self.null_display_value,
486- show_remove_button=self.show_remove_button,
487- show_assign_me_button=self.show_assign_me_button,
488- show_search_box=self.show_search_box))
489+ return dict(
490+ header=self.header, step_title=self.step_title,
491+ remove_button_text=self.remove_button_text,
492+ null_display_value=self.null_display_value,
493+ show_remove_button=self.optional_field,
494+ show_assign_me_button=self.show_assign_me_button,
495+ show_search_box=self.show_search_box)
496+
497+ @property
498+ def json_config(self):
499+ return simplejson.dumps(self.config)
500
501 @cachedproperty
502 def vocabulary(self):
503 registry = getVocabularyRegistry()
504 return registry.get(
505- IVocabulary, self.interface_attribute.vocabularyName)
506+ IVocabulary, self.exported_field.vocabularyName)
507
508 @property
509 def show_search_box(self):
510@@ -329,43 +277,6 @@
511 user = getUtility(ILaunchBag).user
512 return user and user in vocabulary
513
514- @classmethod
515- def _generate_id(cls):
516- """Return a presumably unique id for this widget."""
517- cls.last_id += 1
518- return 'inline-picker-activator-id-%d' % cls.last_id
519-
520- @property
521- def json_resource_uri(self):
522- return simplejson.dumps(
523- canonical_url(
524- self.context, request=IWebServiceClientRequest(self.request),
525- path_only_if_possible=True))
526-
527- @property
528- def can_write(self):
529- if canWrite(self.context, self.attribute_name):
530- return True
531- else:
532- # The user may not have write access on the attribute itself, but
533- # the REST API may have a mutator method configured, such as
534- # transitionToAssignee.
535- #
536- # We look at the top of the annotation stack, since Ajax
537- # requests always go to the most recent version of the web
538- # service.
539- try:
540- exported_tag_stack = self.interface_attribute.getTaggedValue(
541- 'lazr.restful.exported')
542- except KeyError:
543- return False
544- mutator_info = exported_tag_stack.get('mutator_annotations')
545- if mutator_info is not None:
546- mutator_method, mutator_extra = mutator_info
547- return canAccess(self.context, mutator_method.__name__)
548- else:
549- return False
550-
551
552 def vocabulary_to_choice_edit_items(
553 vocab, css_class_prefix=None, disabled_items=None, as_json=False,
554@@ -412,3 +323,13 @@
555 else:
556 return items
557
558+
559+def standard_text_html_representation(value, linkify_text=True):
560+ """Render a string for html display.
561+
562+ For this we obfuscate email and render as html.
563+ """
564+ if value is None:
565+ return ''
566+ nomail = FormattersAPI(value).obfuscate_email()
567+ return FormattersAPI(nomail).text_to_html(linkify_text=linkify_text)
568
569=== renamed file 'lib/canonical/widgets/tests/test_inlineeditpickerwidget.py' => 'lib/lp/app/browser/tests/test_inlineeditpickerwidget.py'
570--- lib/canonical/widgets/tests/test_inlineeditpickerwidget.py 2011-01-20 21:16:36 +0000
571+++ lib/lp/app/browser/tests/test_inlineeditpickerwidget.py 2011-01-30 20:24:55 +0000
572@@ -9,7 +9,7 @@
573 from zope.schema import Choice
574
575 from canonical.testing.layers import DatabaseFunctionalLayer
576-from canonical.widgets.lazrjs import InlineEditPickerWidget
577+from lp.app.browser.lazrjs import InlineEditPickerWidget
578 from lp.testing import (
579 login_person,
580 TestCaseWithFactory,
581@@ -23,35 +23,35 @@
582 def getWidget(self, **kwargs):
583 class ITest(Interface):
584 test_field = Choice(**kwargs)
585- return InlineEditPickerWidget(None, None, ITest['test_field'], None)
586+ return InlineEditPickerWidget(None, ITest['test_field'], None)
587
588 def test_huge_vocabulary_is_searchable(self):
589 # Make sure that when given a field for a huge vocabulary, the picker
590 # is set to show the search box.
591 widget = self.getWidget(vocabulary='ValidPersonOrTeam')
592- self.assertTrue(widget.show_search_box)
593+ self.assertTrue(widget.config['show_search_box'])
594
595 def test_normal_vocabulary_is_not_searchable(self):
596 # Make sure that when given a field for a normal vocabulary, the picker
597 # is set to show the search box.
598 widget = self.getWidget(vocabulary='UserTeamsParticipation')
599- self.assertFalse(widget.show_search_box)
600+ self.assertFalse(widget.config['show_search_box'])
601
602 def test_required_fields_dont_have_a_remove_link(self):
603 widget = self.getWidget(vocabulary='ValidPersonOrTeam', required=True)
604- self.assertFalse(widget.show_remove_button)
605+ self.assertFalse(widget.config['show_remove_button'])
606
607 def test_optional_fields_do_have_a_remove_link(self):
608 widget = self.getWidget(
609 vocabulary='ValidPersonOrTeam', required=False)
610- self.assertTrue(widget.show_remove_button)
611+ self.assertTrue(widget.config['show_remove_button'])
612
613 def test_assign_me_exists_if_user_in_vocabulary(self):
614 widget = self.getWidget(vocabulary='ValidPersonOrTeam', required=True)
615 login_person(self.factory.makePerson())
616- self.assertTrue(widget.show_assign_me_button)
617+ self.assertTrue(widget.config['show_assign_me_button'])
618
619 def test_assign_me_not_shown_if_user_not_in_vocabulary(self):
620 widget = self.getWidget(vocabulary='TargetPPAs', required=True)
621 login_person(self.factory.makePerson())
622- self.assertFalse(widget.show_assign_me_button)
623+ self.assertFalse(widget.config['show_assign_me_button'])
624
625=== modified file 'lib/lp/app/browser/webservice.py'
626--- lib/lp/app/browser/webservice.py 2011-01-20 22:27:42 +0000
627+++ lib/lp/app/browser/webservice.py 2011-01-30 20:24:55 +0000
628@@ -18,7 +18,7 @@
629 )
630 from zope.schema.interfaces import IText
631
632-from lp.app.browser.stringformatter import FormattersAPI
633+from lp.app.browser.lazrjs import standard_text_html_representation
634 from lp.app.browser.tales import format_link
635
636
637@@ -44,12 +44,4 @@
638 @implementer(IFieldHTMLRenderer)
639 def text_xhtml_representation(context, field, request):
640 """Render text as XHTML using the webservice."""
641- formatter = FormattersAPI
642-
643- def renderer(value):
644- if value is None:
645- return ''
646- nomail = formatter(value).obfuscate_email()
647- return formatter(nomail).text_to_html()
648-
649- return renderer
650+ return standard_text_html_representation
651
652=== renamed file 'lib/canonical/launchpad/doc/lazr-js-widgets.txt' => 'lib/lp/app/doc/lazr-js-widgets.txt'
653--- lib/canonical/launchpad/doc/lazr-js-widgets.txt 2011-01-21 04:36:36 +0000
654+++ lib/lp/app/doc/lazr-js-widgets.txt 2011-01-30 20:24:55 +0000
655@@ -1,31 +1,40 @@
656 LAZR JS Wrappers
657 ================
658
659-The canonical.widgets.lazrjs module contains a bunch of wrapper
660-for widgets defined in Lazr-JS.
661+The lp.app.browser.lazrjs module contains several classes that simplify the
662+use of widgets defined in Lazr-JS.
663+
664+When rendering these widgets in page templates, all you need to do is 'call'
665+them. TAL will do this for you.
666+
667+ <tal:widget replace="structure view/nifty_widget"/>
668+
669
670 TextLineEditorWidget
671---------------------------
672+--------------------
673
674 We have a convenient wrapper for the inlineedit/editor JS widget in
675 TextLineEditorWidget.
676
677- >>> from canonical.launchpad.webapp.publisher import canonical_url
678- >>> from canonical.widgets.lazrjs import TextLineEditorWidget
679- >>> bug = factory.makeBug(title='My bug is > important')
680-
681-The wrapper takes as arguments the object and the attribute of the
682-object that is being edited, as well as the URL to use for editing when
683-when JS is turned off.
684-
685- >>> widget = TextLineEditorWidget(
686- ... bug, 'title', canonical_url(bug.bugtasks[0], view_name='+edit'))
687+ >>> from lp.app.browser.lazrjs import TextLineEditorWidget
688+
689+The bare minimum that you need to provide the widget is the object that you
690+are editing, and the exported field that is being edited, and a title for the
691+edit link that is rendered as the itle of the anchor so it shows on mouse
692+over, and the tag that surrounds the text.
693+
694+ >>> from lp.registry.interfaces.product import IProduct
695+ >>> product = factory.makeProduct(
696+ ... name='widget', title='Widgets > important')
697+ >>> title_field = IProduct['title']
698+ >>> title = 'Edit the title'
699+ >>> widget = TextLineEditorWidget(product, title_field, title, 'h1')
700
701 The widget is rendered by executing it, it prints out the attribute
702 content.
703
704 >>> print widget()
705- <h1 id="..."><span class="yui3-editable_text-text">My bug is &gt;
706+ <h1 id="edit-title"><span class="yui3-editable_text-text">Widgets &gt;
707 important</span>
708 </h1>
709
710@@ -33,97 +42,229 @@
711 the edit view that appears as well as a <script> tag that will change that
712 link into an AJAX control when JS is available:
713
714- >>> login('no-priv@canonical.com')
715+ >>> login_person(product.owner)
716 >>> print widget()
717- <h1 id="..."><span class="yui3-editable_text-text">My bug is &gt;
718+ <h1 id="edit-title"><span class="yui3-editable_text-text">Widgets &gt;
719 important</span>
720- <a href="http://bugs.launchpad.dev/.../+edit"
721- class="yui3-editable_text-trigger sprite edit"
722- ></a>
723+ <a class="yui3-editable_text-trigger sprite edit"
724+ href="http://launchpad.dev/widget/+edit"></a>
725 </h1>
726 <script>
727 ...
728 </script>
729
730-The id and title attribute to use can be passed via parameters to the
731-constructor:
732-
733- >>> widget = TextLineEditorWidget(
734- ... bug, 'title', canonical_url(bug.bugtasks[0], view_name='+edit'),
735- ... id="bug-title", title="Edit this summary")
736- >>> print widget()
737- <h1 id="bug-title">...
738- ...class="yui3-editable_text-trigger sprite edit"...
739-
740-The initial_value_override parameter is passed as a JSON-serialized value.
741-
742- >>> widget = TextLineEditorWidget(
743- ... bug, 'title', canonical_url(bug.bugtasks[0], view_name='+edit'),
744- ... id="bug-title", title="Edit this summary",
745- ... initial_value_override='This bug has no title')
746- >>> print widget()
747- <h1 id="bug-title">...
748- ...initial_value_override: "This bug has no title"...
749-
750-When the value isn't supplied it defaults to None, which is serialized as a
751-Javascript null.
752-
753- >>> widget = TextLineEditorWidget(
754- ... bug, 'title', canonical_url(bug.bugtasks[0], view_name='+edit'),
755- ... id="bug-title", title="Edit this summary")
756- >>> print widget()
757- <h1 id="bug-title">...
758- ...initial_value_override: null...
759+
760+Changing the tag
761+****************
762+
763+The id of the surrounding tag defaults to "edit-" followed by the name of the
764+attribute being edited. This can be overridden if needed using the
765+"content_box_id" constructor argument.
766+
767+ >>> span_widget = TextLineEditorWidget(
768+ ... product, title_field, title, 'span', content_box_id="overridden")
769+ >>> login(ANONYMOUS) # To not get the script tag rendered
770+ >>> print span_widget()
771+ <span id="overridden">...</span>
772+
773+
774+Changing the edit link
775+**********************
776+
777+When there is a logged in user that has edit rights on the field being edited,
778+the edit button is shown, and has a link that takes to the user to a normal
779+edit page if javascript is disabled. This link defaults to the '+edit' view
780+of the object being edited. This can be overridden in two ways:
781+ * change the 'edit_view' parameter to be a different view
782+ * provide an 'edit_url' to use instead
783+
784+ >>> print widget.edit_url
785+ http://launchpad.dev/widget/+edit
786+
787+ >>> diff_view = TextLineEditorWidget(
788+ ... product, title_field, title, 'h1', edit_view='+edit-people')
789+ >>> print diff_view.edit_url
790+ http://launchpad.dev/widget/+edit-people
791+
792+ >>> diff_url = TextLineEditorWidget(
793+ ... product, title_field, title, 'h1', edit_url='http://example.com/')
794+ >>> print diff_url.edit_url
795+ http://example.com/
796+
797+
798+Other nifty bits
799+****************
800+
801+You are also able to set the default text to show if the attribute has no
802+value using the 'default_text' parameter. The 'initial_value_override' is
803+used by the javascript widget to provide that text instead of the objects
804+value (of the default_text). The width of the field can also be specified
805+using the 'width' parameter (please use 'em's).
806+
807+For an example of these parameters, see the editor for a products programming
808+languages.
809+
810+
811+TextAreaEditorWidget
812+--------------------
813+
814+This widget renders a multi-line editor. Example uses of this widget are:
815+ * editing a bug's description
816+ * editing a merge proposal's commit message or description
817+ * editing a PPA's description
818+
819+ >>> from lp.app.browser.lazrjs import TextAreaEditorWidget
820+
821+The bare minimum that you need to provide the widget is the object that you
822+are editing, and the exported field that is being edited, and a title for the
823+edit link that is rendered as the itle of the anchor so it shows on mouse
824+over.
825+
826+ >>> eric = factory.makePerson(name='eric')
827+ >>> archive = factory.makeArchive(
828+ ... owner=eric, name='ppa', description='short description')
829+ >>> from lp.soyuz.interfaces.archive import IArchive
830+ >>> description = IArchive['description']
831+ >>> widget = TextAreaEditorWidget(archive, description, 'A title')
832+
833+With no-one logged in, there are no edit buttons.
834+
835+ >>> print widget()
836+ <div id="edit-description" class="lazr-multiline-edit">
837+ <div class="clearfix">
838+ <h2>A title</h2>
839+ </div>
840+ <div class="yui3-editable_text-text"><p>short description</p></div>
841+ </div>
842+
843+The initial text defaults to the value of the attribute, which is then passed
844+through two string formatter methods to obfuscate the email and then return
845+the text as HTML.
846+
847+When the logged in user has edit permission, the edit button is shown, and
848+javascript is written to the page to hook up the links to show the multiline
849+editor.
850+
851+ >>> login_person(eric)
852+ >>> print widget()
853+ <div id="edit-description" class="lazr-multiline-edit">
854+ <div class="clearfix">
855+ <div class="edit-controls">
856+ &nbsp;
857+ <a class="yui3-editable_text-trigger sprite edit"
858+ href="http://launchpad.dev/~eric/+archive/ppa/+edit"></a>
859+ </div>
860+ <h2>A title</h2>
861+ </div>
862+ <div class="yui3-editable_text-text"><p>short description</p></div>
863+ <script>...</script>
864+ </div>
865+
866+
867+Changing the edit link
868+**********************
869+
870+The edit link can be changed in exactly the same way as for the
871+TextLineEditorWidget above.
872+
873+
874+Hiding the widget for empty fields
875+**********************************
876+
877+Sometimes you don't want to show the widget if there is no content. An
878+example of this can be found in the branch merge proposal view for editing the
879+description or the commit message. This uses links when there is no content.
880+Ideally the interaction with the links would be encoded as part of the widget
881+itself, but that is an exercise left for another yak shaver.
882+
883+Hiding the widget is done by appending the "unseen" CSS class to the outer
884+tag.
885+
886+ >>> archive.description = None
887+ >>> from lp.services.propertycache import clear_property_cache
888+ >>> clear_property_cache(widget)
889+ >>> print widget()
890+ <div id="edit-description" class="lazr-multiline-edit unseen">
891+ ...
892+
893+This behaviour can be overridden by setting the "hide_empty" parameter to
894+False.
895+
896+ >>> widget = TextAreaEditorWidget(
897+ ... archive, description, 'A title', hide_empty=False)
898+ >>> print widget()
899+ <div id="edit-description" class="lazr-multiline-edit">
900+ ...
901+
902+
903+Not linkifying the text
904+***********************
905+
906+A part of the standard HTML rendering is to "linkify" links. That is, turn
907+words that look like hyperlinks into anchors. This is not always considered a
908+good idea as some spammers can create PPAs and link to other sites in the
909+descriptions. since the barrier to create a PPA is relatively low, we
910+restrict the linkability of some fields. The constructor provides a
911+"linkify_text" parameter that defaults to True. Set this to False to avoid
912+the linkification of text. See the IArchive['description'] editor for an example.
913
914
915 InlineEditPickerWidget
916 ----------------------
917
918-The InlineEditPickerWidget can be used for any interface attribute that
919-has a vocabulary defined for it.
920-
921- >>> from zope.component import getUtility
922- >>> from canonical.widgets.lazrjs import InlineEditPickerWidget
923- >>> from lp.bugs.interfaces.bugtask import IBugTask, IBugTaskSet
924- >>> bugtask = getUtility(IBugTaskSet).get(2)
925- >>> def create_inline_edit_picker_widget():
926- ... view = create_initialized_view(bugtask, '+index')
927- ... return InlineEditPickerWidget(
928- ... context=view.context,
929- ... request=view.request,
930- ... interface_attribute=IBugTask['assignee'],
931- ... default_html='default-html',
932- ... header='Change assignee',
933- ... step_title='Search for people or teams',
934- ... remove_button_text='Remove Assignee',
935- ... null_display_value='Nobody')
936-
937-An unauthenticated user cannot see the activator's edit button, which
938-is revealed by Y.lp.app.picker.addPickerPatcher.
939-
940- >>> login(ANONYMOUS)
941- >>> widget = create_inline_edit_picker_widget()
942- >>> print widget.can_write
943- False
944- >>> print widget()
945- <span id="inline-picker-activator-id-...">...
946- <div class="yui3-activator-message-box yui3-activator-hidden" />
947- </span>
948-
949-The foo.bar user can see the activator's edit button.
950-
951- >>> login('foo.bar@canonical.com')
952- >>> widget = create_inline_edit_picker_widget()
953- >>> print widget.can_write
954- True
955- >>> print widget()
956- <span id="inline-picker-activator-id-...">
957- ...<div class="yui3-activator-message-box yui3-activator-hidden" />
958- </span>
959- ...Y.lp.app.picker.addPickerPatcher(...
960-
961-The json_resource_uri is the canonical_url for the object in
962-WebServiceClientRequests.
963-
964- >>> print widget.json_resource_uri
965- "/firefox/+bug/1"
966+The InlineEditPickerWidget provides a simple way to create a popup selector
967+widget to choose items from a vocabulary.
968+
969+ >>> from lp.app.browser.lazrjs import InlineEditPickerWidget
970+
971+The bare minimum that you need to provide the widget is the object that you
972+are editing, and the exported field that is being edited, and the default
973+HTML representation of the field you are editing.
974+
975+Since most of the things that are being chosen are entities in Launchpad, and
976+most of those entities have URLs, a common approach is to have the default
977+HTML be a link to that entity. There is a utility function called format_link
978+that does the equivalent of the TALES expression 'obj/fmt:link'.
979+
980+ >>> from lp.app.browser.tales import format_link
981+ >>> default_text = format_link(archive.owner)
982+
983+The vocabulary is determined from the field passed in. If the vocabulary is a
984+huge vocabulary (one that provides a search), then the picker is shown with an
985+entry field to allow the user to search for an item. If the vocabulary is not
986+huge, the different items are shown in the normal paginated way for the user
987+to select.
988+
989+ >>> owner = IArchive['owner']
990+ >>> widget = InlineEditPickerWidget(archive, owner, default_text)
991+ >>> print widget()
992+ <span id="edit-owner">
993+ <span class="yui3-activator-data-box">
994+ <a...>Eric</a>
995+ </span>
996+ <button class="lazr-btn yui3-activator-act yui3-activator-hidden">
997+ Edit
998+ </button>
999+ <div class="yui3-activator-message-box yui3-activator-hidden" />
1000+ </span>
1001+
1002+
1003+Picker headings
1004+***************
1005+
1006+The picker has two headings that are almost always desirable to customize.
1007+ * "header" - Shown at the top of the picker
1008+ * "step_title" - Shown just below the green progress bar
1009+
1010+To customize these, pass the named parameters into the constructor of the
1011+widget.
1012+
1013+
1014+Other nifty links
1015+*****************
1016+
1017+If the logged in user is in the defined vocabulary (only occurs with people
1018+type vocabularies), a link is shown "Assign me'.
1019+
1020+If the field is optional, a "Remove" link is shown. The "Remove" text is
1021+customizable thought the "remove_button_text" parameter.
1022
1023=== modified file 'lib/lp/app/javascript/picker.js'
1024--- lib/lp/app/javascript/picker.js 2011-01-20 21:26:07 +0000
1025+++ lib/lp/app/javascript/picker.js 2011-01-30 20:24:55 +0000
1026@@ -39,6 +39,7 @@
1027 var remove_button_text = 'Remove';
1028 var null_display_value = 'None';
1029 var show_search_box = true;
1030+ resource_uri = LP.client.normalize_uri(resource_uri)
1031 var full_resource_uri = LP.client.get_absolute_uri(resource_uri);
1032 var current_context_uri = LP.client.cache['context']['self_link'];
1033 var editing_main_context = (full_resource_uri == current_context_uri);
1034
1035=== renamed file 'lib/canonical/widgets/templates/inline-picker.pt' => 'lib/lp/app/templates/inline-picker.pt'
1036--- lib/canonical/widgets/templates/inline-picker.pt 2011-01-20 20:24:10 +0000
1037+++ lib/lp/app/templates/inline-picker.pt 2011-01-30 20:24:55 +0000
1038@@ -20,7 +20,7 @@
1039 ${view/json_resource_uri},
1040 ${view/json_attribute},
1041 ${view/json_content_box_id},
1042- ${view/config});
1043+ ${view/json_config});
1044 }, window);
1045 });
1046 "/>
1047
1048=== added file 'lib/lp/app/templates/text-area-editor.pt'
1049--- lib/lp/app/templates/text-area-editor.pt 1970-01-01 00:00:00 +0000
1050+++ lib/lp/app/templates/text-area-editor.pt 2011-01-30 20:24:55 +0000
1051@@ -0,0 +1,40 @@
1052+<div tal:attributes="id view/content_box_id;
1053+ class view/tag_class">
1054+ <div class="clearfix">
1055+ <div class="edit-controls" tal:condition="view/can_write">
1056+ &nbsp;
1057+ <a tal:attributes="href view/edit_url"
1058+ class="yui3-editable_text-trigger sprite edit"></a>
1059+ </div>
1060+ <h2 tal:condition="view/title"
1061+ tal:content="view/title">the title</h2>
1062+ </div>
1063+ <div class="yui3-editable_text-text"
1064+ tal:content="structure view/value">some text</div>
1065+<script tal:condition="view/can_write"
1066+ tal:content="structure string:
1067+ LPS.use('lazr.editor', 'lp.client.plugins', function (Y) {
1068+ var widget = new Y.EditableText({
1069+ contentBox: ${view/widget_css_selector},
1070+ accept_empty: ${view/accept_empty},
1071+ multiline: true,
1072+ buttons: 'top'
1073+ });
1074+ widget.editor.plug({
1075+ fn: Y.lp.client.plugins.PATCHPlugin, cfg: {
1076+ patch: ${view/json_attribute},
1077+ resource: ${view/json_attribute_uri},
1078+ patch_field: true,
1079+ accept: 'application/xhtml+xml'
1080+ }});
1081+ if (!Y.UA.opera) {
1082+ widget.render();
1083+ }
1084+ var lpns = Y.namespace('lp');
1085+ if (!lpns.widgets) {
1086+ lpns.widgets = {};
1087+ }
1088+ lpns.widgets['${view/content_box_id}'] = widget;
1089+ });
1090+"/>
1091+</div>
1092
1093=== added file 'lib/lp/app/templates/text-line-editor.pt'
1094--- lib/lp/app/templates/text-line-editor.pt 1970-01-01 00:00:00 +0000
1095+++ lib/lp/app/templates/text-line-editor.pt 2011-01-30 20:24:55 +0000
1096@@ -0,0 +1,25 @@
1097+<tal:open-tag replace="structure view/open_tag"/><span
1098+ class="yui3-editable_text-text"><tal:text replace="view/value"/></span>
1099+ <a tal:condition="view/can_write"
1100+ tal:attributes="href view/edit_url"
1101+ class="yui3-editable_text-trigger sprite edit"></a>
1102+<tal:close-tag replace="structure view/close_tag"/>
1103+
1104+<script tal:condition="view/can_write"
1105+ tal:content="structure string:
1106+ LPS.use('lazr.editor', 'lp.client.plugins', function (Y) {
1107+ var widget = new Y.EditableText({
1108+ contentBox: ${view/widget_css_selector},
1109+ accept_empty: ${view/accept_empty},
1110+ width: ${view/width},
1111+ initial_value_override: ${view/initial_value_override}
1112+ });
1113+ widget.editor.plug({
1114+ fn: Y.lp.client.plugins.PATCHPlugin, cfg: {
1115+ patch: ${view/json_attribute},
1116+ resource: ${view/json_attribute_uri},
1117+ patch_field: true
1118+ }});
1119+ widget.render();
1120+ });
1121+"/>
1122
1123=== modified file 'lib/lp/bugs/browser/bugtask.py'
1124--- lib/lp/bugs/browser/bugtask.py 2011-01-18 20:29:21 +0000
1125+++ lib/lp/bugs/browser/bugtask.py 2011-01-30 20:24:55 +0000
1126@@ -163,11 +163,6 @@
1127 from canonical.lazr.interfaces import IObjectPrivacy
1128 from canonical.lazr.utils import smartquote
1129 from canonical.widgets.itemswidgets import LabeledMultiCheckBoxWidget
1130-from canonical.widgets.lazrjs import (
1131- TextAreaEditorWidget,
1132- TextLineEditorWidget,
1133- vocabulary_to_choice_edit_items,
1134- )
1135 from canonical.widgets.project import ProjectScopeWidget
1136 from lp.answers.interfaces.questiontarget import IQuestionTarget
1137 from lp.app.browser.launchpadform import (
1138@@ -176,8 +171,12 @@
1139 LaunchpadEditFormView,
1140 LaunchpadFormView,
1141 )
1142+from lp.app.browser.lazrjs import (
1143+ TextAreaEditorWidget,
1144+ TextLineEditorWidget,
1145+ vocabulary_to_choice_edit_items,
1146+ )
1147 from lp.app.browser.tales import (
1148- FormattersAPI,
1149 ObjectImageDisplayAPI,
1150 PersonFormatterAPI,
1151 )
1152@@ -679,8 +678,8 @@
1153 canonical_url(self.context.bug.default_bugtask))
1154
1155 self.bug_title_edit_widget = TextLineEditorWidget(
1156- bug, 'title', canonical_url(self.context, view_name='+edit'),
1157- id="bug-title", title="Edit this summary")
1158+ bug, IBug['title'], "Edit this summary", 'h1',
1159+ edit_url=canonical_url(self.context, view_name='+edit'))
1160
1161 # XXX 2010-10-05 gmb bug=655597:
1162 # This line of code keeps the view's query count down,
1163@@ -1035,16 +1034,12 @@
1164 @property
1165 def bug_description_html(self):
1166 """The bug's description as HTML."""
1167- formatter = FormattersAPI
1168- hide_email = formatter(self.context.bug.description).obfuscate_email()
1169- description = formatter(hide_email).text_to_html()
1170+ bug = self.context.bug
1171+ description = IBug['description']
1172+ title = "Bug Description"
1173+ edit_url = canonical_url(self.context, view_name='+edit')
1174 return TextAreaEditorWidget(
1175- self.context.bug,
1176- 'description',
1177- canonical_url(self.context, view_name='+edit'),
1178- id="edit-description",
1179- title="Bug Description",
1180- value=description)
1181+ bug, description, title, edit_url=edit_url)
1182
1183 @property
1184 def bug_heat_html(self):
1185
1186=== modified file 'lib/lp/bugs/templates/bugtask-index.pt'
1187--- lib/lp/bugs/templates/bugtask-index.pt 2010-12-21 15:07:26 +0000
1188+++ lib/lp/bugs/templates/bugtask-index.pt 2011-01-30 20:24:55 +0000
1189@@ -103,9 +103,7 @@
1190 <tal:description
1191 define="global description context/bug/description/fmt:obfuscate-email/fmt:text-to-html" />
1192
1193- <div id="edit-description" class="lazr-multiline-edit"
1194- tal:content="structure view/bug_description_html"
1195- />
1196+ <tal:widget replace="structure view/bug_description_html"/>
1197
1198 <div style="margin:-20px 0 20px 5px" class="clearfix">
1199 <span tal:condition="view/wasDescriptionModified" class="discreet"
1200
1201=== modified file 'lib/lp/bugs/windmill/tests/test_mark_duplicate.py'
1202--- lib/lp/bugs/windmill/tests/test_mark_duplicate.py 2010-11-23 14:18:07 +0000
1203+++ lib/lp/bugs/windmill/tests/test_mark_duplicate.py 2011-01-30 20:24:55 +0000
1204@@ -115,7 +115,7 @@
1205 client.click(link=u'bug #1')
1206 client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
1207 client.waits.forElement(
1208- id=u'bug-title', timeout=constants.FOR_ELEMENT)
1209+ id=u'edit-title', timeout=constants.FOR_ELEMENT)
1210
1211 # Make sure all js loads are complete before trying the next test.
1212 client.waits.forElement(
1213
1214=== modified file 'lib/lp/code/browser/branch.py'
1215--- lib/lp/code/browser/branch.py 2011-01-28 02:16:33 +0000
1216+++ lib/lp/code/browser/branch.py 2011-01-30 20:24:55 +0000
1217@@ -100,13 +100,13 @@
1218 from canonical.lazr.utils import smartquote
1219 from canonical.widgets.suggestion import TargetBranchWidget
1220 from canonical.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
1221-from canonical.widgets.lazrjs import vocabulary_to_choice_edit_items
1222 from lp.app.browser.launchpadform import (
1223 action,
1224 custom_widget,
1225 LaunchpadEditFormView,
1226 LaunchpadFormView,
1227 )
1228+from lp.app.browser.lazrjs import vocabulary_to_choice_edit_items
1229 from lp.app.errors import NotFoundError
1230 from lp.blueprints.interfaces.specificationbranch import ISpecificationBranch
1231 from lp.bugs.interfaces.bug import IBugSet
1232
1233=== modified file 'lib/lp/code/browser/branchmergeproposal.py'
1234--- lib/lp/code/browser/branchmergeproposal.py 2011-01-18 20:49:35 +0000
1235+++ lib/lp/code/browser/branchmergeproposal.py 2011-01-30 20:24:55 +0000
1236@@ -87,12 +87,11 @@
1237 LaunchpadEditFormView,
1238 LaunchpadFormView,
1239 )
1240-from lp.app.browser.tales import DateTimeFormatterAPI
1241-from canonical.widgets.lazrjs import (
1242+from lp.app.browser.lazrjs import (
1243 TextAreaEditorWidget,
1244 vocabulary_to_choice_edit_items,
1245 )
1246-from lp.app.browser.stringformatter import FormattersAPI
1247+from lp.app.browser.tales import DateTimeFormatterAPI
1248 from lp.code.adapters.branch import BranchMergeProposalDelta
1249 from lp.code.browser.codereviewcomment import CodeReviewDisplayComment
1250 from lp.code.browser.decorations import (
1251@@ -691,40 +690,36 @@
1252 for bug in self.context.related_bugs]
1253
1254 @property
1255+ def edit_description_link_class(self):
1256+ if self.context.description:
1257+ return "unseen"
1258+ else:
1259+ return ""
1260+
1261+ @property
1262 def description_html(self):
1263 """The description as widget HTML."""
1264- description = self.context.description
1265- if description is None:
1266- description = ''
1267- formatter = FormattersAPI
1268- hide_email = formatter(description).obfuscate_email()
1269- description = formatter(hide_email).text_to_html()
1270+ mp = self.context
1271+ description = IBranchMergeProposal['description']
1272+ title = "Description of the Change"
1273 return TextAreaEditorWidget(
1274- self.context,
1275- 'description',
1276- canonical_url(self.context, view_name='+edit-description'),
1277- id="edit-description",
1278- title="Description of the Change",
1279- value=description,
1280- accept_empty=True)
1281+ mp, description, title, edit_view='+edit-description')
1282+
1283+ @property
1284+ def edit_commit_message_link_class(self):
1285+ if self.context.commit_message:
1286+ return "unseen"
1287+ else:
1288+ return ""
1289
1290 @property
1291 def commit_message_html(self):
1292 """The commit message as widget HTML."""
1293- commit_message = self.context.commit_message
1294- if commit_message is None:
1295- commit_message = ''
1296- formatter = FormattersAPI
1297- hide_email = formatter(commit_message).obfuscate_email()
1298- commit_message = formatter(hide_email).text_to_html()
1299+ mp = self.context
1300+ commit_message = IBranchMergeProposal['commit_message']
1301+ title = "Commit Message"
1302 return TextAreaEditorWidget(
1303- self.context,
1304- 'commit_message',
1305- canonical_url(self.context, view_name='+edit-commit-message'),
1306- id="edit-commit_message",
1307- title="Commit Message",
1308- value=commit_message,
1309- accept_empty=True)
1310+ mp, commit_message, title, edit_view='+edit-commit-message')
1311
1312 @property
1313 def status_config(self):
1314
1315=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
1316--- lib/lp/code/browser/sourcepackagerecipe.py 2011-01-20 20:50:45 +0000
1317+++ lib/lp/code/browser/sourcepackagerecipe.py 2011-01-30 20:24:55 +0000
1318@@ -62,7 +62,6 @@
1319 LabeledMultiCheckBoxWidget,
1320 LaunchpadRadioWidget,
1321 )
1322-from canonical.widgets.lazrjs import InlineEditPickerWidget
1323 from canonical.widgets.suggestion import RecipeOwnerWidget
1324 from lp.app.browser.launchpadform import (
1325 action,
1326@@ -72,9 +71,8 @@
1327 LaunchpadFormView,
1328 render_radio_widget_part,
1329 )
1330-from lp.app.browser.tales import (
1331- format_link,
1332- )
1333+from lp.app.browser.lazrjs import InlineEditPickerWidget
1334+from lp.app.browser.tales import format_link
1335 from lp.code.errors import (
1336 BuildAlreadyPending,
1337 NoSuchBranch,
1338@@ -235,9 +233,8 @@
1339 @property
1340 def person_picker(self):
1341 return InlineEditPickerWidget(
1342- self.context, self.request, ISourcePackageRecipe['owner'],
1343+ self.context, ISourcePackageRecipe['owner'],
1344 format_link(self.context.owner),
1345- content_box_id='recipe-owner',
1346 header='Change owner',
1347 step_title='Select a new owner')
1348
1349@@ -248,11 +245,9 @@
1350 initial_html = 'None'
1351 else:
1352 initial_html = format_link(ppa)
1353+ field = ISourcePackageEditSchema['daily_build_archive']
1354 return InlineEditPickerWidget(
1355- self.context, self.request,
1356- ISourcePackageAddSchema['daily_build_archive'],
1357- initial_html,
1358- content_box_id='recipe-ppa',
1359+ self.context, field, initial_html,
1360 header='Change daily build archive',
1361 step_title='Select a PPA')
1362
1363
1364=== modified file 'lib/lp/code/templates/branchmergeproposal-index.pt'
1365--- lib/lp/code/templates/branchmergeproposal-index.pt 2010-10-10 21:54:16 +0000
1366+++ lib/lp/code/templates/branchmergeproposal-index.pt 2011-01-30 20:24:55 +0000
1367@@ -95,45 +95,23 @@
1368 </div>
1369
1370 <div id="commit-message" class="yui-g">
1371- <tal:no-commit-message condition="not: context/commit_message">
1372- <div tal:define="link menu/set_commit_message"
1373- tal:condition="link/enabled"
1374- tal:content="structure link/render">
1375- Set commit message
1376- </div>
1377- <div id="edit-commit_message" class="lazr-multiline-edit unseen"
1378- tal:content="structure view/commit_message_html"/>
1379- </tal:no-commit-message>
1380- <tal:has-commit-message condition="context/commit_message">
1381- <div tal:define="link menu/set_commit_message"
1382- tal:condition="link/enabled"
1383- tal:content="structure link/render" class="unseen">
1384- Set commit message
1385- </div>
1386- <div id="edit-commit_message" class="lazr-multiline-edit"
1387- tal:content="structure view/commit_message_html"/>
1388- </tal:has-commit-message>
1389+ <div tal:define="link menu/set_commit_message"
1390+ tal:condition="link/enabled"
1391+ tal:content="structure link/render"
1392+ tal:attributes="class view/edit_commit_message_link_class">
1393+ Set commit message
1394+ </div>
1395+ <tal:widget replace="structure view/commit_message_html"/>
1396 </div>
1397
1398 <div id="description" class="yui-g">
1399- <tal:no-description condition="not: context/description">
1400- <div tal:define="link menu/set_description"
1401- tal:condition="link/enabled"
1402- tal:content="structure link/render">
1403- Set description
1404- </div>
1405- <div id="edit-description" class="lazr-multiline-edit unseen"
1406- tal:content="structure view/description_html"/>
1407- </tal:no-description>
1408- <tal:has-description condition="context/description">
1409- <div tal:define="link menu/set_description"
1410- tal:condition="link/enabled"
1411- tal:content="structure link/render" class="unseen">
1412- Set description
1413- </div>
1414- <div id="edit-description" class="lazr-multiline-edit"
1415- tal:content="structure view/description_html"/>
1416- </tal:has-description>
1417+ <div tal:define="link menu/set_description"
1418+ tal:condition="link/enabled"
1419+ tal:content="structure link/render"
1420+ tal:attributes="class view/edit_description_link_class">
1421+ Set description
1422+ </div>
1423+ <tal:widget replace="structure view/description_html"/>
1424 </div>
1425
1426 <div class="yui-g" tal:condition="view/has_bug_or_spec">
1427
1428=== modified file 'lib/lp/registry/browser/product.py'
1429--- lib/lp/registry/browser/product.py 2011-01-20 21:41:13 +0000
1430+++ lib/lp/registry/browser/product.py 2011-01-30 20:24:55 +0000
1431@@ -122,7 +122,6 @@
1432 CheckBoxMatrixWidget,
1433 LaunchpadRadioWidget,
1434 )
1435-from canonical.widgets.lazrjs import TextLineEditorWidget
1436 from canonical.widgets.popup import PersonPickerWidget
1437 from canonical.widgets.product import (
1438 GhostWidget,
1439@@ -143,6 +142,7 @@
1440 ReturnToReferrerMixin,
1441 safe_action,
1442 )
1443+from lp.app.browser.lazrjs import TextLineEditorWidget
1444 from lp.app.browser.tales import MenuAPI
1445 from lp.app.enums import ServiceUsage
1446 from lp.app.errors import NotFoundError
1447@@ -988,25 +988,21 @@
1448
1449 def initialize(self):
1450 self.status_message = None
1451+ product = self.context
1452+ title_field = IProduct['title']
1453+ title = "Edit this title"
1454 self.title_edit_widget = TextLineEditorWidget(
1455- self.context, 'title',
1456- canonical_url(self.context, view_name='+edit'),
1457- id="product-title", title="Edit this title")
1458+ product, title_field, title, 'h1')
1459+ programming_lang = IProduct['programminglang']
1460+ title = 'Edit programming languages'
1461+ additional_arguments = {'width': '9em'}
1462 if self.context.programminglang is None:
1463- additional_arguments = dict(
1464+ additional_arguments.update(dict(
1465 default_text='Not yet specified',
1466 initial_value_override='',
1467- )
1468- else:
1469- additional_arguments = {}
1470+ ))
1471 self.languages_edit_widget = TextLineEditorWidget(
1472- self.context, 'programminglang',
1473- canonical_url(self.context, view_name='+edit'),
1474- id='programminglang', title='Edit programming languages',
1475- tag='span', public_attribute='programming_language',
1476- accept_empty=True,
1477- width='9em',
1478- **additional_arguments)
1479+ product, programming_lang, title, 'span', **additional_arguments)
1480 self.show_programming_languages = bool(
1481 self.context.programminglang or
1482 check_permission('launchpad.Edit', self.context))
1483
1484=== modified file 'lib/lp/registry/windmill/tests/test_product.py'
1485--- lib/lp/registry/windmill/tests/test_product.py 2010-10-18 12:56:47 +0000
1486+++ lib/lp/registry/windmill/tests/test_product.py 2011-01-30 20:24:55 +0000
1487@@ -24,7 +24,7 @@
1488 def test_title_inline_edit(self):
1489 test = widgets.InlineEditorWidgetTest(
1490 url='%s/firefox' % RegistryWindmillLayer.base_url,
1491- widget_id='product-title',
1492+ widget_id='edit-title',
1493 expected_value='Mozilla Firefox',
1494 new_value='The awesome Mozilla Firefox',
1495 name='test_title_inline_edit',
1496@@ -35,7 +35,7 @@
1497 def test_programming_languages_edit(self):
1498 test = widgets.InlineEditorWidgetTest(
1499 url='%s/firefox' % RegistryWindmillLayer.base_url,
1500- widget_id='programminglang',
1501+ widget_id='edit-programminglang',
1502 widget_tag='span',
1503 expected_value='Not yet specified',
1504 new_value='C++',
1505
1506=== modified file 'lib/lp/soyuz/browser/archive.py'
1507--- lib/lp/soyuz/browser/archive.py 2011-01-29 07:41:50 +0000
1508+++ lib/lp/soyuz/browser/archive.py 2011-01-30 20:24:55 +0000
1509@@ -87,10 +87,6 @@
1510 LaunchpadDropdownWidget,
1511 LaunchpadRadioWidget,
1512 )
1513-from canonical.widgets.lazrjs import (
1514- TextAreaEditorWidget,
1515- TextLineEditorWidget,
1516- )
1517 from canonical.widgets.textwidgets import StrippedTextWidget
1518 from lp.app.browser.launchpadform import (
1519 action,
1520@@ -98,6 +94,10 @@
1521 LaunchpadEditFormView,
1522 LaunchpadFormView,
1523 )
1524+from lp.app.browser.lazrjs import (
1525+ TextAreaEditorWidget,
1526+ TextLineEditorWidget,
1527+ )
1528 from lp.app.browser.stringformatter import FormattersAPI
1529 from lp.app.errors import NotFoundError
1530 from lp.buildmaster.enums import BuildStatus
1531@@ -863,11 +863,9 @@
1532
1533 @property
1534 def displayname_edit_widget(self):
1535- widget = TextLineEditorWidget(
1536- self.context, 'displayname',
1537- canonical_url(self.context, view_name='+edit'),
1538- id="displayname", title="Edit the displayname")
1539- return widget
1540+ display_name = IArchive['displayname']
1541+ title = "Edit the displayname"
1542+ return TextLineEditorWidget(self.context, display_name, title, 'h1')
1543
1544 @property
1545 def sources_list_entries(self):
1546@@ -897,25 +895,17 @@
1547 @property
1548 def archive_description_html(self):
1549 """The archive's description as HTML."""
1550- formatter = FormattersAPI
1551-
1552- description = self.context.description
1553- if description is not None:
1554- description = formatter(description).obfuscate_email()
1555- else:
1556- description = ''
1557-
1558+ linkify_text = True
1559 if self.context.is_ppa:
1560- description = formatter(description).text_to_html(
1561- linkify_text=(not self.context.owner.is_probationary))
1562-
1563+ linkify_text = not self.context.owner.is_probationary
1564+ archive = self.context
1565+ description = IArchive['description']
1566+ title = self.archive_label + " description"
1567+ # Don't hide empty archive descriptions. Even though the interface
1568+ # says they are required, the model doesn't.
1569 return TextAreaEditorWidget(
1570- self.context,
1571- 'description',
1572- canonical_url(self.context, view_name='+edit'),
1573- id="edit-description",
1574- title=self.archive_label + " description",
1575- value=description)
1576+ archive, description, title, hide_empty=False,
1577+ linkify_text=linkify_text)
1578
1579 @cachedproperty
1580 def latest_updates(self):
1581
1582=== modified file 'lib/lp/soyuz/stories/soyuz/xx-distribution-archives.txt'
1583--- lib/lp/soyuz/stories/soyuz/xx-distribution-archives.txt 2010-10-17 15:44:08 +0000
1584+++ lib/lp/soyuz/stories/soyuz/xx-distribution-archives.txt 2011-01-30 20:24:55 +0000
1585@@ -199,8 +199,9 @@
1586 Copy archive disabled-security-rebuild for No Privileges Person : Ubuntu
1587
1588 >>> main_content = find_main_content(nopriv_browser.contents)
1589- >>> print main_content.h1
1590- <h1 id="displayname">...</h1>
1591+ >>> print main_content.h1['class']
1592+ Traceback (most recent call last):
1593+ KeyError: 'class'
1594
1595 >>> print first_tag_by_class(nopriv_browser.contents, 'warning message')
1596 None
1597
1598=== modified file 'lib/lp/soyuz/templates/archive-index.pt'
1599--- lib/lp/soyuz/templates/archive-index.pt 2010-11-10 15:33:47 +0000
1600+++ lib/lp/soyuz/templates/archive-index.pt 2011-01-30 20:24:55 +0000
1601@@ -31,9 +31,7 @@
1602 This archive has been disabled.
1603 </p>
1604
1605- <div id="edit-description" class="lazr-multiline-edit"
1606- tal:content="structure view/archive_description_html"
1607- />
1608+ <tal:widget replace="structure view/archive_description_html"/>
1609 </div>
1610
1611 <tal:ppa-upload-hint condition="context/is_ppa">
1612
1613=== modified file 'lib/lp/soyuz/windmill/tests/test_ppainlineedit.py'
1614--- lib/lp/soyuz/windmill/tests/test_ppainlineedit.py 2010-10-18 12:56:47 +0000
1615+++ lib/lp/soyuz/windmill/tests/test_ppainlineedit.py 2011-01-30 20:24:55 +0000
1616@@ -20,7 +20,7 @@
1617
1618 ppa_displayname_inline_edit_test = widgets.InlineEditorWidgetTest(
1619 url='%s/~cprov/+archive/ppa' % SoyuzWindmillLayer.base_url,
1620- widget_id='displayname',
1621+ widget_id='edit-displayname',
1622 expected_value='PPA for Celso Providelo',
1623 new_value="Celso's default PPA",
1624 name='test_ppa_displayname_inline_edit',
1625
1626=== modified file 'lib/lp/translations/browser/hastranslationimports.py'
1627--- lib/lp/translations/browser/hastranslationimports.py 2010-11-23 23:22:27 +0000
1628+++ lib/lp/translations/browser/hastranslationimports.py 2011-01-30 20:24:55 +0000
1629@@ -29,13 +29,13 @@
1630 from canonical.launchpad.webapp.authorization import check_permission
1631 from canonical.launchpad.webapp.batching import TableBatchNavigator
1632 from canonical.launchpad.webapp.vocabulary import ForgivingSimpleVocabulary
1633-from canonical.widgets.lazrjs import vocabulary_to_choice_edit_items
1634 from lp.app.browser.launchpadform import (
1635 action,
1636 custom_widget,
1637 LaunchpadFormView,
1638 safe_action,
1639 )
1640+from lp.app.browser.lazrjs import vocabulary_to_choice_edit_items
1641 from lp.app.errors import UnexpectedFormData
1642 from lp.registry.interfaces.distribution import IDistribution
1643 from lp.registry.interfaces.pillar import IPillarNameSet