← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~alvarocs/launchpad:mp-subscopes-ui into launchpad:master

 

Alvaro Crespo Serrano has proposed merging ~alvarocs/launchpad:mp-subscopes-ui into launchpad:master.

Commit message:
Enable merge proposal subscopes through the UI

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~alvarocs/launchpad/+git/launchpad/+merge/485628

Update Launchpad webhook UI to properly show the webhook event type subscopes properly. The UI now indents subscopes and disables/unchecks them when the parent is checked.

Code changes:
-style.css: add the .indentation CSS class for subscopes.
-itemswidgets.py: new MultilevelLabeledMultiCheckBoxWidget to handle subscopes.
-webhooks/browser.py: drop previous vocabulary class, use new widget in add and edit views. Point the Add view to a new js template.
-webhooks/interfaces.py: rewrite ValidWebhookEventTypeVocabulary class to enforce order of parent and subscopes.
-webhooks/model.py: new helper method parent_of
-webhooks/javascript/deliveries.js: add common namespace.initScopeCheckboxes() function
-webhooks/templates/webhook-add.pt: template for Add webhook view
-webhooks/templates/webhook-index: import the new JS function from deliveries.js script

Tests:
-webhooks/test_browser.py: update event-type expected with new subscopes
-webhooks/test_interfaces.py: unit tests for ValidWebhookEventTypeVocabulary.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~alvarocs/launchpad:mp-subscopes-ui into launchpad:master.
diff --git a/lib/canonical/launchpad/icing/style.css b/lib/canonical/launchpad/icing/style.css
index a66400f..a5d5400 100644
--- a/lib/canonical/launchpad/icing/style.css
+++ b/lib/canonical/launchpad/icing/style.css
@@ -918,6 +918,9 @@ table.listing tr.webhook-delivery:hover {
     cursor: pointer;
 }
 
+.indentation {
+  padding-left: 1rem;
+}
 
 /* ====== Content area styles ====== */
 
diff --git a/lib/lp/app/widgets/itemswidgets.py b/lib/lp/app/widgets/itemswidgets.py
index 54f6c90..492715e 100644
--- a/lib/lp/app/widgets/itemswidgets.py
+++ b/lib/lp/app/widgets/itemswidgets.py
@@ -10,6 +10,7 @@ __all__ = [
     "LaunchpadDropdownWidget",
     "LaunchpadRadioWidget",
     "LaunchpadRadioWidgetWithDescription",
+    "MultilevelLabeledMultiCheckBoxWidget",
     "PlainMultiCheckBoxWidget",
 ]
 
@@ -23,6 +24,7 @@ from zope.schema.interfaces import IChoice
 from zope.schema.vocabulary import SimpleVocabulary
 
 from lp.services.webapp.escaping import html_escape
+from lp.services.webhooks.model import Webhook
 
 
 class LaunchpadDropdownWidget(DropdownWidget):
@@ -111,6 +113,47 @@ class LabeledMultiCheckBoxWidget(PlainMultiCheckBoxWidget):
         return self._joinButtonToMessageTemplate % (option_id, elem, text)
 
 
+class MultilevelLabeledMultiCheckBoxWidget(PlainMultiCheckBoxWidget):
+    """MultiCheckBoxWidget which wraps option labels with proper
+    <label> elements.
+    """
+
+    SUBSCOPE_CSS = "indentation"
+
+    def _renderItem(self, index, text, value, name, cssClass, checked=False):
+        """Render a checkbox and text in a label with a style attribute."""
+
+        kw = {}
+
+        label_class = ""
+        if Webhook.is_subscope(value):
+            label_class = f' class="{self.SUBSCOPE_CSS}"'
+            kw["data-parent"] = Webhook.parent_of(value)
+
+        _label = (
+            '<label for="%s"%s style="font-weight: normal">%s&nbsp;%s</label> '
+        )
+
+        if checked:
+            kw["checked"] = "checked"
+        if value in self.disabled_items:
+            kw["disabled"] = "disabled"
+        value = html_escape(value)
+        text = html_escape(text)
+        id = "%s.%s" % (name, index)
+        elem = renderElement(
+            "input",
+            value=value,
+            name=name,
+            id=id,
+            cssClass=cssClass,
+            type="checkbox",
+            **kw,
+        )
+        option_id = "%s.%s" % (self.name, index)
+        return _label % (option_id, label_class, elem, text)
+
+
 # XXX Brad Bollenbach 2006-08-10 bugs=56062: This is a hack to
 # workaround Zope's RadioWidget not properly selecting the default value.
 class LaunchpadRadioWidget(RadioWidget):
diff --git a/lib/lp/services/webhooks/browser.py b/lib/lp/services/webhooks/browser.py
index bafba3d..226c498 100644
--- a/lib/lp/services/webhooks/browser.py
+++ b/lib/lp/services/webhooks/browser.py
@@ -12,15 +12,13 @@ from lazr.restful.interface import use_template
 from lazr.restful.interfaces import IJSONRequestCache
 from zope.component import getUtility
 from zope.interface import Interface
-from zope.schema import Choice, List
-from zope.schema.vocabulary import SimpleVocabulary
 
 from lp.app.browser.launchpadform import (
     LaunchpadEditFormView,
     LaunchpadFormView,
     action,
 )
-from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from lp.app.widgets.itemswidgets import MultilevelLabeledMultiCheckBoxWidget
 from lp.code.interfaces.gitrepository import IGitRepository
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
@@ -35,38 +33,7 @@ from lp.services.webapp.batching import (
     get_batch_properties_for_json_cache,
 )
 from lp.services.webapp.breadcrumb import Breadcrumb
-from lp.services.webhooks.interfaces import (
-    WEBHOOK_EVENT_TYPES,
-    IWebhook,
-    IWebhookSet,
-)
-from lp.services.webhooks.model import Webhook
-
-
-class UIValidWebhookEventTypeVocabulary(SimpleVocabulary):
-    """A UI-specific vocabulary that excludes subscopes.
-
-    This is used in form views to present only top-level
-    event types (e.g., 'merge-proposal:0.1'), hiding subscopes,
-    which currently have no UI support.
-
-    The full list including subscopes is still available via
-    the Launchpad API through ValidWebhookEventTypeVocabulary.
-    """
-
-    def __init__(self, context):
-        if IWebhook.providedBy(context):
-            target = context.target
-        else:
-            target = context
-
-        terms = []
-        for key in target.valid_webhook_event_types:
-            if not Webhook.is_subscope(key):
-                terms.append(
-                    self.createTerm(key, key, WEBHOOK_EVENT_TYPES[key])
-                )
-        super().__init__(terms)
+from lp.services.webhooks.interfaces import IWebhook, IWebhookSet
 
 
 class WebhookNavigation(Navigation):
@@ -138,23 +105,18 @@ class WebhookEditSchema(Interface):
         include=[
             "delivery_url",
             "active",
+            "event_types",
             "secret",
             "git_ref_pattern",
         ],
     )
 
-    event_types = List(
-        title="Event types",
-        required=True,
-        value_type=Choice(vocabulary="UIValidWebhookEventType"),
-    )
-
 
 class WebhookAddView(LaunchpadFormView):
     page_title = label = "Add webhook"
 
     schema = WebhookEditSchema
-    custom_widget_event_types = LabeledMultiCheckBoxWidget
+    custom_widget_event_types = MultilevelLabeledMultiCheckBoxWidget
     next_url = None
 
     @property
@@ -199,7 +161,7 @@ class WebhookView(LaunchpadEditFormView):
     label = "Manage webhook"
 
     schema = WebhookEditSchema
-    custom_widget_event_types = LabeledMultiCheckBoxWidget
+    custom_widget_event_types = MultilevelLabeledMultiCheckBoxWidget
 
     @property
     def field_names(self):
diff --git a/lib/lp/services/webhooks/configure.zcml b/lib/lp/services/webhooks/configure.zcml
index de2dda9..3c7ad5c 100644
--- a/lib/lp/services/webhooks/configure.zcml
+++ b/lib/lp/services/webhooks/configure.zcml
@@ -41,18 +41,6 @@
     <class class="lp.services.webhooks.interfaces.ValidWebhookEventTypeVocabulary">
         <allow interface="zope.schema.interfaces.IVocabularyTokenized"/>
     </class>
-    
-    <lp:securedutility
-        name="UIValidWebhookEventType"
-        component="lp.services.webhooks.browser.UIValidWebhookEventTypeVocabulary"
-        provides="zope.schema.interfaces.IVocabularyFactory">
-        <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
-    </lp:securedutility>
-
-    <class class="lp.services.webhooks.browser.UIValidWebhookEventTypeVocabulary">
-        <allow interface="zope.schema.interfaces.IVocabularyTokenized"/>
-    </class>
-
     <lp:securedutility
         component="lp.services.webhooks.model.WebhookJob"
         provides="lp.services.webhooks.interfaces.IWebhookJobSource">
@@ -108,7 +96,7 @@
         name="+new-webhook"
         permission="launchpad.Edit"
         class="lp.services.webhooks.browser.WebhookAddView"
-        template="../../app/templates/generic-edit.pt" />
+        template="templates/webhook-add.pt" />
 
     <adapter
         provides="lp.services.webapp.interfaces.IBreadcrumb"
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index 307dccd..d0fb52c 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -92,6 +92,10 @@ class AnyWebhookEventTypeVocabulary(SimpleVocabulary):
 
 
 class ValidWebhookEventTypeVocabulary(SimpleVocabulary):
+    """Vocabulary that returns the valid webhook event types in
+    parent-subscopes order.
+    """
+
     def __init__(self, context):
         # When creating a webhook, the context is the target; when editing
         # an existing webhook, the context is the webhook itself.
@@ -99,10 +103,27 @@ class ValidWebhookEventTypeVocabulary(SimpleVocabulary):
             target = context.target
         else:
             target = context
-        terms = [
-            self.createTerm(key, key, WEBHOOK_EVENT_TYPES[key])
-            for key in target.valid_webhook_event_types
-        ]
+        subscopes_by_parent = {}
+        top_level = []
+        for key in target.valid_webhook_event_types:
+            # Group subscopes under their parent
+            if "::" in key:
+                parent = key.split("::")[0]
+                subscopes_by_parent.setdefault(parent, []).append(key)
+            else:
+                top_level.append(key)
+        terms = []
+        # Create terms for parents and for their corresponding subscopes
+        for parent in top_level:
+            terms.append(
+                self.createTerm(parent, parent, WEBHOOK_EVENT_TYPES[parent])
+            )
+            for subscope in subscopes_by_parent.get(parent, []):
+                terms.append(
+                    self.createTerm(
+                        subscope, subscope, WEBHOOK_EVENT_TYPES[subscope]
+                    )
+                )
         super().__init__(terms)
 
 
diff --git a/lib/lp/services/webhooks/javascript/deliveries.js b/lib/lp/services/webhooks/javascript/deliveries.js
index 81aea30..d3d6eb8 100644
--- a/lib/lp/services/webhooks/javascript/deliveries.js
+++ b/lib/lp/services/webhooks/javascript/deliveries.js
@@ -301,7 +301,48 @@ namespace.retry_delivery = function(deliveries_widget, delivery_url) {
     var lp_client = new Y.lp.client.Launchpad();
     lp_client.named_post(delivery_url, 'retry', config);
 };
+/**
+ * Activate the 'parent-subscope' behaviour for the check‑boxes
+ * rendered by MultilevelLabeledMultiCheckBoxWidget.
+ */
+namespace.initScopeCheckboxes = function () {
+    // Handle checkbox parent-child relationships
+    var handleCheckboxChange = function (e) {
+        var checkbox       = e.currentTarget;
+        var value          = checkbox.get('value');
+        var isChecked      = checkbox.get('checked');
+        // If this is a parent checkbox, handle children
+        var childCheckboxes =
+            Y.all('input[type="checkbox"][data-parent="' + value + '"]');
+
+        childCheckboxes.each(function (childCheckbox) {
+            if (isChecked) {
+                // If parent is checked, uncheck and disable child
+                childCheckbox.set('checked', false);
+                childCheckbox.set('disabled', true);
+            } else {
+                // If parent is unchecked, enable child
+                childCheckbox.set('disabled', false);
+            }
+        });
+    };
 
+    // Set up event listeners for all checkboxes
+    var checkboxes = Y.all('input[type="checkbox"]');
+    checkboxes.on('change', handleCheckboxChange);
+
+    // Find all parent checkboxes that are checked
+    var checkedParents = Y.all('input[type="checkbox"]:checked');
+    checkedParents.each(function (parentCheckbox) {
+        var value          = parentCheckbox.get('value');
+        var childCheckboxes =
+            Y.all('input[type="checkbox"][data-parent="' + value + '"]');
+        childCheckboxes.each(function (childCheckbox) {
+            childCheckbox.set('checked', false);
+            childCheckbox.set('disabled', true);
+        });
+    });
+};
 }, "0.1", {"requires": ["event", "node", "widget", "lp.app.date",
                         "lp.app.listing_navigator", "lp.client",
                         "lp.mustache"]});
diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
index 994f72d..8027fe5 100644
--- a/lib/lp/services/webhooks/model.py
+++ b/lib/lp/services/webhooks/model.py
@@ -209,6 +209,11 @@ class Webhook(StormBase):
         """Return True if the event_type is a subscope (contains '::')."""
         return "::" in event_type
 
+    @staticmethod
+    def parent_of(event_type: str) -> str:
+        """Return the top‑level parent of a subscope event type."""
+        return event_type.split("::", 1)[0]
+
 
 @implementer(IWebhookSet)
 class WebhookSet:
diff --git a/lib/lp/services/webhooks/templates/webhook-add.pt b/lib/lp/services/webhooks/templates/webhook-add.pt
new file mode 100644
index 0000000..3a0189c
--- /dev/null
+++ b/lib/lp/services/webhooks/templates/webhook-add.pt
@@ -0,0 +1,25 @@
+<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";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+
+  <head>
+    <metal:script fill-slot="head_epilogue">
+      <script tal:content="structure string:
+        LPJS.use('base','node','event','lp.services.webhooks.deliveries',
+          function (Y) {
+             Y.lp.services.webhooks.deliveries.initScopeCheckboxes();
+          });
+      "/>
+    </metal:script>
+  </head>
+
+  <body>
+    <div metal:fill-slot="main">
+      <div metal:use-macro="context/@@launchpad_form/form" />
+    </div>
+  </body>
+</html>
\ No newline at end of file
diff --git a/lib/lp/services/webhooks/templates/webhook-index.pt b/lib/lp/services/webhooks/templates/webhook-index.pt
index ce04e54..a4ae1cc 100644
--- a/lib/lp/services/webhooks/templates/webhook-index.pt
+++ b/lib/lp/services/webhooks/templates/webhook-index.pt
@@ -52,7 +52,13 @@
                   deliveries_widget.render();
               });
           });
+      LPJS.use('base','node','event','lp.services.webhooks.deliveries',
+        function (Y) {
+           Y.lp.services.webhooks.deliveries.initScopeCheckboxes();
+        });
     "/>
+    <script type="text/javascript">
+    </script>
   </metal:block>
 </head>
 <body>
diff --git a/lib/lp/services/webhooks/tests/test_browser.py b/lib/lp/services/webhooks/tests/test_browser.py
index 283a009..ce6ebe6 100644
--- a/lib/lp/services/webhooks/tests/test_browser.py
+++ b/lib/lp/services/webhooks/tests/test_browser.py
@@ -66,6 +66,12 @@ class GitRepositoryTestHelpers:
         ("ci:build:0.1", "CI build"),
         ("git:push:0.1", "Git push"),
         ("merge-proposal:0.1", "Merge proposal"),
+        ("merge-proposal:0.1::create", "Merge proposal Created"),
+        ("merge-proposal:0.1::edit", "Merge proposal Edited"),
+        ("merge-proposal:0.1::status-change", "Merge proposal Status Changed"),
+        ("merge-proposal:0.1::delete", "Merge proposal Deleted"),
+        ("merge-proposal:0.1::push", "Merge proposal Pushed"),
+        ("merge-proposal:0.1::review", "Merge proposal Reviewed"),
     ]
 
     def makeTarget(self):
@@ -80,6 +86,12 @@ class BranchTestHelpers:
     expected_event_types = [
         ("bzr:push:0.1", "Bazaar push"),
         ("merge-proposal:0.1", "Merge proposal"),
+        ("merge-proposal:0.1::create", "Merge proposal Created"),
+        ("merge-proposal:0.1::edit", "Merge proposal Edited"),
+        ("merge-proposal:0.1::status-change", "Merge proposal Status Changed"),
+        ("merge-proposal:0.1::delete", "Merge proposal Deleted"),
+        ("merge-proposal:0.1::push", "Merge proposal Pushed"),
+        ("merge-proposal:0.1::review", "Merge proposal Reviewed"),
     ]
 
     def makeTarget(self):
diff --git a/lib/lp/services/webhooks/tests/test_interfaces.py b/lib/lp/services/webhooks/tests/test_interfaces.py
new file mode 100644
index 0000000..9b3568e
--- /dev/null
+++ b/lib/lp/services/webhooks/tests/test_interfaces.py
@@ -0,0 +1,59 @@
+import unittest
+
+from zope.interface import implementer
+
+from lp.services.webhooks.interfaces import (
+    IWebhookTarget,
+    ValidWebhookEventTypeVocabulary,
+)
+
+
+@implementer(IWebhookTarget)
+class FakeTarget:
+    def __init__(self, valid_event_types):
+        self.valid_webhook_event_types = valid_event_types
+
+
+class TestValidWebhookEventTypeVocabulary(unittest.TestCase):
+    def test_ordering_with_parent_and_subscopes(self):
+        target = FakeTarget(
+            [
+                "merge-proposal:0.1",
+                "merge-proposal:0.1::create",
+                "merge-proposal:0.1::push",
+                "git:push:0.1",
+            ]
+        )
+        vocab = ValidWebhookEventTypeVocabulary(target)
+        self.assertEqual(
+            [term.token for term in vocab],
+            [
+                "merge-proposal:0.1",
+                "merge-proposal:0.1::create",
+                "merge-proposal:0.1::push",
+                "git:push:0.1",
+            ],
+        )
+
+    def test_skips_subscope_without_parent(self):
+        target = FakeTarget(
+            [
+                "merge-proposal:0.1::review",
+                "git:push:0.1",
+            ]
+        )
+        vocab = ValidWebhookEventTypeVocabulary(target)
+        self.assertEqual([term.token for term in vocab], ["git:push:0.1"])
+
+    def test_parent_only(self):
+        target = FakeTarget(
+            [
+                "git:push:0.1",
+                "merge-proposal:0.1",
+            ]
+        )
+        vocab = ValidWebhookEventTypeVocabulary(target)
+        self.assertEqual(
+            [term.token for term in vocab],
+            ["git:push:0.1", "merge-proposal:0.1"],
+        )

Follow ups