← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:comment-editing-ui into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:comment-editing-ui into launchpad:master with ~pappacena/launchpad:comment-editing-revisions-api as a prerequisite.

Commit message:
Javascript component to edit messages, and its first usage in QuestionMessage view

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/402522
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:comment-editing-ui into launchpad:master.
diff --git a/lib/canonical/launchpad/icing/css/base.scss b/lib/canonical/launchpad/icing/css/base.scss
index 68bcce1..6ee2779 100644
--- a/lib/canonical/launchpad/icing/css/base.scss
+++ b/lib/canonical/launchpad/icing/css/base.scss
@@ -2,7 +2,8 @@
 
 body {
     /* line-height is the same as the sprite height. */
-    font-family: Ubuntu, 'Bitstream Vera Sans', 'DejaVu Sans', Tahoma, sans-serif;
+    font-family: Ubuntu, 'Bitstream Vera Sans', 'DejaVu Sans', Tahoma,
+                 sans-serif;
     font-size: 12px;
     line-height: 18px;
     color: #333;
@@ -444,7 +445,8 @@ body {
 
       table {
         th, td {
-          /* We don't want extra padding on nested tables, like batch navigation. */
+          /* We don't want extra padding on nested tables,
+             like batch navigation. */
           padding: 0;
         }
       }
@@ -543,6 +545,42 @@ body {
     border-bottom-left-radius: 5px;
   }
 
+  .editable-message {
+    .editable-message-notification {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      top: 0;
+      left: 0;
+      background-color: white;
+      opacity: 0.9;
+      display: flex;
+      flex-wrap: wrap;
+      justify-content: center;
+      align-items: center;
+
+      p {
+        display: block;
+        flex-basis: 100%;
+        margin-top: 10px;
+      }
+      .editable-message-notification-dismiss {
+        flex-basis: 100%;
+        text-align: center;
+        padding: 1px;
+        margin-top: -10px;
+      }
+    }
+
+    .editable-message-form {
+      padding: 0.5em 12px 0;
+      input[type="button"] {
+        padding: 1px;
+        margin: 5px;
+      }
+    }
+  }
+
 @import 'typography',
         'colours',
         'forms',
diff --git a/lib/lp/answers/browser/question.py b/lib/lp/answers/browser/question.py
index 18b4c47..104017e 100644
--- a/lib/lp/answers/browser/question.py
+++ b/lib/lp/answers/browser/question.py
@@ -1191,8 +1191,14 @@ class QuestionMessageDisplayView(LaunchpadView):
             # If a comment that isn't visible is being rendered, it's being
             # rendered for an admin or registry_expert.
             css_classes.append("adminHiddenComment")
+        if self.can_edit:
+            css_classes.append("editable-message")
         return " ".join(css_classes)
 
+    @property
+    def can_edit(self):
+        return check_permission('launchpad.Edit', self.context)
+
     def canConfirmAnswer(self):
         """Return True if the user can confirm this answer."""
         return (self.display_confirm_button and
diff --git a/lib/lp/answers/stories/question-workflow.txt b/lib/lp/answers/stories/question-workflow.txt
index 984aaf4..7d23e23 100755
--- a/lib/lp/answers/stories/question-workflow.txt
+++ b/lib/lp/answers/stories/question-workflow.txt
@@ -219,7 +219,8 @@ The confirmed answer is also highlighted.
     <img ... src="/@@/favourite-yes" ... title="Marked as best answer"/>
 
     >>> print(soup.find(
-    ...     'div', 'boardCommentBody highlighted').decode_contents())
+    ...     'div', 'boardCommentBody highlighted editable-message-body'
+    ... ).decode_contents())
     <p>New version of the firefox package are available with SVG support
     enabled. You can use apt-get or adept to upgrade.</p>
 
@@ -289,9 +290,9 @@ answerer back to None.
     >>> bestAnswer.find('strong') is None
     True
 
-    >>> bestAnswer.find('div', 'boardCommentBody')
-    <div class="boardCommentBody" itemprop="commentText"><p>New version
-    of the firefox package
+    >>> bestAnswer.find('div', 'boardCommentBody editable-message-body')
+    <div class="boardCommentBody editable-message-body"
+    itemprop="commentText"><p>New version of the firefox package
     are available with SVG support enabled. You can use apt-get or adept to
     upgrade.</p></div>
 
@@ -356,9 +357,9 @@ The answer's message is also highlighted as the best answer.
     No Privileges Person (no-priv)
 
     >>> message = soup.find(
-    ...     'div', 'boardCommentBody highlighted')
+    ...     'div', 'boardCommentBody highlighted editable-message-body')
     >>> print(message)
-    <div class="boardCommentBody highlighted"
+    <div class="boardCommentBody highlighted editable-message-body"
     itemprop="commentText"><p>New version of the firefox package are
     available with SVG support enabled. You can use apt-get or adept to
     upgrade.</p></div>
diff --git a/lib/lp/answers/templates/question-index.pt b/lib/lp/answers/templates/question-index.pt
index e7e233d..13f6270 100644
--- a/lib/lp/answers/templates/question-index.pt
+++ b/lib/lp/answers/templates/question-index.pt
@@ -17,7 +17,8 @@
     </style>
     <script type="text/javascript">
         LPJS.use('base', 'node', 'event',
-                'lp.app.comment', 'lp.answers.subscribers',
+                 'lp.app.comment', 'lp.answers.subscribers',
+                 'lp.services.messages.edit',
             function(Y) {
         Y.on('domready', function() {
             LP.cache.comment_context = LP.cache.context;
@@ -29,6 +30,7 @@
             cl.render();
             }
             new Y.lp.answers.subscribers.createQuestionSubscribersLoader();
+            Y.lp.services.messages.edit.setup();
         });
       });
     </script>
diff --git a/lib/lp/answers/templates/questionmessage-display.pt b/lib/lp/answers/templates/questionmessage-display.pt
index b69b4b6..c5a6869 100644
--- a/lib/lp/answers/templates/questionmessage-display.pt
+++ b/lib/lp/answers/templates/questionmessage-display.pt
@@ -5,9 +5,10 @@
 <div
   itemscope=""
   itemtype="http://schema.org/UserComments";
-  tal:define="css_classes view/getBoardCommentCSSClass"
+  tal:define="css_classes python: view.getBoardCommentCSSClass()"
   tal:attributes="class string:${css_classes};
-                  id string:comment-${context/index}">
+                  id string:comment-${context/index};
+                  data-baseurl context/fmt:url">
   <div class="boardCommentDetails">
     <table>
       <tbody>
@@ -26,7 +27,18 @@
       tal:attributes="title context/datecreated/fmt:datetime;
         datetime context/datecreated/fmt:isodate"
       tal:content="context/datecreated/fmt:displaydate">Thursday
-    13:21</time>:
+    13:21</time>
+    <span tal:condition="context/date_last_edit">
+        (last edit <time
+              itemprop="editTime"
+              tal:attributes="title context/date_last_edit/fmt:datetime;
+                datetime context/date_last_edit/fmt:isodate"
+              tal:content="context/date_last_edit/fmt:displaydate" />)
+      </span>
+    </td>
+    <td>
+      <img class="sprite edit action-icon editable-message-edit-btn"
+           tal:condition="view/can_edit"/>
     </td>
     <td class="bug-comment-index">
       <a
@@ -36,13 +48,20 @@
   </div>
 
   <div class="boardCommentBody"
-    tal:attributes="class view/getBodyCSSClass"
+    tal:attributes="class python: view.getBodyCSSClass() + ' editable-message-body'"
     itemprop="commentText"
     tal:content="structure
       context/text_contents/fmt:obfuscate-email/fmt:email-to-html">
     Message text.
   </div>
 
+  <div class="editable-message-form" style="display: none">
+     <textarea style="width: 100%" rows="10"
+               tal:content="context/text_contents" />
+     <input type="button" value="Update" class="editable-message-update-btn" />
+     <input type="button" value="Cancel" class="editable-message-cancel-btn" />
+  </div>
+
   <div class="confirmBox"
         tal:condition="view/canConfirmAnswer">
     <form action=""
diff --git a/lib/lp/services/messages/interfaces/message.py b/lib/lp/services/messages/interfaces/message.py
index 7dea2f8..7ac4fb4 100644
--- a/lib/lp/services/messages/interfaces/message.py
+++ b/lib/lp/services/messages/interfaces/message.py
@@ -58,7 +58,7 @@ class IMessageEdit(Interface):
 
     @export_write_operation()
     @operation_parameters(
-        new_content=TextLine(
+        new_content=Text(
             title=_("Message content"),
             description=_("The new message content string"),
             required=True))
diff --git a/lib/lp/services/messages/javascript/messages.edit.js b/lib/lp/services/messages/javascript/messages.edit.js
new file mode 100644
index 0000000..d54a36e
--- /dev/null
+++ b/lib/lp/services/messages/javascript/messages.edit.js
@@ -0,0 +1,168 @@
+/* Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * This modules controls HTML comments in order to make them editable. To do
+ * so, it requires:
+ *  - A div container with the class .editable-message containing everything
+ *      else related to the message
+ *  - A data-baseurl="/path/to/msg" on the .editable-message container
+ *  - A .editable-message-body container with the original msg content
+ *  - A .editable-message-edit-btn element inside the main container, that will
+ *      switch the view to edit form when clicked.
+ *  - A .editable-message-form, with a textarea and 2 buttons:
+ *      .editable-message-update-btn and  .editable-message-cancel-btn.
+ *
+ * Once those HTML elements are available in the page, this module should be
+ * initialized with `lp.services.messages.edit.setup()`.
+ *
+ * @module Y.lp.services.messages.edit
+ * @requires node, DOM, lp.client
+ */
+YUI.add('lp.services.messages.edit', function(Y) {
+    var module = Y.namespace('lp.services.messages.edit');
+
+    module.msg_edit_success_notification = (
+        "Message edited, but the original content may still be publicly " +
+        "visible using the API.<br />Please, " +
+        "<a href='https://launchpad.net/+apidoc/devel.html#message'>" +
+        "check the API documentation</a> in case " +
+        "need to remove old message revisions."
+    );
+    module.msg_edit_error_notification = (
+        "There was an error updating the comment. " +
+        "Please, try again in some minutes."
+    );
+
+    module.htmlify_msg = function(text) {
+        text = text.replace(/</g, "&lt;");
+        text = text.replace(/>/g, "&gt;");
+        text = text.replace(/\n/g, "<br/>");
+        return "<p>" + text    + "</p>";
+    };
+
+    module.show_edit_message_field = function(msg_body, msg_form) {
+        msg_body.setStyle('display', 'none');
+        msg_form.setStyle('display', 'block');
+    };
+
+    module.hide_edit_message_field = function(msg_body, msg_form) {
+        msg_body.setStyle('display', 'block');
+        msg_form.setStyle('display', 'none');
+    };
+
+    module.save_message_content = function(
+            msg_path, new_content, on_success, on_failure) {
+        var msg_url = "/api/devel" + msg_path;
+        var config = {
+            on: {
+                 success: on_success,
+                 failure: on_failure
+             },
+            parameters: {"new_content": new_content}
+        };
+        this.lp_client.named_post(msg_url, 'editContent', config);
+    };
+
+    module.show_notification = function(container, msg, can_dismiss) {
+        can_dismiss = can_dismiss || false;
+        // Clean up previous notification.
+        module.hide_notification(container);
+        container.setStyle('position', 'relative');
+        var node = Y.Node.create(
+            "<div class='editable-message-notification'>" +
+            "  <p class='block-sprite large-warning'>" +
+            msg +
+            "  </p>" +
+            "</div>");
+         container.append(node);
+         if (can_dismiss) {
+             var dismiss = Y.Node.create(
+                "<div class='editable-message-notification-dismiss'>" +
+                "  <input type='button' value=' Ok ' />" +
+                "</div>");
+             dismiss.on('click', function() {
+                module.hide_notification(container);
+             });
+             node.append(dismiss);
+         }
+    };
+
+    module.hide_notification = function(container) {
+        var notification = container.one(".editable-message-notification");
+        if(notification) {
+            notification.remove();
+        }
+    };
+
+    module.show_loading = function(container) {
+        module.show_notification(
+            container,
+            '<img class="spinner" src="/@@/spinner" alt="Loading..." />');
+    };
+
+    module.hide_loading = function(container) {
+        module.hide_notification(container);
+    };
+
+    module.setup = function() {
+        this.lp_client = new Y.lp.client.Launchpad();
+
+        Y.all('.editable-message').each(function(container) {
+            var node = container.getDOMNode();
+            var baseurl = node.dataset.baseurl;
+            var msg_body = container.one('.editable-message-body');
+            var msg_form = container.one('.editable-message-form');
+            var edit_btn = container.one('.editable-message-edit-btn');
+            var update_btn = msg_form.one('.editable-message-update-btn');
+            var cancel_btn = msg_form.one('.editable-message-cancel-btn');
+
+            module.hide_edit_message_field(msg_body, msg_form);
+
+            // When clicking edit icon, show the edit form and focus on the
+            // text area.
+            edit_btn.on('click', function(e) {
+                module.show_edit_message_field(msg_body, msg_form);
+                msg_form.one('textarea').getDOMNode().focus();
+            });
+
+            // When clicking on "update" button, disable UI elements and send a
+            // request to update the message at the backend.
+            update_btn.on('click', function(e) {
+                module.show_loading(container);
+                var textarea = msg_form.one('textarea').getDOMNode();
+                var new_content = textarea.value;
+                textarea.disabled = true;
+                update_btn.getDOMNode().disabled = true;
+
+                module.save_message_content(
+                    baseurl, new_content, function() {
+                        // When finished updating at the backend, re-enable UI
+                        // elements and display the new message.
+                        var html_msg = module.htmlify_msg(new_content);
+                        msg_body.getDOMNode().innerHTML = html_msg;
+                        module.hide_edit_message_field(msg_body, msg_form);
+                        textarea.disabled = false;
+                        update_btn.getDOMNode().disabled = false;
+                        module.hide_loading(container);
+                        module.show_notification(
+                            container,
+                            module.msg_edit_success_notification, true);
+                    },
+                    function(err) {
+                        // When something goes wrong at the backend, re-enable
+                        // UI elements and display an error.
+                        module.show_notification(
+                            container,
+                            module.msg_edit_error_notification,  true);
+                        textarea.disabled = false;
+                        update_btn.getDOMNode().disabled = false;
+                    }
+                );
+            });
+
+            cancel_btn.on('click', function(e) {
+                module.hide_edit_message_field(msg_body, msg_form);
+            });
+        });
+    };
+}, '0.1', {'requires': ['lp.client', 'node', 'DOM']});
diff --git a/lib/lp/services/messages/javascript/tests/test_messages.edit.html b/lib/lp/services/messages/javascript/tests/test_messages.edit.html
new file mode 100644
index 0000000..0fd6056
--- /dev/null
+++ b/lib/lp/services/messages/javascript/tests/test_messages.edit.html
@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<!--
+Copyright 2021 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>Test message edit</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
+      <link rel="stylesheet"
+      href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
+
+      <link rel="stylesheet"
+            href="../../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/client.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/lp.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/anim/anim.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/extras/extras.js"></script>
+      <script type="text/javascript"
+          src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../messages.edit.js"></script>
+
+      <!-- Any css assert for this module. -->
+      <!-- <link rel="stylesheet" href="../assets/archive-packages-core.css" /> -->
+
+      <!-- The test suite. -->
+      <script type="text/javascript" src="test_messages.edit.js"></script>
+
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.services.messages.edit.test</li>
+        </ul>
+
+        <div class="editable-message" id="first-message"
+             data-baseurl="/message/1">
+            <div class="editable-message-body"></div>
+            <img class="sprite edit action-icon editable-message-edit-btn">
+
+            <div class="editable-message-form">
+                <textarea></textarea>
+                <input type="button" value="Update" class="editable-message-update-btn" />
+                <input type="button" value="Cancel" class="editable-message-cancel-btn" />
+            </div>
+        </div>
+
+        <div class="editable-message" id="second-message"
+             data-baseurl="/message/2">
+            <div class="editable-message-body"></div>
+            <img class="sprite edit action-icon editable-message-edit-btn">
+
+            <div class="editable-message-form">
+                <textarea></textarea>
+                <input type="button" value="Update" class="editable-message-update-btn" />
+                <input type="button" value="Cancel" class="editable-message-cancel-btn" />
+            </div>
+        </div>
+    </body>
+</html>
diff --git a/lib/lp/services/messages/javascript/tests/test_messages.edit.js b/lib/lp/services/messages/javascript/tests/test_messages.edit.js
new file mode 100644
index 0000000..9f9f549
--- /dev/null
+++ b/lib/lp/services/messages/javascript/tests/test_messages.edit.js
@@ -0,0 +1,155 @@
+/**
+ * Copyright 2012-2021 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Tests for lp.services.messages.edit.
+ *
+ * @module lp.services.messages.edit
+ * @submodule test
+ */
+
+YUI.add('lp.services.messages.edit.test', function(Y) {
+
+    var namespace = Y.namespace('lp.services.messages.edit.test');
+
+    var suite = new Y.Test.Suite("lp.services.messages.edit Tests");
+    var module = Y.lp.services.messages.edit;
+
+    function assertDisplayStyles(items, visibility) {
+        for(var i=items ; i<items.length ; i++) {
+             Y.Assert.areSame(visibility, items[i].getStyle("display"));
+        }
+    }
+
+    function assertDisplayStyle(item, visibility) {
+        Y.Assert.areSame(visibility, item.getStyle("display"));
+    }
+
+    var TestMessageEdit = {
+        name: "TestMessageEdit",
+
+        setUp: function() {
+            this.containers = [
+                Y.one("#first-message"), Y.one("#second-message")];
+            this.msg_bodies = [
+                this.containers[0].one(".editable-message-body"),
+                this.containers[1].one(".editable-message-body")
+            ];
+            this.msg_forms = [
+                this.containers[0].one(".editable-message-form"),
+                this.containers[1].one(".editable-message-form")
+            ];
+            this.edit_icons = [
+                this.containers[0].one(".editable-message-edit-btn"),
+                this.containers[1].one(".editable-message-edit-btn")
+            ];
+            this.cancel_btns = [
+                this.containers[0].one(".editable-message-cancel-btn"),
+                this.containers[1].one(".editable-message-cancel-btn")
+            ];
+            this.textareas = [
+                this.msg_forms[0].one("textarea"),
+                this.msg_forms[1].one("textarea")
+            ];
+            this.update_btns = [
+                this.containers[0].one(".editable-message-update-btn"),
+                this.containers[1].one(".editable-message-update-btn")
+            ];
+
+            for(var i=0 ; i<this.containers.length ; i++) {
+                this.msg_bodies[i].getDOMNode().innerHTML = (
+                    "Message number " + i);
+                this.msg_bodies[i].setStyle('display', '');
+                this.msg_forms[i].setStyle('display', '');
+                this.textareas[i].getDOMNode().value = '';
+            }
+        },
+
+        test_instantiation_hides_forms: function() {
+            // When editable messages are initialized, the forms should be
+            // hidden.
+            module.setup();
+
+            assertDisplayStyles(this.msg_bodies, 'block');
+            assertDisplayStyles(this.msg_forms, 'none');
+        },
+
+        test_click_edit_icon_shows_form: function() {
+            // Makes sure the form is shown when we click one of the edit icons.
+            module.setup();
+            this.edit_icons[1].simulate('click');
+
+            // Form 1 should be visible...
+            assertDisplayStyle(this.msg_bodies[1], 'none');
+            assertDisplayStyle(this.msg_forms[1], 'block');
+
+            // ... but form 0 should have not be affected.
+            assertDisplayStyle(this.msg_bodies[0], 'block');
+            assertDisplayStyle(this.msg_forms[0], 'none');
+        },
+
+        test_cancel_button_hides_form: function() {
+            // Makes sure the form is hidden again if the user, after clicking
+            // edit icons, decides to cancel edition.
+            module.setup();
+            this.edit_icons[1].simulate('click');
+            this.cancel_btns[1].simulate('click');
+
+            assertDisplayStyle(this.msg_bodies[0], 'block');
+            assertDisplayStyle(this.msg_forms[0], 'none');
+            assertDisplayStyle(this.msg_bodies[1], 'block');
+            assertDisplayStyle(this.msg_forms[1], 'none');
+        },
+
+        test_success_save_comment_edition: function() {
+            module.setup();
+            module.lp_client.io_provider = new Y.lp.testing.mockio.MockIo();
+
+            this.edit_icons[1].simulate('click');
+            this.textareas[1].getDOMNode().value = 'edited\nmessage <foo>';
+            this.update_btns[1].simulate('click');
+
+            // Checks that only the current form interactions are blocked.
+            Y.Assert.isTrue(this.textareas[1].getDOMNode().disabled);
+            Y.Assert.isTrue(this.update_btns[1].getDOMNode().disabled);
+            Y.Assert.isFalse(this.textareas[0].getDOMNode().disabled);
+            Y.Assert.isFalse(this.update_btns[0].getDOMNode().disabled);
+
+            module.lp_client.io_provider.success({
+                responseText:'null',
+                responseHeaders: {'Content-Type': 'application/json'}
+            });
+            Y.Assert.areSame(
+                '<p>edited<br>message &lt;foo&gt;</p>',
+                this.msg_bodies[1].getDOMNode().innerHTML);
+
+            // All forms should be released.
+            Y.Assert.isFalse(this.textareas[1].getDOMNode().disabled);
+            Y.Assert.isFalse(this.update_btns[1].getDOMNode().disabled);
+            Y.Assert.isFalse(this.textareas[0].getDOMNode().disabled);
+            Y.Assert.isFalse(this.update_btns[0].getDOMNode().disabled);
+
+            // Check forms and msg bodies visibility are back to normal.
+            assertDisplayStyle(this.msg_bodies[0], 'block');
+            assertDisplayStyle(this.msg_forms[0], 'none');
+            assertDisplayStyle(this.msg_bodies[1], 'block');
+            assertDisplayStyle(this.msg_forms[1], 'none');
+
+            // Check that the request was made correctly.
+            var last_request = module.lp_client.io_provider.last_request;
+            Y.Assert.areSame("/api/devel/message/2", last_request.url);
+            Y.Assert.areSame("POST", last_request.config.method);
+            Y.Assert.areSame(
+                "ws.op=editContent&new_content=edited%0Amessage%20%3Cfoo%3E",
+                last_request.config.data);
+        }
+
+    };
+
+    suite.add(new Y.Test.Case(TestMessageEdit));
+
+    namespace.suite = suite;
+
+}, "0.1", {"requires": [
+               "lp.services.messages.edit", "node", "lp.testing.mockio",
+               "node-event-simulate", "test", "lp.anim"]});
diff --git a/lib/lp/services/messages/tests/test_yuitests.py b/lib/lp/services/messages/tests/test_yuitests.py
new file mode 100644
index 0000000..e614a1c
--- /dev/null
+++ b/lib/lp/services/messages/tests/test_yuitests.py
@@ -0,0 +1,26 @@
+# Copyright 2011-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Run YUI.test tests."""
+
+__metaclass__ = type
+__all__ = []
+
+from lp.testing import (
+    build_yui_unittest_suite,
+    YUIUnitTestCase,
+    )
+from lp.testing.layers import YUITestLayer
+
+
+class MessagesYUIUnitTestCase(YUIUnitTestCase):
+
+    layer = YUITestLayer
+    suite_name = 'MessagesYUIUnitTests'
+
+
+def test_suite():
+    app_testing_path = 'lp/services/messages'
+    return build_yui_unittest_suite(
+            app_testing_path,
+            MessagesYUIUnitTestCase)

Follow ups