← Back to team overview

launchpad-reviewers team mailing list archive

lp:~allenap/launchpad/localpackagediffs-filter-by-person-or-team-bug-798873-ui into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/localpackagediffs-filter-by-person-or-team-bug-798873-ui into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #798873 in Launchpad itself: "Being able to filter by subscribed packages for a given team or user."
  https://bugs.launchpad.net/launchpad/+bug/798873

For more details, see:
https://code.launchpad.net/~allenap/launchpad/localpackagediffs-filter-by-person-or-team-bug-798873-ui/+merge/69766

This branch makes it possible to click on the "Last changed" table
heading in +localpackagediffs and select a user or team using the
familiar person picker. The page is then reloaded with the result
limited to that person or team's changes.

View changes:

- DistroSeriesDifferenceBaseView has a new property,
  specified_changed_by_filter, that returns the selected person or
  teams. The value of this is passed into getForDistroSeries().

JavaScript and HTML changes:

- In lp.registry.distroseries.differences, a linkify() function was
  extracted from connect_packageset_picker().

- New function connect_last_changed_picker() linkifies the given
  origin node and wires up the person picker.

- Nothing in the differences modules was tested before - it is
  somewhat experimental - so I added a test module. The person picker
  stuff is much easier to test than the existing packageset picker
  (the latter needs to make API calls during set-up) so it is tested
  as well as all the module's functions. There are still no tests for
  connect_packageset_picker().

- connect_last_changed_picker() is called from the template.

-- 
https://code.launchpad.net/~allenap/launchpad/localpackagediffs-filter-by-person-or-team-bug-798873-ui/+merge/69766
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/localpackagediffs-filter-by-person-or-team-bug-798873-ui into lp:launchpad.
=== modified file 'lib/lp/registry/browser/distroseries.py'
--- lib/lp/registry/browser/distroseries.py	2011-07-28 18:35:44 +0000
+++ lib/lp/registry/browser/distroseries.py	2011-07-29 11:02:33 +0000
@@ -97,6 +97,7 @@
 from lp.registry.interfaces.distroseriesdifference import (
     IDistroSeriesDifferenceSource,
     )
+from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.features import getFeatureFlag
@@ -1042,6 +1043,18 @@
         return None if len(packagesets) == 0 else packagesets
 
     @property
+    def specified_changed_by_filter(self):
+        """If specified, return Persons given in the GET form data."""
+        get_person_by_name = getUtility(IPersonSet).getByName
+        changed_by_names = set(
+            self.request.query_string_params.get("field.changed_by", ()))
+        changed_by = (
+            get_person_by_name(name) for name in changed_by_names)
+        changed_by = set(
+            person for person in changed_by if person is not None)
+        return None if len(changed_by) == 0 else changed_by
+
+    @property
     def specified_package_type(self):
         """If specified, return the package type filter from the GET form
         data.
@@ -1074,7 +1087,8 @@
                 self.context, difference_type=self.differences_type,
                 name_filter=self.specified_name_filter,
                 status=status, child_version_higher=child_version_higher,
-                packagesets=self.specified_packagesets_filter)
+                packagesets=self.specified_packagesets_filter,
+                changed_by=self.specified_changed_by_filter)
         return BatchNavigator(differences, self.request)
 
     @cachedproperty

=== modified file 'lib/lp/registry/browser/tests/test_distroseries.py'
--- lib/lp/registry/browser/tests/test_distroseries.py	2011-07-28 18:35:44 +0000
+++ lib/lp/registry/browser/tests/test_distroseries.py	2011-07-29 11:02:33 +0000
@@ -9,6 +9,7 @@
 import difflib
 import re
 from textwrap import TextWrapper
+from urllib import urlencode
 
 from BeautifulSoup import BeautifulSoup
 from lazr.restful.interfaces import IJSONRequestCache
@@ -2151,6 +2152,59 @@
                 query_string='field.packageset=%d' % packageset.id)
             self.assertEqual(1, len(view.cached_differences.batch))
 
+    def test_specified_changed_by_filter_none_specified(self):
+        # specified_changed_by_filter is None when there are no
+        # field.changed_by parameters in the query.
+        set_derived_series_ui_feature_flag(self)
+        dsd = self.factory.makeDistroSeriesDifference()
+        person = dsd.derived_series.owner
+        with person_logged_in(person):
+            view = create_initialized_view(
+                dsd.derived_series, '+localpackagediffs', method='GET',
+                query_string='')
+            self.assertIs(None, view.specified_changed_by_filter)
+
+    def test_specified_changed_by_filter_specified(self):
+        # specified_changed_by_filter returns a collection of Person when
+        # there are field.changed_by query parameters.
+        set_derived_series_ui_feature_flag(self)
+        dsd = self.factory.makeDistroSeriesDifference()
+        person = dsd.derived_series.owner
+        changed_by1 = self.factory.makePerson()
+        changed_by2 = self.factory.makePerson()
+        with person_logged_in(person):
+            query_string = urlencode(
+                {"field.changed_by": (changed_by1.name, changed_by2.name)},
+                doseq=True)
+            view = create_initialized_view(
+                dsd.derived_series, '+localpackagediffs', method='GET',
+                query_string=query_string)
+            self.assertContentEqual(
+                [changed_by1, changed_by2],
+                view.specified_changed_by_filter)
+
+    def test_search_for_changed_by(self):
+        # If changed_by is specified the query the resulting batch will only
+        # contain packages relating to those people or teams.
+        set_derived_series_ui_feature_flag(self)
+        dsd = self.factory.makeDistroSeriesDifference()
+        person = dsd.derived_series.owner
+        ironhide = self.factory.makePersonByName("Ironhide")
+        query_string = urlencode({"field.changed_by": ironhide.name})
+        # The package release is not from Ironhide so the batch will be empty.
+        with person_logged_in(person):
+            view = create_initialized_view(
+                dsd.derived_series, '+localpackagediffs', method='GET',
+                query_string=query_string)
+            self.assertEqual(0, len(view.cached_differences.batch))
+            # The batch will contain the package once Ironhide has been
+            # associated with its release.
+            removeSecurityProxy(dsd.source_package_release).creator = ironhide
+            view = create_initialized_view(
+                dsd.derived_series, '+localpackagediffs', method='GET',
+                query_string=query_string)
+            self.assertEqual(1, len(view.cached_differences.batch))
+
 
 class TestDistroSeriesNeedsPackagesView(TestCaseWithFactory):
     """Test the distroseries +needs-packaging view."""

=== modified file 'lib/lp/registry/javascript/distroseries/differences.js'
--- lib/lp/registry/javascript/distroseries/differences.js	2011-07-22 10:19:51 +0000
+++ lib/lp/registry/javascript/distroseries/differences.js	2011-07-29 11:02:33 +0000
@@ -2,7 +2,7 @@
  * Copyright 2011 Canonical Ltd. This software is licensed under the
  * GNU Affero General Public License version 3 (see the file LICENSE).
  *
- * DistroSeries Initialization.
+ * DistroSeries Differences.
  *
  * @module lp.registry.distroseries
  * @submodule differences
@@ -13,11 +13,14 @@
 Y.log('loading lp.registry.distroseries.differences');
 
 var namespace = Y.namespace('lp.registry.distroseries.differences'),
+    testspace = Y.namespace('lp.registry.distroseries.differences.test'),
     formwidgets = Y.lp.app.formwidgets,
     widgets = Y.lp.registry.distroseries.widgets;
 
 var PACKAGESET_FIELD = "field.packageset",
-    PACKAGESET_FIELD_SELECTOR = "input[name=" + PACKAGESET_FIELD + "]";
+    PACKAGESET_FIELD_SELECTOR = "input[name=" + PACKAGESET_FIELD + "]",
+    CHANGED_BY_FIELD = "field.changed_by",
+    CHANGED_BY_FIELD_SELECTOR = "input[name=" + CHANGED_BY_FIELD + "]";
 
 
 /**
@@ -51,6 +54,49 @@
 
 
 /**
+ * Return the first field.changed_by value from the current window's
+ * query string.
+ *
+ * @param {String} qs The query string, typically obtained from
+ *     window.location.search.
+ */
+var get_changed_by_in_query = function(qs) {
+    var query = Y.QueryString.parse(qs.replace(/^[?]/, ""));
+    var changed_by = query[CHANGED_BY_FIELD];
+    if (Y.Lang.isArray(changed_by)) {
+        return changed_by[0];
+    }
+    else if (Y.Lang.isValue(changed_by)) {
+        return changed_by;
+    }
+    else {
+        return null;
+    }
+};
+
+
+/**
+ * Convert the content of the given node into a js-action link.
+ *
+ * I would rather not do this with innerHTML, but I can't figure out
+ * how to get YUI3 to wrap the contents of a node with another node,
+ * including text nodes*.
+ *
+ * Also, node.get("text") is broken; given <a>foo<b/>bar</a>,
+ * a.get("text") will return "foobar".
+ *
+ * @param {Y.Node} node The node to linkify.
+ *
+ */
+var linkify = function(node) {
+    var link = Y.Node.create('<a class="js-action sprite edit" />');
+    link.set("innerHTML", node.get("innerHTML"));
+    node.empty().append(link);
+    return link;
+};
+
+
+/**
  * Wire up a packageset picker that updates the given form.
  *
  * @param {Y.Node} origin The node that, when clicked, should activate
@@ -120,7 +166,7 @@
     var reposition_overlay = function() {
         /* Trigger alignment and constrain to the viewport. Should
            these not be automatic? Perhaps a bad interaction between
-           widget-position-align and widget-position-constrain? Only
+           widget-position-align and widget-positionposition-constrain? Only
            reposition when overlay is visible. */
         if (overlay.get("visible")) {
             overlay.set("align", overlay.get("align"));
@@ -147,28 +193,88 @@
        populated with choices. */
     overlay.after("visibleChange", initialize_picker);
 
-    /* Convert the content of the origin into a js-action link, and
-       show the overlay when it's clicked. I would rather not do this
-       with innerHTML, but I can't figure out how to get YUI3 to wrap
-       the contents of a node with another node, *including text
-       nodes*. Also, node.get("text") is broken; given
-       <a>foo<b/>bar</a>, a.get("text") will return "foobar". */
-    var link = Y.Node.create('<a class="js-action sprite edit" />');
-    link.set("innerHTML", origin.get("innerHTML"));
+    /* Linkify the origin and show the overlay when it's clicked. */
+    var link = linkify(origin);
     link.on("click", function() { overlay.show(); });
-    origin.empty().append(link);
+};
+
+
+/**
+ * Wire up a person picker that updates the given form.
+ *
+ * @param {Y.Node} origin The node that, when clicked, should activate
+ *     the picker.
+ * @param {Y.Node} form The form that the picker should update.
+ */
+var connect_last_changed_picker = function(origin, form) {
+    var config = {
+        picker_type: "person",
+        header: "Choose a person or a team",
+        visible: false
+    };
+    var picker = new Y.lp.app.picker.create("ValidPersonOrTeam", config);
+
+    /* Align the centre of the overlay with the centre of the origin
+       node. */
+    var align = {
+        node: origin,
+        points: [
+            Y.WidgetPositionAlign.CC,
+            Y.WidgetPositionAlign.CC
+        ]
+    };
+
+    /* lazr.picker.Picker sets centered to true each time the picker
+       is shown. Not happy. Killing it with fire^W preventDefault. */
+    picker.on("centeredChange", function(e) { e.preventDefault(); });
+
+    /* XXX: GavinPanella 2011-07-27 bug=817091: Alignment must be done
+       after creating the picker because lp.app.picker.create()
+       clobbers the passed configuration. */
+
+    /* Pre-fill the search box with an existing selection. */
+    picker.after("visibleChange", function(e) {
+        if (e.newVal) {
+            picker.set("align", align);  // Arg.
+            var changed_by = get_changed_by_in_query(window.location.search);
+            if (Y.Lang.isValue(changed_by)) {
+                picker._search_input.set("value", changed_by);
+            }
+        }
+    });
+
+    /* When the picker is saved the search filter form is modified and
+       submitted. */
+    picker.on("save", function(e) {
+        form.all(CHANGED_BY_FIELD_SELECTOR).remove();
+        if (e.value) {
+            form.append(
+                Y.Node.create('<input type="hidden" />')
+                    .set("name", CHANGED_BY_FIELD)
+                    .set("value", e.value));
+        }
+        form.submit();
+    });
+
+    /* Linkify the origin and show the picker when it's clicked. */
+    linkify(origin).on("click", function(e) { picker.show(); });
+
+    return picker;
 };
 
 
 // Exports.
 namespace.connect_packageset_picker = connect_packageset_picker;
-
-
-}, "0.1", {
-    "requires": [
-        "node",
-        "querystring-parse",
-        "lazr.formoverlay",
-        "lp.app.formwidgets",
-        "lp.registry.distroseries.widgets"
-    ]});
+namespace.connect_last_changed_picker = connect_last_changed_picker;
+
+
+// Exports for testing.
+testspace.get_packagesets_in_query = get_packagesets_in_query;
+testspace.get_changed_by_in_query = get_changed_by_in_query;
+testspace.linkify = linkify;
+
+
+}, "0.1", {"requires": [
+               "lazr.formoverlay", "lp.app.formwidgets",
+               "lp.app.picker", "lp.registry.distroseries.widgets",
+               "node", "querystring-parse"]});

=== added file 'lib/lp/registry/javascript/distroseries/tests/test_differences.html'
--- lib/lp/registry/javascript/distroseries/tests/test_differences.html	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/distroseries/tests/test_differences.html	2011-07-29 11:02:33 +0000
@@ -0,0 +1,47 @@
+<!DOCTYPE
+   HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+   "http://www.w3.org/TR/html4/strict.dtd";>
+<html>
+  <head>
+  <title>Launchpad DistroSeries Differences</title>
+
+  <!-- YUI and test setup -->
+  <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+  <script type="text/javascript"
+          src="../../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/testing/testrunner.js"></script>
+
+  <!-- Required modules -->
+  <script type="text/javascript"
+          src="../../../../app/javascript/client.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/activator/activator.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/anim/anim.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/lazr/lazr.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/overlay/overlay.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/picker/picker.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/picker/person_picker.js"></script>
+  <script type="text/javascript"
+          src="../../../../app/javascript/picker/picker_patcher.js"></script>
+
+  <!-- The module under test -->
+  <script type="text/javascript"
+          src="../differences.js"></script>
+
+  <!-- The test suite -->
+  <script type="text/javascript"
+          src="test_differences.js"></script>
+
+  </head>
+  <body class="yui3-skin-sam">
+    <ul id="suites">
+      <li>lp.registry.distroseries.differences.test</li>
+    </ul>
+  </body>
+</html>

=== added file 'lib/lp/registry/javascript/distroseries/tests/test_differences.js'
--- lib/lp/registry/javascript/distroseries/tests/test_differences.js	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/distroseries/tests/test_differences.js	2011-07-29 11:02:33 +0000
@@ -0,0 +1,139 @@
+/**
+ * Copyright 2011 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Tests for DistroSeries Differences.
+ *
+ * @module lp.registry.distroseries.differences
+ * @submodule test
+ */
+
+YUI.add('lp.registry.distroseries.differences.test', function(Y) {
+
+    var namespace = Y.namespace('lp.registry.distroseries.differences.test');
+
+    var Assert = Y.Assert;
+    var ArrayAssert = Y.ArrayAssert;
+
+    var suite = new Y.Test.Suite("distroseries.differences Tests");
+    var differences = Y.lp.registry.distroseries.differences;
+
+    var TestFunctions = {
+        name: "TestFunctions",
+
+        test_get_packagesets_in_query: function() {
+            Assert.isFunction(namespace.get_packagesets_in_query);
+        },
+
+        test_get_packagesets_in_query_no_matching_parameters: function() {
+            ArrayAssert.itemsAreSame(
+                [], namespace.get_packagesets_in_query(""));
+            ArrayAssert.itemsAreSame(
+                [], namespace.get_packagesets_in_query("?"));
+            ArrayAssert.itemsAreSame(
+                [], namespace.get_packagesets_in_query("?foo=bar"));
+        },
+
+        test_get_packagesets_in_query_matching_parameters: function() {
+            ArrayAssert.itemsAreSame(
+                ["foo"], namespace.get_packagesets_in_query(
+                    "field.packageset=foo"));
+            // A leading question mark is okay.
+            ArrayAssert.itemsAreSame(
+                ["foo"], namespace.get_packagesets_in_query(
+                    "?field.packageset=foo"));
+            ArrayAssert.itemsAreSame(
+                ["foo", "bar"], namespace.get_packagesets_in_query(
+                    "?field.packageset=foo&field.packageset=bar"));
+        },
+
+        test_get_packagesets_in_query_numeric_parameters: function() {
+            // All-digit parameters are still returned as strings.
+            ArrayAssert.itemsAreSame(
+                ["123"], namespace.get_packagesets_in_query(
+                    "field.packageset=123"));
+        },
+
+        test_get_changed_by_in_query: function() {
+            Assert.isFunction(namespace.get_changed_by_in_query);
+        },
+
+        test_get_changed_by_in_query_no_matching_parameters: function() {
+            Assert.isNull(namespace.get_changed_by_in_query(""));
+            Assert.isNull(namespace.get_changed_by_in_query("?"));
+            Assert.isNull(namespace.get_changed_by_in_query("?foo=bar"));
+        },
+
+        test_get_changed_by_in_query_matching_parameters: function() {
+            Assert.areSame(
+                "foo", namespace.get_changed_by_in_query(
+                    "field.changed_by=foo"));
+            // A leading question mark is okay.
+            Assert.areSame(
+                "foo", namespace.get_changed_by_in_query(
+                    "?field.changed_by=foo"));
+            // Only the first changed_by parameter is returned.
+            Assert.areSame(
+                "foo", namespace.get_changed_by_in_query(
+                    "?field.changed_by=foo&field.changed_by=bar"));
+        },
+
+        test_linkify: function() {
+            var target = Y.Node.create("<div>Foobar</div>");
+            var link = namespace.linkify(target);
+            Assert.isInstanceOf(Y.Node, link);
+            Assert.areSame(link, target.one("a"));
+            Assert.areSame("Foobar", link.get("text"));
+            Assert.isTrue(link.hasClass("js-action"));
+        }
+
+    };
+
+    suite.add(new Y.Test.Case(TestFunctions));
+
+    var TestLastChangedPicker = {
+        name: "TestLastChangedPicker",
+
+        setUp: function() {
+            var body = Y.one(document.body);
+            this.form = Y.Node.create("<form />").appendTo(body);
+            this.origin = Y.Node.create("<div>Origin</div>").appendTo(body);
+            this.picker = differences.connect_last_changed_picker(
+                this.origin, this.form);
+        },
+
+        tearDown: function() {
+            this.picker.get("boundingBox").remove(true);
+            this.origin.remove(true);
+            this.form.remove(true);
+        },
+
+        test_linkify_click_activates_picker: function() {
+            Assert.isFalse(this.picker.get("visible"));
+            this.origin.one("a").simulate("click");
+            Assert.isTrue(this.picker.get("visible"));
+        },
+
+        test_picker_save_updates_and_submits_form: function() {
+            var submitted = false;
+            this.form.submit = function(e) {
+                submitted = true;
+            };
+            this.picker.fire("save", {value: "foobar"});
+            Assert.isTrue(submitted);
+            var input = this.form.one("input");
+            Assert.isInstanceOf(Y.Node, input);
+            Assert.areSame("hidden", input.get("type"));
+            Assert.areSame("field.changed_by", input.get("name"));
+            Assert.areSame("foobar", input.get("value"));
+        }
+
+    };
+
+    suite.add(new Y.Test.Case(TestLastChangedPicker));
+
+    namespace.suite = suite;
+
+}, "0.1", {"requires": [
+               "lp.registry.distroseries.differences", "node",
+               "node-event-simulate", "test"]});

=== modified file 'lib/lp/registry/templates/distroseries-localdifferences.pt'
--- lib/lp/registry/templates/distroseries-localdifferences.pt	2011-07-28 10:56:43 +0000
+++ lib/lp/registry/templates/distroseries-localdifferences.pt	2011-07-29 11:02:33 +0000
@@ -69,7 +69,7 @@
                   class="package-sets">
                 Package-sets
               </th>
-              <th>Last changed</th>
+              <th class="last-changed">Last changed</th>
               <th>Latest comment</th>
             </tr>
           </thead>
@@ -233,6 +233,9 @@
       Y.all("th.package-sets").each(function(header) {
         differences.connect_packageset_picker(header, form);
       });
+      Y.all("th.last-changed").each(function(header) {
+        differences.connect_last_changed_picker(header, form);
+      });
     });
   });
 </script>