← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~thumper/launchpad/daily-ajax into lp:launchpad

 

Tim Penhey has proposed merging lp:~thumper/launchpad/daily-ajax into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #673530 Inline editing for recipes
  https://bugs.launchpad.net/bugs/673530

For more details, see:
https://code.launchpad.net/~thumper/launchpad/daily-ajax/+merge/49295

The primary motivation of this branch is to add a simple popup
chooser for the build frequency.  I did this by creating a
BooleanChoiceWidget.

lib/canonical/launchpad/icing/style-3-0.css.in
 - Since the value string of the choice is clickable, we should
   make the cursor change, and underline it as the user hovers

The lazr-js-widgets documentation is updated to cover the new
widget.

I also added an in-page help link about the build schedule.

-- 
https://code.launchpad.net/~thumper/launchpad/daily-ajax/+merge/49295
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~thumper/launchpad/daily-ajax into lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/style-3-0.css.in'
--- lib/canonical/launchpad/icing/style-3-0.css.in	2011-02-09 10:37:39 +0000
+++ lib/canonical/launchpad/icing/style-3-0.css.in	2011-02-11 01:53:14 +0000
@@ -960,6 +960,12 @@
 .widget-hd.js-action:hover {
     text-decoration: underline;
     }
+
+.yui3-ichoicesource-content .value:hover {
+    text-decoration: underline;
+    cursor:pointer;
+    }
+
 .error.message, .warning.message, .informational.message {
     border: solid #666;
     border-width: 1px 2px 2px 1px;

=== modified file 'lib/lp/app/browser/lazrjs.py'
--- lib/lp/app/browser/lazrjs.py	2011-01-28 01:09:35 +0000
+++ lib/lp/app/browser/lazrjs.py	2011-02-11 01:53:14 +0000
@@ -5,6 +5,7 @@
 
 __metaclass__ = type
 __all__ = [
+    'BooleanChoiceWidget',
     'InlineEditPickerWidget',
     'standard_text_html_representation',
     'TextAreaEditorWidget',
@@ -61,6 +62,7 @@
             if mutator_info is not None:
                 mutator_method, mutator_extra = mutator_info
                 self.mutator_method_name = mutator_method.__name__
+        self.json_attribute = simplejson.dumps(self.api_attribute)
 
     @property
     def resource_uri(self):
@@ -90,19 +92,27 @@
             return False
 
 
-class TextWidgetBase(WidgetBase):
+class EditableWidgetBase(WidgetBase):
+    """Adds an edit_url property to WidgetBase."""
+
+    def __init__(self, context, exported_field, content_box_id,
+                 edit_view, edit_url):
+        super(EditableWidgetBase, self).__init__(
+            context, exported_field, content_box_id)
+        if edit_url is None:
+            edit_url = canonical_url(self.context, view_name=edit_view)
+        self.edit_url = edit_url
+
+
+class TextWidgetBase(EditableWidgetBase):
     """Abstract base for the single and multiline text editor widgets."""
 
     def __init__(self, context, exported_field, title, content_box_id,
                  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)
-        self.edit_url = edit_url
+            context, exported_field, content_box_id, edit_view, edit_url)
         self.accept_empty = simplejson.dumps(self.optional_field)
         self.title = title
-        self.json_attribute = simplejson.dumps(self.api_attribute)
         self.widget_css_selector = simplejson.dumps('#' + self.content_box_id)
 
     @property
@@ -110,7 +120,19 @@
         return simplejson.dumps(self.resource_uri + '/' + self.api_attribute)
 
 
-class TextLineEditorWidget(TextWidgetBase):
+class DefinedTagMixin:
+    """Mixin class to define open and closing tags."""
+
+    @property
+    def open_tag(self):
+        return '<%s id="%s">' % (self.tag, self.content_box_id)
+
+    @property
+    def close_tag(self):
+        return '</%s>' % self.tag
+
+
+class TextLineEditorWidget(TextWidgetBase, DefinedTagMixin):
     """Wrapper for the lazr-js inlineedit/editor.js widget."""
 
     __call__ = ViewPageTemplateFile('../templates/text-line-editor.pt')
@@ -146,14 +168,6 @@
         self.width = simplejson.dumps(width)
 
     @property
-    def open_tag(self):
-        return '<%s id="%s">' % (self.tag, self.content_box_id)
-
-    @property
-    def close_tag(self):
-        return '</%s>' % self.tag
-
-    @property
     def value(self):
         text = getattr(self.context, self.attribute_name, self.default_text)
         if text is None:
@@ -333,3 +347,62 @@
         return ''
     nomail = FormattersAPI(value).obfuscate_email()
     return FormattersAPI(nomail).text_to_html(linkify_text=linkify_text)
+
+
+class BooleanChoiceWidget(EditableWidgetBase, DefinedTagMixin):
+    """A ChoiceEdit for a boolean field."""
+
+    __call__ = ViewPageTemplateFile('../templates/boolean-choice-widget.pt')
+
+    def __init__(self, context, exported_field,
+                 tag, false_text, true_text, prefix=None,
+                 edit_view="+edit", edit_url=None,
+                 content_box_id=None, header='Select an item'):
+        """Create a widget wrapper.
+
+        :param context: The object that is being edited.
+        :param exported_field: The attribute being edited. This should be
+            a field from an interface of the form ISomeInterface['fieldname']
+        :param tag: The HTML tag to use.
+        :param false_text: The string to show for a false value.
+        :param true_text: The string to show for a true value.
+        :param prefix: Optional text to show before the value.
+        :param edit_view: The view name to use to generate the edit_url if
+            one is not specified.
+        :param edit_url: The URL to use for editing when the user isn't logged
+            in and when JS is off.  Defaults to the edit_view on the context.
+        :param content_box_id: The HTML id to use for this widget. Automatically
+            generated if this is not provided.
+        :param header: The large text at the top of the choice popup.
+        """
+        super(BooleanChoiceWidget, self).__init__(
+            context, exported_field, content_box_id, edit_view, edit_url)
+        self.header = header
+        self.tag = tag
+        self.prefix = prefix
+        self.true_text = true_text
+        self.false_text = false_text
+        self.current_value = getattr(self.context, self.attribute_name)
+
+    @property
+    def value(self):
+        if self.current_value:
+            return self.true_text
+        else:
+            return self.false_text
+
+    @property
+    def config(self):
+        return dict(
+            contentBox='#'+self.content_box_id,
+            value=self.current_value,
+            title=self.header,
+            items=[
+                dict(name=self.true_text, value=True, style='', help='',
+                     disabled=False),
+                dict(name=self.false_text, value=False, style='', help='',
+                     disabled=False)])
+
+    @property
+    def json_config(self):
+        return simplejson.dumps(self.config)

=== modified file 'lib/lp/app/doc/lazr-js-widgets.txt'
--- lib/lp/app/doc/lazr-js-widgets.txt	2011-01-28 00:54:11 +0000
+++ lib/lp/app/doc/lazr-js-widgets.txt	2011-02-11 01:53:14 +0000
@@ -268,3 +268,61 @@
 
 If the field is optional, a "Remove" link is shown.  The "Remove" text is
 customizable thought the "remove_button_text" parameter.
+
+
+BooleanChoiceWidget
+-------------------
+
+This widget provides a simple popup with two options for the user to choose
+from.
+
+    >>> from lp.app.browser.lazrjs import BooleanChoiceWidget
+
+As with the other widgets, this one requires a context object and a Bool type
+field.  The rendering of the widget hooks up to the lazr ChoiceSource with the
+standard patch plugin.
+
+The surrounding tag is customisable, and a prefix may be given.  The prefix is
+passed through to the ChoiceSource and is rendered as part of the widget, but
+isn't updated when the value changes.
+
+If the user does not have edit rights, the widget just renders the text based
+on the current value of the field on the object:
+
+    >>> login(ANONYMOUS)
+    >>> from lp.registry.interfaces.person import IPerson
+    >>> hide_email = IPerson['hide_email_addresses']
+    >>> widget = BooleanChoiceWidget(
+    ...     eric, hide_email, 'span',
+    ...     false_text="Don't hide it",
+    ...     true_text="Keep it secret",
+    ...     prefix="My email: ")
+    >>> print widget()
+    <span id="edit-hide_email_addresses">
+    My email: <span class="value">Don't hide it</span>
+    </span>
+
+If the user has edit rights, an edit icon is rendered and some javascript is
+rendered to hook up the widget.
+
+    >>> login_person(eric)
+    >>> print widget()
+    <span id="edit-hide_email_addresses">
+    My email: <span class="value">Don't hide it</span>
+      <span>
+        &nbsp;
+        <a class="editicon sprite edit"
+           href="http://launchpad.dev/~eric/+edit";></a>
+      </span>
+    </span>
+    <script>
+    LPS.use('lp.app.choice', function(Y) {
+    ...
+    </script>
+
+
+Changing the edit link
+**********************
+
+The edit link can be changed in exactly the same way as for the
+TextLineEditorWidget above.

=== added file 'lib/lp/app/javascript/choice.js'
--- lib/lp/app/javascript/choice.js	1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/choice.js	2011-02-11 01:53:14 +0000
@@ -0,0 +1,21 @@
+YUI.add('lp.app.choice', function(Y) {
+
+var namespace = Y.namespace('lp.app.choice');
+
+namespace.addBinaryChoice = function(config, resource_uri, attribute) {
+
+  if (Y.UA.ie) {
+    return;
+  }
+
+  var widget = new Y.ChoiceSource(config);
+  widget.plug({
+    fn: Y.lp.client.plugins.PATCHPlugin,
+    cfg: {
+      patch: attribute,
+      resource: resource_uri}});
+  widget.render();
+};
+
+
+}, "0.1", {"requires": ["lazr.choiceedit", "lp.client.plugins"]});

=== added file 'lib/lp/app/templates/boolean-choice-widget.pt'
--- lib/lp/app/templates/boolean-choice-widget.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/app/templates/boolean-choice-widget.pt	2011-02-11 01:53:14 +0000
@@ -0,0 +1,18 @@
+<tal:open-tag replace="structure view/open_tag"/>
+<tal:prefix replace="view/prefix"/><span class="value" tal:content="view/value">Unset</span>
+    <span tal:condition="view/can_write">
+      &nbsp;
+      <a tal:attributes="href view/edit_url"
+         class="editicon sprite edit"></a>
+    </span>
+<tal:close-tag replace="structure view/close_tag"/>
+
+<script tal:condition="view/can_write"
+        tal:content="structure string:
+LPS.use('lp.app.choice', function(Y) {
+    Y.lp.app.choice.addBinaryChoice(
+        ${view/json_config},
+        ${view/json_resource_uri},
+        ${view/json_attribute});
+});
+"/>

=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
--- lib/lp/code/browser/sourcepackagerecipe.py	2011-02-03 05:23:55 +0000
+++ lib/lp/code/browser/sourcepackagerecipe.py	2011-02-11 01:53:14 +0000
@@ -69,7 +69,10 @@
     LaunchpadFormView,
     render_radio_widget_part,
     )
-from lp.app.browser.lazrjs import InlineEditPickerWidget
+from lp.app.browser.lazrjs import (
+    BooleanChoiceWidget,
+    InlineEditPickerWidget,
+    )
 from lp.app.browser.tales import format_link
 from lp.app.widgets.itemswidgets import (
     LabeledMultiCheckBoxWidget,
@@ -260,6 +263,15 @@
             header='Change daily build archive',
             step_title='Select a PPA')
 
+    @property
+    def daily_build_widget(self):
+        return BooleanChoiceWidget(
+            self.context, ISourcePackageRecipe['build_daily'],
+            tag='span',
+            false_text='Build on request',
+            true_text='Build daily',
+            header='Change build schedule')
+
 
 class SourcePackageRecipeRequestBuildsView(LaunchpadFormView):
     """A view for requesting builds of a SourcePackageRecipe."""

=== added file 'lib/lp/code/help/recipe-build-frequency.html'
--- lib/lp/code/help/recipe-build-frequency.html	1970-01-01 00:00:00 +0000
+++ lib/lp/code/help/recipe-build-frequency.html	2011-02-11 01:53:14 +0000
@@ -0,0 +1,41 @@
+<html>
+  <head>
+    <title>Source package recipe build schedule</title>
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssreset/reset.css" />
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssfonts/fonts.css" />
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssbase/base.css" />
+    <style type="text/css">
+      dt { font-weight: bold }
+      dd p { margin-bottom: 0.5em }
+    </style>
+  </head>
+  <body>
+    <h1>Source package recipe build schedule</h1>
+
+    <p>There are two options for when recipes get built:</p>
+    <dl>
+      <dt>Build daily</dt>
+      <dd>
+        <p>A build will be scheduled automatically once a change in any
+        of the branches used in the recipe is detected.</p>
+        <p>If there has been a build of the recipe within the previous
+          24 hours into the daily build PPA, the build will not be scheduled
+          until 24 hours since the last build into the daily build PPA.</p>
+        <p>If the recipe had been built within the last 24 hours into a
+          different PPA using the "Request build" action, this will not
+          delay the daily build.</p>
+        <p>If you really want the build to happen before the 24 period is up,
+          you can use the "Request build" action.</p>
+      </dd>
+      <dt>Build on request</dt>
+      <dd>
+        <p>Builds of the recipe have to be manually requested using the
+          "Request build" action.</p>
+      </dd>
+    </dl>
+
+  </body>
+</html>

=== modified file 'lib/lp/code/templates/sourcepackagerecipe-index.pt'
--- lib/lp/code/templates/sourcepackagerecipe-index.pt	2011-01-20 20:35:32 +0000
+++ lib/lp/code/templates/sourcepackagerecipe-index.pt	2011-02-11 01:53:14 +0000
@@ -44,13 +44,12 @@
         <h2>Recipe information</h2>
         <div class="two-column-list">
           <dl id="build_daily">
-            <dt>Build schedule:</dt>
-            <dd tal:condition="context/build_daily">
-              Built daily
-            </dd>
-            <dd tal:condition="not:context/build_daily">
-              Built on request
-            </dd>
+            <dt>Build schedule:
+              <a href="/+help/recipe-build-frequency.html" target="help" class="sprite maybe">
+                &nbsp;<span class="invisible-link">Tag help</span>
+              </a>
+            </dt>
+            <dd tal:content="structure view/daily_build_widget"/>
           </dl>
 
           <dl id="owner">

=== added file 'lib/lp/code/windmill/tests/test_recipe_index.py'
--- lib/lp/code/windmill/tests/test_recipe_index.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/windmill/tests/test_recipe_index.py	2011-02-11 01:53:14 +0000
@@ -0,0 +1,48 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for branch statuses."""
+
+__metaclass__ = type
+__all__ = []
+
+import transaction
+import unittest
+
+from lp.code.windmill.testing import CodeWindmillLayer
+from lp.testing import WindmillTestCase
+from lp.testing.windmill.constants import (
+    PAGE_LOAD,
+    SLEEP,
+    )
+
+
+class TestRecipeSetDaily(WindmillTestCase):
+    """Test setting the daily build flag."""
+
+    layer = CodeWindmillLayer
+    suite_name = "Recipe daily build flag setting"
+
+    BUILD_DAILY_TEXT = u'//span[@id="edit-build_daily"]/span[@class="value"]'
+    BUILD_DAILY_POPUP = u'//div[contains(@class, "yui3-ichoicelist-content")]'
+
+    def test_inline_recipe_daily_build(self):
+        eric = self.factory.makePerson(
+            name="eric", displayname="Eric the Viking", password="test",
+            email="eric@xxxxxxxxxxx")
+        recipe = self.factory.makeSourcePackageRecipe(owner=eric)
+        transaction.commit()
+
+        client, start_url = self.getClientFor(recipe, user=eric)
+        client.click(xpath=self.BUILD_DAILY_TEXT)
+        client.waits.forElement(xpath=self.BUILD_DAILY_POPUP)
+        client.click(link=u'Build daily')
+        client.waits.sleep(milliseconds=SLEEP)
+        client.asserts.assertText(
+            xpath=self.BUILD_DAILY_TEXT, validator=u'Build daily')
+
+        # Reload the page and make sure the change has stuck.
+        client.open(url=start_url)
+        client.waits.forPageLoad(timeout=PAGE_LOAD)
+        client.asserts.assertText(
+            xpath=self.BUILD_DAILY_TEXT, validator=u'Build daily')

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2011-02-10 08:38:19 +0000
+++ lib/lp/testing/__init__.py	2011-02-11 01:53:14 +0000
@@ -158,7 +158,7 @@
 from lp.testing.fixture import ZopeEventHandlerFixture
 from lp.testing.karma import KarmaRecorder
 from lp.testing.matchers import Provides
-from lp.testing.windmill import constants
+from lp.testing.windmill import constants, lpuser
 
 
 class FakeTime:
@@ -772,6 +772,23 @@
         # of things like https://launchpad.net/bugs/515494)
         self.client.open(url=self.layer.appserver_root_url())
 
+    def getClientFor(self, obj, user=None, password='test', view_name=None):
+        """Return a new client, and the url that it has loaded."""
+        client = WindmillTestClient(self.suite_name)
+        if user is not None:
+            email = removeSecurityProxy(user).preferredemail.email
+            client.open(url=lpuser.get_basic_login_url(email, password))
+            client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
+        if isinstance(obj, basestring):
+            url = obj
+        else:
+            url = canonical_url(
+                obj, view_name=view_name, force_local_path=True)
+        obj_url = self.layer.base_url + url
+        client.open(url=obj_url)
+        client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
+        return client, obj_url
+
 
 class YUIUnitTestCase(WindmillTestCase):
 

=== modified file 'lib/lp/testing/windmill/lpuser.py'
--- lib/lp/testing/windmill/lpuser.py	2011-02-10 04:00:00 +0000
+++ lib/lp/testing/windmill/lpuser.py	2011-02-11 01:53:14 +0000
@@ -11,6 +11,14 @@
 from lp.testing.windmill import constants
 
 
+def get_basic_login_url(email, password):
+    """Return the constructed url to login a user."""
+    base_url = windmill.settings['TEST_URL']
+    basic_auth_url = base_url.replace('http://', 'http://%s:%s@')
+    basic_auth_url = basic_auth_url + '+basiclogin'
+    return basic_auth_url % (email, password)
+
+
 class LaunchpadUser:
     """Object representing well-known user on Launchpad."""
 
@@ -32,10 +40,7 @@
 
         current_url = client.commands.execJS(
             code='windmill.testWin().location;')['result']['href']
-        base_url = windmill.settings['TEST_URL']
-        basic_auth_url = base_url.replace('http://', 'http://%s:%s@')
-        basic_auth_url = basic_auth_url + '+basiclogin'
-        client.open(url=basic_auth_url % (self.email, self.password))
+        client.open(url=get_basic_login_url(self.email, self.password))
         client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
         client.open(url=current_url)
         client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
@@ -63,8 +68,7 @@
 
 def login_person(person, email, password, client):
     """Create a LaunchpadUser for a person and password."""
-    user = LaunchpadUser(
-        person.displayname, email, password)
+    user = LaunchpadUser(person.displayname, email, password)
     user.ensure_login(client)