← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/sub-search-ui-bug-656823-4 into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/sub-search-ui-bug-656823-4 into lp:launchpad with lp:~allenap/launchpad/sub-search-ui-bug-656823-3 as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~allenap/launchpad/sub-search-ui-bug-656823-4/+merge/45824

The main purpose of this branch is to provide an edit view for bug
subscription filters. However, to pull this off several things also
got done:

- I updated the IBugSubscriptionFilter field descriptions to work
  better in the web UI. I think they make sense for the web service
  API doc too.

- Add a new widget, BugTagsFrozenSetWidget, that subclasses
  BugTagsWidget. I made some changes to the latter to make the
  subclassing a bit more elegant.

- I moved canonical.widgets.bug to lp.bugs.browser.widgets.bug. I also
  moved the widget registration ZCML to the lp.bugs.brower.widgets
  package.

- Added some rudimentary "(edit)" links to the structural subscription
  overview page. They are there as much to make manual testing and UI
  review easier (though I doubt this branch warrants a UI review as it
  stands).

- Some imports and lint needed fixing, but I've stuffed those into a
  later branch because this branch is noisy enough.

-- 
https://code.launchpad.net/~allenap/launchpad/sub-search-ui-bug-656823-4/+merge/45824
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/sub-search-ui-bug-656823-4 into lp:launchpad.
=== modified file 'lib/lp/bugs/browser/bug.py'
--- lib/lp/bugs/browser/bug.py	2010-12-21 15:28:54 +0000
+++ lib/lp/bugs/browser/bug.py	2011-01-11 10:08:37 +0000
@@ -86,7 +86,6 @@
     ICanonicalUrlData,
     ILaunchBag,
     )
-from canonical.widgets.bug import BugTagsWidget
 from canonical.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
 from canonical.widgets.project import ProjectScopeWidget
 from lp.app.browser.launchpadform import (
@@ -97,6 +96,7 @@
     )
 from lp.app.browser.stringformatter import FormattersAPI
 from lp.app.errors import NotFoundError
+from lp.bugs.browser.widgets.bug import BugTagsWidget
 from lp.bugs.interfaces.bug import (
     IBug,
     IBugSet,

=== modified file 'lib/lp/bugs/browser/bugsubscriptionfilter.py'
--- lib/lp/bugs/browser/bugsubscriptionfilter.py	2011-01-11 10:08:27 +0000
+++ lib/lp/bugs/browser/bugsubscriptionfilter.py	2011-01-11 10:08:37 +0000
@@ -9,8 +9,21 @@
     ]
 
 
+from zope.app.form.browser import TextWidget
+
 from canonical.launchpad.helpers import english_list
-from canonical.launchpad.webapp.publisher import LaunchpadView
+from canonical.launchpad.webapp.publisher import (
+    canonical_url,
+    LaunchpadView,
+    )
+from canonical.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from lp.app.browser.launchpadform import (
+    action,
+    custom_widget,
+    LaunchpadEditFormView,
+    )
+from lp.bugs.browser.widgets.bug import BugTagsFrozenSetWidget
+from lp.bugs.interfaces.bugsubscriptionfilter import IBugSubscriptionFilter
 
 
 class BugSubscriptionFilterView(LaunchpadView):
@@ -59,3 +72,35 @@
                     sorted(tags), conjunction=(
                         u"and" if self.context.find_all_tags else u"or")))
         return conditions
+
+
+class BugSubscriptionFilterEditView(LaunchpadEditFormView):
+    """Edit view for `IBugSubscriptionFilter`."""
+
+    page_title = u"Edit filter"
+    schema = IBugSubscriptionFilter
+    field_names = (
+        "description",
+        "statuses",
+        "importances",
+        "tags",
+        "find_all_tags",
+        )
+
+    custom_widget("description", TextWidget, displayWidth=50)
+    custom_widget("statuses", LabeledMultiCheckBoxWidget)
+    custom_widget("importances", LabeledMultiCheckBoxWidget)
+    custom_widget("tags", BugTagsFrozenSetWidget, displayWidth=35)
+
+    @action("Update", name="update")
+    def update_action(self, action, data):
+        """Update the bug filter with the form data."""
+        self.updateContextFromData(data)
+
+    @property
+    def next_url(self):
+        """Return to the user's structural subscriptions page."""
+        return canonical_url(
+            self.user, view_name="+structural-subscriptions")
+
+    cancel_url = next_url

=== modified file 'lib/lp/bugs/browser/bugtarget.py'
--- lib/lp/bugs/browser/bugtarget.py	2010-12-02 16:14:30 +0000
+++ lib/lp/bugs/browser/bugtarget.py	2011-01-11 10:08:37 +0000
@@ -58,9 +58,7 @@
     FeedsMixin,
     )
 from canonical.launchpad.browser.librarian import ProxiedLibraryFileAlias
-from canonical.launchpad.interfaces.launchpad import (
-    ILaunchpadCelebrities,
-    )
+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from canonical.launchpad.searchbuilder import any
 from canonical.launchpad.validators.name import valid_name_pattern
 from canonical.launchpad.webapp import (
@@ -74,10 +72,6 @@
 from canonical.launchpad.webapp.interfaces import ILaunchBag
 from canonical.launchpad.webapp.menu import structured
 from canonical.launchpad.webapp.publisher import HTTP_MOVED_PERMANENTLY
-from canonical.widgets.bug import (
-    BugTagsWidget,
-    LargeBugTagsWidget,
-    )
 from canonical.widgets.bugtask import NewLineToSpacesWidget
 from canonical.widgets.product import (
     GhostCheckBoxWidget,
@@ -103,6 +97,10 @@
     )
 from lp.bugs.browser.bugrole import BugRoleMixin
 from lp.bugs.browser.bugtask import BugTaskSearchListingView
+from lp.bugs.browser.widgets.bug import (
+    BugTagsWidget,
+    LargeBugTagsWidget,
+    )
 from lp.bugs.interfaces.apportjob import IProcessApportBlobJobSource
 from lp.bugs.interfaces.bug import (
     CreateBugParams,

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2010-12-22 03:50:13 +0000
+++ lib/lp/bugs/browser/bugtask.py	2011-01-11 10:08:37 +0000
@@ -164,7 +164,6 @@
 from canonical.launchpad.webapp.menu import structured
 from canonical.lazr.interfaces import IObjectPrivacy
 from canonical.lazr.utils import smartquote
-from canonical.widgets.bug import BugTagsWidget
 from canonical.widgets.bugtask import (
     AssigneeDisplayWidget,
     BugTaskAssigneeWidget,
@@ -208,6 +207,7 @@
     build_comments_from_chunks,
     group_comments_with_activity,
     )
+from lp.bugs.browser.widgets.bug import BugTagsWidget
 from lp.bugs.interfaces.bug import (
     IBug,
     IBugSet,

=== modified file 'lib/lp/bugs/browser/configure.zcml'
--- lib/lp/bugs/browser/configure.zcml	2011-01-11 10:08:27 +0000
+++ lib/lp/bugs/browser/configure.zcml	2011-01-11 10:08:37 +0000
@@ -1189,6 +1189,15 @@
           template="../templates/bug-subscription-filter.pt"
           permission="launchpad.View"
           name="+definition" />
+      <browser:page
+          for="lp.bugs.interfaces.bugsubscriptionfilter.IBugSubscriptionFilter"
+          class=".bugsubscriptionfilter.BugSubscriptionFilterEditView"
+          template="../templates/bug-subscription-filter-edit.pt"
+          permission="launchpad.Edit"
+          name="+edit" />
     </facet>
 
+    <!-- Widgets -->
+    <include package=".widgets"/>
+
 </configure>

=== modified file 'lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py'
--- lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py	2011-01-11 10:08:27 +0000
+++ lib/lp/bugs/browser/tests/test_bugsubscriptionfilter.py	2011-01-11 10:08:37 +0000
@@ -31,10 +31,12 @@
 from lp.testing import (
     normalize_whitespace,
     person_logged_in,
+    login_person,
     TestCaseWithFactory,
     ws_object,
     )
 from lp.testing.views import create_initialized_view
+from lp.bugs.publisher import BugsLayer
 
 
 class TestBugSubscriptionFilterBase:
@@ -306,7 +308,7 @@
         # the absense of conditions.
         self.assertRender(
             u"This filter does not allow mail through.",
-            u"There are no filter conditions!")
+            u"There are no filter conditions! (edit)")
 
     def test_render_with_no_description_and_conditions(self):
         # If conditions are set but no description, the rendered description
@@ -325,7 +327,7 @@
             self.subscription_filter.tags = [u"foo", u"bar"]
         self.assertRender(
             u"This filter allows mail through when:",
-            u" and ".join(self.view.conditions))
+            u" and ".join(self.view.conditions) + u" (edit)")
 
     def test_render_with_description_and_no_conditions(self):
         # If a description is set it appears in the content of the dt tag,
@@ -334,7 +336,7 @@
             self.subscription_filter.description = u"The Wait"
         self.assertRender(
             u"\u201cThe Wait\u201d does not allow mail through.",
-            u"There are no filter conditions!")
+            u"There are no filter conditions! (edit)")
 
     def test_render_with_description_and_conditions(self):
         # If a description is set it appears in the content of the dt tag,
@@ -344,4 +346,53 @@
             self.subscription_filter.tags = [u"foo"]
         self.assertRender(
             u"\u201cThe Wait\u201d allows mail through when:",
-            u" and ".join(self.view.conditions))
+            u" and ".join(self.view.conditions) + u" (edit)")
+
+
+class TestBugSubscriptionFilterEditView(
+    TestBugSubscriptionFilterBase, TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_view_properties(self):
+        # The cancel url and next url will both point to the user's structural
+        # subscription overview page.
+        login_person(self.owner)
+        view = create_initialized_view(
+            self.subscription_filter, name="+edit",
+            layer=BugsLayer, principal=self.owner)
+        self.assertEqual([], view.errors)
+        path = "/~%s/+structural-subscriptions" % self.owner.name
+        self.assertEqual(path, urlparse(view.cancel_url).path)
+        self.assertEqual(path, urlparse(view.next_url).path)
+
+    def test_edit(self):
+        # The filter can be updated by using the update action.
+        form = {
+            "field.description": "New description",
+            "field.statuses": ["NEW", "INCOMPLETE"],
+            "field.importances": ["LOW", "MEDIUM"],
+            "field.tags": u"foo bar",
+            "field.find_all_tags": "on",
+            "field.actions.update": "Update",
+            }
+        with person_logged_in(self.owner):
+            view = create_initialized_view(
+                self.subscription_filter, name="+edit",
+                form=form, layer=BugsLayer, principal=self.owner)
+            self.assertEqual([], view.errors)
+        # The subscription filter has been updated.
+        self.assertEqual(
+            u"New description",
+            self.subscription_filter.description)
+        self.assertEqual(
+            frozenset([BugTaskStatus.NEW, BugTaskStatus.INCOMPLETE]),
+            self.subscription_filter.statuses)
+        self.assertEqual(
+            frozenset([BugTaskImportance.LOW, BugTaskImportance.MEDIUM]),
+            self.subscription_filter.importances)
+        self.assertEqual(
+            frozenset([u"foo", u"bar"]),
+            self.subscription_filter.tags)
+        self.assertTrue(
+            self.subscription_filter.find_all_tags)

=== added directory 'lib/lp/bugs/browser/widgets'
=== added file 'lib/lp/bugs/browser/widgets/__init__.py'
=== renamed file 'lib/canonical/widgets/bug.py' => 'lib/lp/bugs/browser/widgets/bug.py'
--- lib/canonical/widgets/bug.py	2010-10-03 15:30:06 +0000
+++ lib/lp/bugs/browser/widgets/bug.py	2011-01-11 10:08:37 +0000
@@ -2,6 +2,12 @@
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
+__all__ = [
+    "BugTagsFrozenSetWidget",
+    "BugTagsWidget",
+    "BugWidget",
+    "LargeBugTagsWidget",
+    ]
 
 import re
 
@@ -18,6 +24,7 @@
 
 class BugWidget(IntWidget):
     """A widget for displaying a field that is bound to an IBug."""
+
     def _toFormValue(self, value):
         """See zope.app.form.widget.SimpleInputWidget."""
         if value == self.context.missing_value:
@@ -44,23 +51,46 @@
 class BugTagsWidgetBase:
     """Base class for bug tags widgets."""
 
+    def _tagsFromFieldValue(self, tags):
+        """Package up the tags for display.
+
+        Override this to provide custom ordering for example.
+
+        :return: `None` if there are no tags, else an iterable of tags.
+        """
+        if tags is None or len(tags) == 0:
+            return None
+        else:
+            return tags
+
+    def _tagsToFieldValue(self, tags):
+        """Package up the tags for the field.
+
+        :param tags: `None` if the submitted data was missing, otherwise an
+            iterable of tags.
+        """
+        if tags is None:
+            return []
+        else:
+            return sorted(set(tags))
+
     def _toFormValue(self, value):
         """Convert a list of strings to a single, space separated, string."""
-        if value:
-            return u' '.join(value)
+        tags = self._tagsFromFieldValue(value)
+        if tags is None:
+            return self._missing
         else:
-            return self._missing
+            return u" ".join(tags)
 
     def _toFieldValue(self, input):
         """Convert a space separated string to a list of strings."""
         input = input.strip()
         if input == self._missing:
-            return []
+            return self._tagsToFieldValue(None)
         else:
-            tags = set(tag.lower()
-                       for tag in re.split(r'[,\s]+', input)
-                       if len(tag) != 0)
-            return sorted(tags)
+            return self._tagsToFieldValue(
+                tag.lower() for tag in re.split(r'[,\s]+', input)
+                if len(tag) != 0)
 
     def getInputValue(self):
         try:
@@ -88,8 +118,9 @@
     def _getInputValue(self):
         raise NotImplementedError('_getInputValue must be overloaded')
 
+
 class BugTagsWidget(BugTagsWidgetBase, TextWidget):
-    """A widget for editing bug tags."""
+    """A widget for editing bug tags in a `List` field."""
 
     def __init__(self, field, value_type, request):
         # We don't use value_type.
@@ -99,8 +130,29 @@
         return TextWidget.getInputValue(self)
 
 
+class BugTagsFrozenSetWidget(BugTagsWidget):
+    """A widget for editing bug tags in a `FrozenSet` field."""
+
+    def _tagsFromFieldValue(self, tags):
+        """Order the tags for display.
+
+        The field value is assumed to be unordered.
+        """
+        if tags is None or len(tags) == 0:
+            return None
+        else:
+            return sorted(tags)
+
+    def _tagsToFieldValue(self, tags):
+        """Return a `frozenset` of tags."""
+        if tags is None:
+            return frozenset()
+        else:
+            return frozenset(tags)
+
+
 class LargeBugTagsWidget(BugTagsWidgetBase, TextAreaWidget):
-    """A large widget for editing bug tags."""
+    """A large widget for editing bug tags in a `List` field."""
 
     def __init__(self, field, value_type, request):
         # We don't use value_type.

=== added file 'lib/lp/bugs/browser/widgets/configure.zcml'
--- lib/lp/bugs/browser/widgets/configure.zcml	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/browser/widgets/configure.zcml	2011-01-11 10:08:37 +0000
@@ -0,0 +1,19 @@
+<!-- Copyright 2011 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure
+    xmlns="http://namespaces.zope.org/zope";
+    xmlns:browser="http://namespaces.zope.org/browser";
+    xmlns:i18n="http://namespaces.zope.org/i18n";
+    i18n_domain="launchpad">
+
+  <view
+      type="zope.publisher.interfaces.browser.IBrowserRequest"
+      for="lp.services.fields.IBugField"
+      provides="zope.app.form.interfaces.IInputWidget"
+      factory="lp.bugs.browser.widgets.bug.BugWidget"
+      permission="zope.Public"
+      />
+
+</configure>

=== modified file 'lib/lp/bugs/doc/bug-tags.txt'
--- lib/lp/bugs/doc/bug-tags.txt	2010-10-18 22:24:59 +0000
+++ lib/lp/bugs/doc/bug-tags.txt	2011-01-11 10:08:37 +0000
@@ -68,7 +68,7 @@
 use BugTagsWidget.
 
     >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
-    >>> from canonical.widgets.bug import BugTagsWidget
+    >>> from lp.bugs.browser.widgets.bug import BugTagsWidget
     >>> from lp.bugs.interfaces.bug import IBug
     >>> bug_tags_field = IBug['tags'].bind(bug_one)
     >>> tag_field = bug_tags_field.value_type
@@ -158,6 +158,49 @@
     [u'bar', u'foo']
 
 
+=== Bug Tags Widget for Frozen Sets ===
+
+A variant of `BugTagsWidget` exists for when tags are stored in a
+`FrozenSet` field.
+
+    >>> from lp.bugs.browser.widgets.bug import BugTagsFrozenSetWidget
+
+Field-manipulation is not going to be examined here, and the widget
+does not care what type the field is otherwise, so the field from
+earlier can be used again.
+
+    >>> tags_frozen_set_widget = BugTagsFrozenSetWidget(
+    ...     bug_tags_field, tag_field, request)
+
+_tagsFromFieldValue() converts tags from the field value to tags for
+display. The absense of tags causes it to return None:
+
+    >>> print tags_frozen_set_widget._tagsFromFieldValue(None)
+    None
+    >>> print tags_frozen_set_widget._tagsFromFieldValue(frozenset())
+    None
+
+Tags are ordered before returning:
+
+    >>> tags_frozen_set_widget._tagsFromFieldValue(
+    ...     frozenset([5, 4, 1, 12]))
+    [1, 4, 5, 12]
+
+_tagsToFieldValue() converts the tags entered in the form into a value
+suitable for the field. In the absense of tags it returns an empty
+frozenset():
+
+    >>> tags_frozen_set_widget._tagsToFieldValue(None)
+    frozenset([])
+    >>> tags_frozen_set_widget._tagsToFieldValue([])
+    frozenset([])
+
+Otherwise it returns a `frozenset` of the tags given:
+
+    >>> tags_frozen_set_widget._tagsToFieldValue([u"foo", u"bar"])
+    frozenset([u'foo', u'bar'])
+
+
 === Large and Small Bug Tags Widget ===
 
 A regular BugTagsWidget is rendered as an <input> tag,
@@ -167,7 +210,7 @@
 
 A LargeBugTagsWidget is rendered as a <textarea>,
 
-    >>> from canonical.widgets.bug import LargeBugTagsWidget
+    >>> from lp.bugs.browser.widgets.bug import LargeBugTagsWidget
     >>> large_text_widget = LargeBugTagsWidget(
     ...     bug_tags_field, tag_field, request)
     >>> print large_text_widget()

=== modified file 'lib/lp/bugs/doc/bugwidget.txt'
--- lib/lp/bugs/doc/bugwidget.txt	2010-10-18 22:24:59 +0000
+++ lib/lp/bugs/doc/bugwidget.txt	2011-01-11 10:08:37 +0000
@@ -3,7 +3,7 @@
 The BugWidget converts string bug ids to the corresponding bug object.
 
     >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
-    >>> from canonical.widgets.bug import BugWidget
+    >>> from lp.bugs.browser.widgets.bug import BugWidget
     >>> from lp.services.fields import BugField
     >>> bug_field = BugField(__name__='bug', title=u'Bug')
     >>> request = LaunchpadTestRequest(form={'field.bug': '1'})

=== modified file 'lib/lp/bugs/interfaces/bugsubscriptionfilter.py'
--- lib/lp/bugs/interfaces/bugsubscriptionfilter.py	2010-12-21 09:34:17 +0000
+++ lib/lp/bugs/interfaces/bugsubscriptionfilter.py	2011-01-11 10:08:37 +0000
@@ -48,37 +48,37 @@
 
     find_all_tags = exported(
         Bool(
-            title=_("All given tags must be found, or any."),
+            title=_("All given tags must be found"),
             required=True, default=False))
     include_any_tags = Bool(
-        title=_("Include any tags."),
+        title=_("Include any tags"),
         required=True, default=False)
     exclude_any_tags = Bool(
-        title=_("Exclude all tags."),
+        title=_("Exclude all tags"),
         required=True, default=False)
 
     description = exported(
         Text(
-            title=_("Description of this filter."),
+            title=_("A short description of this filter"),
             required=False))
 
     statuses = exported(
         FrozenSet(
-            title=_("The statuses to filter on."),
+            title=_("The statuses interested in (empty for all)"),
             required=True, default=frozenset(),
             value_type=Choice(
                 title=_('Status'), vocabulary=BugTaskStatus)))
 
     importances = exported(
         FrozenSet(
-            title=_("The importances to filter on."),
+            title=_("The importances interested in (empty for all)"),
             required=True, default=frozenset(),
             value_type=Choice(
                 title=_('Importance'), vocabulary=BugTaskImportance)))
 
     tags = exported(
         FrozenSet(
-            title=_("The tags to filter on."),
+            title=_("The tags interested in"),
             required=True, default=frozenset(),
             value_type=SearchTag()))
 

=== added file 'lib/lp/bugs/templates/bug-subscription-filter-edit.pt'
--- lib/lp/bugs/templates/bug-subscription-filter-edit.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/templates/bug-subscription-filter-edit.pt	2011-01-11 10:08:37 +0000
@@ -0,0 +1,14 @@
+<html
+    xmlns="http://www.w3.org/1999/xhtml";
+    xmlns:tal="http://xml.zope.org/namespaces/tal";
+    xmlns:metal="http://xml.zope.org/namespaces/metal";
+    xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+    xml:lang="en" lang="en" dir="ltr"
+    metal:use-macro="view/macro:page/main_only"
+    i18n:domain="malone">
+  <body>
+    <div metal:fill-slot="main">
+      <div metal:use-macro="context/@@launchpad_form/form" />
+    </div>
+  </body>
+</html>

=== modified file 'lib/lp/bugs/templates/bug-subscription-filter.pt'
--- lib/lp/bugs/templates/bug-subscription-filter.pt	2011-01-11 10:08:27 +0000
+++ lib/lp/bugs/templates/bug-subscription-filter.pt	2011-01-11 10:08:37 +0000
@@ -27,8 +27,10 @@
         <b>and</b> <br />
       </tal:conjunction>
     </tal:conditions>
+    <br /><a tal:attributes="href context/fmt:url/+edit">(edit)</a>
   </dd>
   <dd tal:condition="not:conditions">
     There are no filter conditions!
+    <br /><a tal:attributes="href context/fmt:url/+edit">(edit)</a>
   </dd>
 </dl>

=== modified file 'lib/lp/services/fields/configure.zcml'
--- lib/lp/services/fields/configure.zcml	2010-08-12 18:11:47 +0000
+++ lib/lp/services/fields/configure.zcml	2011-01-11 10:08:37 +0000
@@ -52,14 +52,6 @@
 
     <view
         type="zope.publisher.interfaces.browser.IBrowserRequest"
-        for="lp.services.fields.IBugField"
-        provides="zope.app.form.interfaces.IInputWidget"
-        factory="canonical.widgets.bug.BugWidget"
-        permission="zope.Public"
-        />
-
-    <view
-        type="zope.publisher.interfaces.browser.IBrowserRequest"
         for="lp.services.fields.IStrippedTextLine"
         provides="zope.app.form.interfaces.IInputWidget"
         factory="canonical.widgets.textwidgets.StrippedTextWidget"