← Back to team overview

launchpad-reviewers team mailing list archive

lp:~allenap/launchpad/dd-initseries-bug-727105-packageset-picker into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/dd-initseries-bug-727105-packageset-picker into lp:launchpad with lp:~allenap/launchpad/dd-initseries-bug-727105-architecture-picker as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~allenap/launchpad/dd-initseries-bug-727105-packageset-picker/+merge/54382

More work on the +initseries page for deriving a distroseries:

- Much of CheckBoxListWidget has been refactored into a new superclass
  FormRowWidget.

- There's a new widget, SelectWidget, another subclass of
  FormRowWidget, for displaying a select areas.

- There's another new widget PackagesetPickerWidget, a subclass of
  SelectWidget, for choosing packagesets to copy over to the derived
  series.

- IPackagesetSet.getBySeries() was created and exported to support the
  PackagesetPickerWidget.

This merge proposal is quite large, but a lot of it is Javascript
boilerplate-like code.

-- 
https://code.launchpad.net/~allenap/launchpad/dd-initseries-bug-727105-packageset-picker/+merge/54382
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/dd-initseries-bug-727105-packageset-picker into lp:launchpad.
=== modified file 'lib/lp/registry/javascript/distroseries.js'
--- lib/lp/registry/javascript/distroseries.js	2011-03-22 16:48:34 +0000
+++ lib/lp/registry/javascript/distroseries.js	2011-03-22 16:48:38 +0000
@@ -17,6 +17,120 @@
 
 /**
  * A form row matching that which LaunchpadForm presents, containing a
+ * field (defined in a subclass), and an optional label and
+ * description.
+ *
+ * @class FormRowWidget
+ */
+var FormRowWidget = function() {
+    FormRowWidget.superclass.constructor.apply(this, arguments);
+};
+
+Y.mix(FormRowWidget, {
+
+    NAME: 'formRowWidget',
+
+    ATTRS: {
+
+        /**
+         * The field name.
+         *
+         * @property name
+         */
+        name: {
+            setter: function(value, name) {
+                this.fieldNode.all("input, select").set("name", value);
+            }
+        },
+
+        /**
+         * The top label for the field.
+         *
+         * @property label
+         */
+        label: {
+            getter: function() {
+                return this.labelNode.get("text");
+            },
+            setter: function(value, name) {
+                this.labelNode.set("text", value);
+            }
+        },
+
+        /**
+         * A description shown near the field.
+         *
+         * @label description
+         */
+        description: {
+            getter: function() {
+                return this.descriptionNode.get("text");
+            },
+            setter: function(value, name) {
+                this.descriptionNode.set("text", value);
+            }
+        }
+    }
+
+});
+
+Y.extend(FormRowWidget, Y.Widget, {
+
+    BOUNDING_TEMPLATE: "<tr></tr>",
+
+    CONTENT_TEMPLATE: '<td colspan="2"></td>',
+
+    initializer: function(config) {
+        this.labelNode = Y.Node.create("<label />");
+        this.fieldNode = Y.Node.create("<div></div>");
+        this.descriptionNode = Y.Node.create('<p class="formHelp" />');
+        this.spinnerNode = Y.Node.create(
+            '<img src="/@@/spinner" alt="Loading..." />');
+    },
+
+    renderUI: function() {
+        this.get("contentBox")
+            .append(this.labelNode)
+            .append(this.fieldNode)
+            .append(this.descriptionNode);
+    },
+
+    /**
+     * Show the spinner.
+     *
+     * @method showSpinner
+     */
+    showSpinner: function() {
+        this.fieldNode.empty().append(this.spinnerNode);
+    },
+
+    /**
+     * Hide the spinner.
+     *
+     * @method hideSpinner
+     */
+    hideSpinner: function() {
+        this.spinnerNode.remove();
+    },
+
+    /**
+     * Display an error.
+     *
+     * @method showError
+     */
+    showError: function(error) {
+        var message = Y.Node.create('<p />').set("text", error);
+        this.fieldNode.empty().append(message);
+        Y.lazr.anim.red_flash({node: message}).run();
+    }
+
+});
+
+namespace.FormRowWidget = FormRowWidget;
+
+
+/**
+ * A form row matching that which LaunchpadForm presents, containing a
  * list of checkboxes, and an optional label and description.
  *
  * @class CheckBoxListWidget
@@ -32,17 +146,6 @@
     ATTRS: {
 
         /**
-         * The field name.
-         *
-         * @property name
-         */
-        name: {
-            setter: function(value, name) {
-                this.fieldNode.all("input").set("name", value);
-            }
-        },
-
-        /**
          * An array of strings from which to choose.
          *
          * @property choices
@@ -73,59 +176,13 @@
                 );
                 this.fieldNode.empty().append(list);
             }
-        },
-
-        /**
-         * The top label for the field.
-         *
-         * @property label
-         */
-        label: {
-            getter: function() {
-                return this.labelNode.get("text");
-            },
-            setter: function(value, name) {
-                this.labelNode.set("text", value);
-            }
-        },
-
-        /**
-         * A description shown near the field.
-         *
-         * @label description
-         */
-        description: {
-            getter: function() {
-                return this.descriptionNode.get("text");
-            },
-            setter: function(value, name) {
-                this.descriptionNode.set("text", value);
-            }
         }
-    }
-
-});
-
-Y.extend(CheckBoxListWidget, Y.Widget, {
-
-    BOUNDING_TEMPLATE: "<tr></tr>",
-
-    CONTENT_TEMPLATE: '<td colspan="2"></td>',
-
-    initializer: function(config) {
-        this.labelNode = Y.Node.create("<label />");
-        this.fieldNode = Y.Node.create("<div></div>");
-        this.descriptionNode = Y.Node.create('<p class="formHelp" />');
-    },
-
-    renderUI: function() {
-        this.get("contentBox")
-            .append(this.labelNode)
-            .append(this.fieldNode)
-            .append(this.descriptionNode);
-    }
-
-});
+
+    }
+
+});
+
+Y.extend(CheckBoxListWidget, FormRowWidget);
 
 namespace.CheckBoxListWidget = CheckBoxListWidget;
 
@@ -180,10 +237,11 @@
                         }
                     )
                 );
-                if (value.entries.length == 0) {
+                if (value.entries.length === 0) {
                     this.fieldNode.append(
                         Y.Node.create('<p />').set(
-                            "text", "The chosen series has no architectures!"));
+                            "text",
+                            "The chosen series has no architectures!"));
                 }
                 Y.lazr.anim.green_flash({node: this.fieldNode}).run();
             }
@@ -199,37 +257,6 @@
         this.error_handler = new Y.lp.client.ErrorHandler();
         this.error_handler.clearProgressUI = Y.bind(this.hideSpinner, this);
         this.error_handler.showError = Y.bind(this.showError, this);
-        this.spinner = Y.Node.create(
-            '<img src="/@@/spinner" alt="Loading..." />');
-    },
-
-    /**
-     * Show the spinner.
-     *
-     * @method showSpinner
-     */
-    showSpinner: function() {
-        this.fieldNode.empty().append(this.spinner);
-    },
-
-    /**
-     * Hide the spinner.
-     *
-     * @method hideSpinner
-     */
-    hideSpinner: function() {
-        this.spinner.remove();
-    },
-
-    /**
-     * Display an error.
-     *
-     * @method showError
-     */
-    showError: function(error) {
-        var message = Y.Node.create('<p />').set("text", error);
-        this.fieldNode.empty().append(message);
-        Y.lazr.anim.red_flash({node: message}).run();
     }
 
 });
@@ -238,6 +265,202 @@
 
 
 /**
+ * A special form of FormRowWidget, containing a select control.
+ *
+ * @class SelectWidget
+ */
+var SelectWidget = function() {
+    SelectWidget.superclass.constructor.apply(this, arguments);
+};
+
+Y.mix(SelectWidget, {
+
+    NAME: 'selectWidget',
+
+    ATTRS: {
+
+        /**
+         * An array of objects from which to choose. Each object
+         * should contain a value for "value", "text" and "data".
+         *
+         * @property choices
+         */
+        choices: {
+            getter: function() {
+                /* I think this is a YUI3 wart; I can't see any way to
+                   map() over a NodeList, so I must push the elements
+                   one by one into an array first. */
+                var options = Y.Array([]);
+                this.fieldNode.all("select > option").each(
+                    function(option) { options.push(option); });
+                return options.map(
+                    function(option) {
+                        return {
+                            value: option.get("value"),
+                            text: option.get("text"),
+                            data: option.getData("data")
+                        };
+                    }
+                );
+            },
+            setter: function(value, name) {
+                var select = Y.Node.create("<select />");
+                select.set("name", this.get("name"))
+                      .set("size", this.get("size"));
+                if (this.get("multiple")) {
+                    select.set("multiple", "multiple");
+                }
+                var choices = Y.Array(value);
+                choices.forEach(
+                    function(choice) {
+                        var option = Y.Node.create("<option />");
+                        option.set("value", choice.value)
+                              .set("text", choice.text)
+                              .setData("data", choice.data);
+                        select.append(option);
+                    }
+                );
+                if (choices.length > 0) {
+                    this.fieldNode.empty().append(select);
+                }
+                else {
+                    this.fieldNode.empty();
+                }
+            }
+        },
+
+        /**
+         * The number of rows to show in the select widget.
+         *
+         * @property size
+         */
+        size: {
+            value: 1,
+            setter: function(value, name) {
+                this.fieldNode.all("select").set("size", value);
+            }
+        },
+
+        /**
+         * Whether multiple rows can be selected.
+         *
+         * @property multiple
+         */
+        multiple: {
+            value: false,
+            setter: function(value, name) {
+                var select = this.fieldNode.all("select");
+                if (value) {
+                    select.setAttribute("multiple", "multiple");
+                    return true;
+                }
+                else {
+                    select.removeAttribute("multiple");
+                    return false;
+                }
+            }
+        }
+
+    }
+
+});
+
+Y.extend(SelectWidget, FormRowWidget);
+
+namespace.SelectWidget = SelectWidget;
+
+
+/**
+ * A special form of SelectWidget for choosing packagesets.
+ *
+ * @class PackagesetPickerWidget
+ */
+var PackagesetPickerWidget = function() {
+    PackagesetPickerWidget
+        .superclass.constructor.apply(this, arguments);
+};
+
+Y.mix(PackagesetPickerWidget, {
+
+    NAME: 'packagesetPickerWidget',
+
+    ATTRS: {
+
+        /**
+         * The DistroSeries the choices in this field should
+         * reflect. Takes the form of a string, e.g. "ubuntu/hoary".
+         *
+         * @property distroSeries
+         */
+        distroSeries: {
+            setter: function(value, name) {
+                var distro_series_uri = Y.lp.client.get_absolute_uri(value);
+                var on = {
+                    start: Y.bind(this.showSpinner, this),
+                    success: Y.bind(this.set, this, "packageSets"),
+                    failure: this.error_handler.getFailureHandler(),
+                    end: Y.bind(this.hideSpinner, this)
+                };
+                var config = {
+                    on: on,
+                    parameters: {
+                        distroseries: distro_series_uri
+                    }
+                };
+                this.client.named_get("package-sets", "getBySeries", config);
+            }
+        },
+
+        /**
+         * The Packagesets the choices in this field should
+         * reflect. Takes the form of a Y.lp.client.Collection.
+         *
+         * @property packageSets
+         */
+        packageSets: {
+            setter: function(value, name) {
+                this.set(
+                    "choices", value.entries.map(
+                        function(packageset) {
+                            return {
+                                data: packageset,
+                                value: packageset.get("name"),
+                                text: (
+                                    packageset.get("name") + ": " +
+                                    packageset.get("description"))
+                            };
+                        }
+                    )
+                );
+                if (value.entries.length === 0) {
+                    this.fieldNode.append(
+                        Y.Node.create('<p />').set(
+                            "text",
+                            "The chosen series has no package sets!"));
+                }
+                Y.lazr.anim.green_flash({node: this.fieldNode}).run();
+            }
+        }
+
+    }
+
+});
+
+Y.extend(PackagesetPickerWidget, SelectWidget, {
+
+    initializer: function(config) {
+        this.client = new Y.lp.client.Launchpad();
+        this.error_handler = new Y.lp.client.ErrorHandler();
+        this.error_handler.clearProgressUI = Y.bind(this.hideSpinner, this);
+        this.error_handler.showError = Y.bind(this.showError, this);
+    }
+
+});
+
+namespace.PackagesetPickerWidget = PackagesetPickerWidget;
+
+
+/**
  * Setup the widgets on the +initseries page.
  *
  * @function setup
@@ -252,10 +475,19 @@
                  "Choose the architectures you want to " +
                  "use from the parent series."))
         .render(form_table_body);
-
-    // Wire up the distroseries select to the architectures widget.
+    var packageset_choice = new PackagesetPickerWidget()
+        .set("name", "field.packagesets")
+        .set("size", 5)
+        .set("multiple", true)
+        .set("label", "Package sets to copy from parent")
+        .set("description", (
+                 "The package sets that will be imported " +
+                 "into the derived distroseries."))
+        .render(form_table_body);
     var field_derived_from_series =
         form_table_body.one("[name=field.derived_from_series]");
+
+    // Wire up the distroseries select to the architectures widget.
     function update_architecture_choice() {
         architecture_choice
             .set("distroSeries", field_derived_from_series.get("value"));
@@ -265,6 +497,16 @@
     // Update the architectures widget for the selected distroseries.
     update_architecture_choice();
 
+    // Wire up the distroseries select to the packagesets widget.
+    function update_packageset_choice() {
+        packageset_choice
+            .set("distroSeries", field_derived_from_series.get("value"));
+    }
+    field_derived_from_series.on("change", update_packageset_choice);
+
+    // Update the packagesets widget for the selected distroseries.
+    update_packageset_choice();
+
     // Show the form.
     form_container.removeClass("unseen");
 };

=== modified file 'lib/lp/registry/javascript/tests/test_distroseries.js'
--- lib/lp/registry/javascript/tests/test_distroseries.js	2011-03-22 16:48:34 +0000
+++ lib/lp/registry/javascript/tests/test_distroseries.js	2011-03-22 16:48:38 +0000
@@ -15,12 +15,24 @@
     var console = new Y.Console({newestOnTop: false});
     console.render('#log');
 
-    var testCheckBoxListWidget = {
-        name: 'TestCheckBoxListWidget',
+    var attrgetter = function(name) {
+        return function(thing) {
+            return thing[name];
+        };
+    };
+
+    var attrselect = function(name) {
+        return function(things) {
+            return Y.Array(things).map(attrgetter(name));
+        };
+    };
+
+    var testFormRowWidget = {
+        name: 'TestFormRowWidget',
 
         setUp: function() {
             this.container = Y.Node.create("<div />");
-            this.widget = new initseries.CheckBoxListWidget();
+            this.widget = new initseries.FormRowWidget();
         },
 
         tearDown: function() {
@@ -34,46 +46,25 @@
                     this.widget.get("boundingBox")));
         },
 
-        testRenderChoices: function() {
-            this.widget.set("choices", ["a", "b"]);
-            this.widget.render(this.container);
-            ArrayAssert.itemsAreEqual(
-                ["a", "b"],
-                this.container.all("li > input").get("value"));
-            ArrayAssert.itemsAreEqual(
-                ["a", "b"],
-                this.container.all("li > label").get("text"));
-        },
-
-        testRenderChoicesChange: function() {
-            this.widget.set("choices", ["a", "b"]);
-            this.widget.render(this.container);
-            this.widget.set("choices", ["c", "d", "e"]);
-            ArrayAssert.itemsAreEqual(
-                ["c", "d", "e"],
-                this.container.all("li > input").get("value"));
-            ArrayAssert.itemsAreEqual(
-                ["c", "d", "e"],
-                this.container.all("li > label").get("text"));
-        },
-
         testRenderWithName: function() {
+            this.widget.fieldNode.append(
+                Y.Node.create("<input /><input />"));
             this.widget.set("name", "field");
-            this.widget.set("choices", ["a", "b"]);
             this.widget.render(this.container);
             ArrayAssert.itemsAreEqual(
                 ["field", "field"],
-                this.container.all("li > input").get("name"));
+                this.container.all("input").get("name"));
         },
 
         testRenderWithNameChange: function() {
+            this.widget.fieldNode.append(
+                Y.Node.create("<input /><input />"));
             this.widget.set("name", "field");
-            this.widget.set("choices", ["a", "b"]);
             this.widget.render(this.container);
             this.widget.set("name", "plain");
             ArrayAssert.itemsAreEqual(
                 ["plain", "plain"],
-                this.container.all("li > input").get("name"));
+                this.container.all("input").get("name"));
         },
 
         testRenderLabel: function() {
@@ -108,10 +99,69 @@
             Assert.areEqual(
                 "Another description.",
                 this.container.one("p.formHelp").get("text"));
-        }
-
-    };
-
+        },
+
+        testSpinner: function() {
+            Assert.isFalse(
+                this.widget.fieldNode.contains(this.widget.spinnerNode));
+            this.widget.showSpinner();
+            Assert.isTrue(
+                this.widget.fieldNode.contains(this.widget.spinnerNode));
+            this.widget.hideSpinner();
+            Assert.isFalse(
+                this.widget.fieldNode.contains(this.widget.spinnerNode));
+        },
+
+        testShowError: function() {
+            this.widget.showError("Unrealistic expectations.");
+            Assert.areEqual(
+                "Unrealistic expectations.",
+                this.widget.fieldNode.one("p").get("text"));
+        }
+
+    };
+
+    suite.add(new Y.Test.Case(testFormRowWidget));
+
+    var testCheckBoxListWidget = {
+        name: 'TestCheckBoxListWidget',
+
+        setUp: function() {
+            this.container = Y.Node.create("<div />");
+            this.widget = new initseries.CheckBoxListWidget();
+        },
+
+        tearDown: function() {
+            this.container.remove();
+        },
+
+        testRenderChoices: function() {
+            this.widget.set("choices", ["a", "b"]);
+            this.widget.render(this.container);
+            ArrayAssert.itemsAreEqual(
+                ["a", "b"],
+                this.container.all("li > input").get("value"));
+            ArrayAssert.itemsAreEqual(
+                ["a", "b"],
+                this.container.all("li > label").get("text"));
+        },
+
+        testRenderChoicesChange: function() {
+            this.widget.set("choices", ["a", "b"]);
+            this.widget.render(this.container);
+            this.widget.set("choices", ["c", "d", "e"]);
+            ArrayAssert.itemsAreEqual(
+                ["c", "d", "e"],
+                this.container.all("li > input").get("value"));
+            ArrayAssert.itemsAreEqual(
+                ["c", "d", "e"],
+                this.container.all("li > label").get("text"));
+        }
+
+    };
+
+    testCheckBoxListWidget = Y.merge(
+        testFormRowWidget, testCheckBoxListWidget);
     suite.add(new Y.Test.Case(testCheckBoxListWidget));
 
     var testArchitecturesCheckBoxListWidget = {
@@ -183,13 +233,13 @@
             widget.client = {
                 get: function(path, config) {
                     Assert.isFalse(
-                        widget.fieldNode.contains(widget.spinner));
+                        widget.fieldNode.contains(widget.spinnerNode));
                     config.on.start();
                     Assert.isTrue(
-                        widget.fieldNode.contains(widget.spinner));
+                        widget.fieldNode.contains(widget.spinnerNode));
                     config.on.end();
                     Assert.isFalse(
-                        widget.fieldNode.contains(widget.spinner));
+                        widget.fieldNode.contains(widget.spinnerNode));
                 }
             };
             this.widget.set("distroSeries", "ubuntu/hoary");
@@ -214,9 +264,281 @@
 
     testArchitecturesCheckBoxListWidget = Y.merge(
         testCheckBoxListWidget, testArchitecturesCheckBoxListWidget);
-
     suite.add(new Y.Test.Case(testArchitecturesCheckBoxListWidget));
 
+    var testSelectWidget = {
+        name: 'TestSelectWidget',
+
+        setUp: function() {
+            this.container = Y.Node.create("<div />");
+            this.widget = new initseries.SelectWidget();
+        },
+
+        tearDown: function() {
+            this.container.remove();
+        },
+
+        testNameChange: function() {
+            var choices = [
+                {value: "a", text: "A", data: 123},
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget
+                .set("name", "foo")
+                .set("choices", choices);
+            var select = this.widget.fieldNode.one("select");
+            Assert.areEqual("foo", select.get("name"));
+            this.widget
+                .set("name", "bar");
+            Assert.areEqual("bar", select.get("name"));
+        },
+
+        testChoices: function() {
+            var choices = [
+                {value: "a", text: "A", data: 123},
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget.set("choices", choices);
+            var choices_observed = this.widget.get("choices");
+            /* We have to compare bit by bit ourselves because
+               Javascript is a language born in hell. */
+            ArrayAssert.itemsAreEqual(
+                attrselect("value")(choices),
+                attrselect("value")(choices_observed));
+            ArrayAssert.itemsAreEqual(
+                attrselect("text")(choices),
+                attrselect("text")(choices_observed));
+            ArrayAssert.itemsAreEqual(
+                attrselect("data")(choices),
+                attrselect("data")(choices_observed));
+        },
+
+        testRenderChoices: function() {
+            var choices = [
+                {value: "a", text: "A", data: 123},
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget.set("choices", choices);
+            this.widget.render(this.container);
+            ArrayAssert.itemsAreEqual(
+                ["a", "b", "c"],
+                this.container.all("select > option").get("value"));
+            ArrayAssert.itemsAreEqual(
+                ["A", "B", "C"],
+                this.container.all("select > option").get("text"));
+        },
+
+        testRenderEmptyChoices: function() {
+            this.widget.fieldNode.append("something");
+            this.widget.set("choices", []);
+            this.widget.render(this.container);
+            Assert.isNull(this.container.one("select"));
+            Assert.isFalse(this.widget.fieldNode.hasChildNodes());
+        },
+
+        testRenderChoicesChange: function() {
+            var choices1 = [
+                {value: "a", text: "A", data: 123}
+            ];
+            this.widget.set("choices", choices1);
+            this.widget.render(this.container);
+            var choices2 = [
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget.set("choices", choices2);
+            ArrayAssert.itemsAreEqual(
+                ["b", "c"],
+                this.container.all("select > option").get("value"));
+            ArrayAssert.itemsAreEqual(
+                ["B", "C"],
+                this.container.all("select > option").get("text"));
+        },
+
+        testSize: function() {
+            Assert.areEqual(1, this.widget.get("size"));
+        },
+
+        testRenderSize: function() {
+            var choices = [
+                {value: "a", text: "A", data: 123},
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget
+                .set("choices", choices)
+                .set("size", 7)
+                .render(this.container);
+            Assert.areEqual(
+                7, this.widget.fieldNode.one("select").get("size"));
+        },
+
+        testRenderSizeChange: function() {
+            var choices = [
+                {value: "a", text: "A", data: 123},
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget
+                .set("choices", choices)
+                .set("size", 3)
+                .render(this.container)
+                .set("size", 5);
+            Assert.areEqual(
+                5, this.widget.fieldNode.one("select").get("size"));
+        },
+
+        testMultiple: function() {
+            Assert.areEqual(false, this.widget.get("multiple"));
+        },
+
+        testRenderMultiple: function() {
+            var choices = [
+                {value: "a", text: "A", data: 123},
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget
+                .set("choices", choices)
+                .set("multiple", true)
+                .render(this.container);
+            Assert.isTrue(
+                this.widget.fieldNode.one("select")
+                    .hasAttribute("multiple"));
+        },
+
+        testRenderMultipleChange: function() {
+            var choices = [
+                {value: "a", text: "A", data: 123},
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget
+                .set("choices", choices)
+                .set("multiple", true)
+                .render(this.container)
+                .set("multiple", false);
+            Assert.isFalse(
+                this.widget.fieldNode.one("select")
+                    .hasAttribute("multiple"));
+        }
+
+    };
+
+    testSelectWidget = Y.merge(
+        testFormRowWidget, testSelectWidget);
+    suite.add(new Y.Test.Case(testSelectWidget));
+
+    var testPackagesetPickerWidget = {
+        name: 'TestPackagesetPickerWidget',
+
+        setUp: function() {
+            this.container = Y.Node.create("<div />");
+            this.widget = new initseries.PackagesetPickerWidget();
+        },
+
+        tearDown: function() {
+            this.container.remove();
+        },
+
+        testSetPackageSetsUpdatesChoices: function() {
+            var package_sets = [
+                {name: "foo", description: "Foo"},
+                {name: "bar", description: "Bar"},
+                {name: "baz", description: "Baz"}
+            ];
+            var package_sets_collection =
+                new Y.lp.client.Collection(
+                    null, {entries: package_sets}, null);
+            this.widget.set(
+                "packageSets",
+                package_sets_collection);
+            var choices = this.widget.get("choices");
+            ArrayAssert.itemsAreEqual(
+                ["foo", "bar", "baz"],
+                attrselect("value")(choices));
+        },
+
+        testSetDistroSeriesInitiatesIO: function() {
+            var io = false;
+            this.widget.client = {
+                named_get: function(path, operation, config) {
+                    io = true;
+                    Assert.areEqual("package-sets", path);
+                    Assert.areEqual("getBySeries", operation);
+                    Assert.isNotNull(
+                        config.parameters.distroseries.match(
+                            new RegExp("/ubuntu/hoary$")));
+                    Assert.isObject(config.on);
+                    Assert.isFunction(config.on.success);
+                    Assert.isFunction(config.on.failure);
+                }
+            };
+            this.widget.set("distroSeries", "ubuntu/hoary");
+            Assert.isTrue(io, "No IO initiated.");
+        },
+
+        testSetDistroSeriesUpdatesPackageSets: function() {
+            var package_sets = [
+                {name: "foo", description: "Foo"},
+                {name: "bar", description: "Bar"},
+                {name: "baz", description: "Baz"}
+            ];
+            var package_sets_collection =
+                new Y.lp.client.Collection(
+                    null, {entries: package_sets}, null);
+            this.widget.client = {
+                named_get: function(path, operation, config) {
+                    config.on.success(package_sets_collection);
+                }
+            };
+            this.widget.set("distroSeries", "ubuntu/hoary");
+            ArrayAssert.itemsAreEqual(
+                ["foo", "bar", "baz"],
+                attrselect("value")(this.widget.get("choices")));
+        },
+
+        testSetDistroSeriesSpinner: function() {
+            var widget = this.widget;
+            widget.client = {
+                named_get: function(path, operation, config) {
+                    Assert.isFalse(
+                        widget.fieldNode.contains(widget.spinnerNode));
+                    config.on.start();
+                    Assert.isTrue(
+                        widget.fieldNode.contains(widget.spinnerNode));
+                    config.on.end();
+                    Assert.isFalse(
+                        widget.fieldNode.contains(widget.spinnerNode));
+                }
+            };
+            this.widget.set("distroSeries", "ubuntu/hoary");
+        },
+
+        testSetDistroSeriesError: function() {
+            var widget = this.widget;
+            widget.client = {
+                named_get: function(path, operation, config) {
+                    config.on.failure(
+                        null, {status: 404,
+                               responseText: "Not found"});
+                    Assert.areEqual(
+                        "Not found",
+                        widget.fieldNode.one("p").get("text"));
+                }
+            };
+            this.widget.set("distroSeries", "ubuntu/hoary");
+        }
+
+    };
+
+    testPackagesetPickerWidget = Y.merge(
+        testSelectWidget, testPackagesetPickerWidget);
+    suite.add(new Y.Test.Case(testPackagesetPickerWidget));
+
     Y.Test.Runner.add(suite);
 
     Y.on('domready', function() {

=== modified file 'lib/lp/soyuz/configure.zcml'
--- lib/lp/soyuz/configure.zcml	2011-03-10 14:05:51 +0000
+++ lib/lp/soyuz/configure.zcml	2011-03-22 16:48:38 +0000
@@ -806,6 +806,7 @@
                 get
                 getByName
                 getByOwner
+                getBySeries
                 setsIncludingSource"/>
         <require
             permission="launchpad.Edit"

=== modified file 'lib/lp/soyuz/interfaces/packageset.py'
--- lib/lp/soyuz/interfaces/packageset.py	2011-03-06 06:47:27 +0000
+++ lib/lp/soyuz/interfaces/packageset.py	2011-03-22 16:48:38 +0000
@@ -425,6 +425,22 @@
         """
 
     @operation_parameters(
+        distroseries=Reference(
+            IDistroSeries, title=_("Distroseries"), required=True,
+            readonly=True, description=_(
+                "The distribution series to which the packagesets "
+                "are related.")))
+    @operation_returns_collection_of(IPackageset)
+    @export_read_operation()
+    def getBySeries(distroseries):
+        """Return the package sets associated with the given distroseries.
+
+        :param distroseries: A `DistroSeries`.
+
+        :return: An iterable collection of `IPackageset` instances.
+        """
+
+    @operation_parameters(
         sourcepackagename=TextLine(
             title=_('Source package name'), required=True),
         distroseries=copy_field(IPackageset['distroseries'], required=False),
@@ -454,4 +470,3 @@
 
     def __getitem__(name):
         """Retrieve a package set by name."""
-

=== modified file 'lib/lp/soyuz/model/packageset.py'
--- lib/lp/soyuz/model/packageset.py	2011-03-05 09:57:00 +0000
+++ lib/lp/soyuz/model/packageset.py	2011-03-22 16:48:38 +0000
@@ -408,6 +408,13 @@
         result_set = store.find(Packageset, Packageset.owner == owner)
         return _order_result_set(result_set)
 
+    def getBySeries(self, distroseries):
+        """See `IPackagesetSet`."""
+        store = IStore(Packageset)
+        result_set = store.find(
+            Packageset, Packageset.distroseries == distroseries)
+        return _order_result_set(result_set)
+
     def get(self):
         """See `IPackagesetSet`."""
         store = IStore(Packageset)

=== modified file 'lib/lp/soyuz/stories/webservice/xx-packageset.txt'
--- lib/lp/soyuz/stories/webservice/xx-packageset.txt	2011-02-13 22:54:48 +0000
+++ lib/lp/soyuz/stories/webservice/xx-packageset.txt	2011-03-22 16:48:38 +0000
@@ -1,4 +1,5 @@
-= Package sets =
+Package sets
+------------
 
 Package sets facilitate the grouping of packages for purposes like the
 control of upload permissions, the calculation of build and runtime package
@@ -19,13 +20,13 @@
 interested in package sets and the complete functionality they offer.
 
 
-== General package set properties ==
+General package set properties
+==============================
 
 We start off by creating an 'umbrella' package set that will include all
 source packages.
 
     >>> from zope.component import getUtility
-    >>> from lp.registry.interfaces.person import IPersonSet
 
     >>> name12 = webservice.get("/~name12").jsonBody()
     >>> response = webservice.named_post(
@@ -85,7 +86,7 @@
 Populate the 'umbrella' package set with source packages.
 
     >>> from canonical.launchpad.webapp.interfaces import (
-    ...     IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR, MASTER_FLAVOR)
+    ...     IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
     >>> from lp.registry.model.sourcepackagename import SourcePackageName
     >>> store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
     >>> all_spns = store.find(SourcePackageName)
@@ -216,7 +217,8 @@
     ...
 
 
-=== Package sets and distro series ===
+Package sets and distro series
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 Every package set is associated with a distro series.
 
@@ -229,8 +231,21 @@
     >>> print mozilla['self_link']
     http://api.launchpad.dev/beta/package-sets/hoary/mozilla
 
-
-=== Related package sets ===
+A collection of package sets belonging to a given distro series can be
+obtained via the `getBySeries` call.
+
+    >>> packagesets = webservice.named_get(
+    ...     '/package-sets', 'getBySeries', {},
+    ...     distroseries=mozilla['distroseries_link']).jsonBody()
+    >>> for entry in packagesets["entries"]:
+    ...     print "{entry[name]}: {entry[description]}".format(entry=entry)
+    gnome: Contains all gnome packages
+    mozilla: Contains all mozilla packages
+    umbrella: Contains all source packages
+
+
+Related package sets
+~~~~~~~~~~~~~~~~~~~~
 
 When adding a package set we can specify that is to be related to another set
 that exists already.
@@ -267,7 +282,8 @@
     http://api.launchpad.dev/beta/package-sets/grumpy/mozilla
 
 
-== Package set hierarchy ==
+Package set hierarchy
+=====================
 
 More package sets are needed to set up the hierarchy described below.
 
@@ -519,7 +535,8 @@
     ["cnews", "thunderbird"]
 
 
-== Archive permissions and package sets ==
+Archive permissions and package sets
+====================================
 
 Operating on package set based archive permissions is possible via
 the Launchpad API as well.
@@ -544,7 +561,6 @@
 
 Let's see what we've got:
 
-    >>> from lazr.restful.testing.webservice import pprint_entry
     >>> new_permission = webservice.get(
     ...     response.getHeader('Location')).jsonBody()
     >>> pprint_entry(new_permission)
@@ -819,7 +835,8 @@
 set listed as well.
 
 
-== Archive permission URLs ==
+Archive permission URLs
+=======================
 
 Archive permissions can be accessed via their URLs in direct fashion.
 

=== modified file 'lib/lp/soyuz/tests/test_packageset.py'
--- lib/lp/soyuz/tests/test_packageset.py	2010-10-04 19:50:45 +0000
+++ lib/lp/soyuz/tests/test_packageset.py	2011-03-22 16:48:38 +0000
@@ -121,6 +121,20 @@
             'kernel', distroseries=self.distroseries_experimental)
         self.assertEqual(pset2, pset_found)
 
+    def test_get_by_distroseries(self):
+        # IPackagesetSet.getBySeries() will return those package sets
+        # associated with the given distroseries.
+        pset1 = self.packageset_set.new(
+            u'timmy', u'Timmy Mallett', self.person1)
+        pset2 = self.packageset_set.new(
+            u'savile', u'Jimmy Savile', self.person1)
+        self.packageset_set.new(
+            u'hoskins', u'Bob Hoskins', self.person1,
+            distroseries=self.distroseries_experimental)
+        self.assertContentEqual(
+            [pset1, pset2],
+            self.packageset_set.getBySeries(self.distroseries_current))
+
 
 class TestPackageset(TestCaseWithFactory):
 


Follow ups