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

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: d0b05df06e00a068657554df752ea1623be6ffc4
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:comment-editing-ui
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:comment-editing-revisions-api
Diff against target: 754 lines (+593/-19)
10 files modified
lib/canonical/launchpad/icing/css/base.scss (+40/-2)
lib/lp/answers/browser/question.py (+6/-0)
lib/lp/answers/stories/question-workflow.txt (+7/-6)
lib/lp/answers/templates/question-index.pt (+3/-1)
lib/lp/answers/templates/questionmessage-display.pt (+29/-9)
lib/lp/services/messages/interfaces/message.py (+1/-1)
lib/lp/services/messages/javascript/messages.edit.js (+211/-0)
lib/lp/services/messages/javascript/tests/test_messages.edit.html (+95/-0)
lib/lp/services/messages/javascript/tests/test_messages.edit.js (+175/-0)
lib/lp/services/messages/tests/test_yuitests.py (+26/-0)
Reviewer Review Type Date Requested Status
Colin Watson Approve
Review via email: mp+402522@code.launchpad.net

Commit message

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

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested changes. This should be good to go now.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/canonical/launchpad/icing/css/base.scss b/lib/canonical/launchpad/icing/css/base.scss
2index 68bcce1..6ee2779 100644
3--- a/lib/canonical/launchpad/icing/css/base.scss
4+++ b/lib/canonical/launchpad/icing/css/base.scss
5@@ -2,7 +2,8 @@
6
7 body {
8 /* line-height is the same as the sprite height. */
9- font-family: Ubuntu, 'Bitstream Vera Sans', 'DejaVu Sans', Tahoma, sans-serif;
10+ font-family: Ubuntu, 'Bitstream Vera Sans', 'DejaVu Sans', Tahoma,
11+ sans-serif;
12 font-size: 12px;
13 line-height: 18px;
14 color: #333;
15@@ -444,7 +445,8 @@ body {
16
17 table {
18 th, td {
19- /* We don't want extra padding on nested tables, like batch navigation. */
20+ /* We don't want extra padding on nested tables,
21+ like batch navigation. */
22 padding: 0;
23 }
24 }
25@@ -543,6 +545,42 @@ body {
26 border-bottom-left-radius: 5px;
27 }
28
29+ .editable-message {
30+ .editable-message-notification {
31+ position: absolute;
32+ width: 100%;
33+ height: 100%;
34+ top: 0;
35+ left: 0;
36+ background-color: white;
37+ opacity: 0.9;
38+ display: flex;
39+ flex-wrap: wrap;
40+ justify-content: center;
41+ align-items: center;
42+
43+ p {
44+ display: block;
45+ flex-basis: 100%;
46+ margin-top: 10px;
47+ }
48+ .editable-message-notification-dismiss {
49+ flex-basis: 100%;
50+ text-align: center;
51+ padding: 1px;
52+ margin-top: -10px;
53+ }
54+ }
55+
56+ .editable-message-form {
57+ padding: 0.5em 12px 0;
58+ input[type="button"] {
59+ padding: 1px;
60+ margin: 5px;
61+ }
62+ }
63+ }
64+
65 @import 'typography',
66 'colours',
67 'forms',
68diff --git a/lib/lp/answers/browser/question.py b/lib/lp/answers/browser/question.py
69index 68c4b59..9d4986d 100644
70--- a/lib/lp/answers/browser/question.py
71+++ b/lib/lp/answers/browser/question.py
72@@ -1200,8 +1200,14 @@ class QuestionMessageDisplayView(LaunchpadView):
73 # If a comment that isn't visible is being rendered, it's being
74 # rendered for an admin or registry_expert.
75 css_classes.append("adminHiddenComment")
76+ if self.can_edit:
77+ css_classes.append("editable-message")
78 return " ".join(css_classes)
79
80+ @property
81+ def can_edit(self):
82+ return check_permission('launchpad.Edit', self.context)
83+
84 def canConfirmAnswer(self):
85 """Return True if the user can confirm this answer."""
86 return (self.display_confirm_button and
87diff --git a/lib/lp/answers/stories/question-workflow.txt b/lib/lp/answers/stories/question-workflow.txt
88index 984aaf4..5e39acd 100755
89--- a/lib/lp/answers/stories/question-workflow.txt
90+++ b/lib/lp/answers/stories/question-workflow.txt
91@@ -219,7 +219,8 @@ The confirmed answer is also highlighted.
92 <img ... src="/@@/favourite-yes" ... title="Marked as best answer"/>
93
94 >>> print(soup.find(
95- ... 'div', 'boardCommentBody highlighted').decode_contents())
96+ ... 'div', 'boardCommentBody highlighted editable-message-text'
97+ ... ).decode_contents())
98 <p>New version of the firefox package are available with SVG support
99 enabled. You can use apt-get or adept to upgrade.</p>
100
101@@ -289,9 +290,9 @@ answerer back to None.
102 >>> bestAnswer.find('strong') is None
103 True
104
105- >>> bestAnswer.find('div', 'boardCommentBody')
106- <div class="boardCommentBody" itemprop="commentText"><p>New version
107- of the firefox package
108+ >>> bestAnswer.find('div', 'boardCommentBody editable-message-text')
109+ <div class="boardCommentBody editable-message-text"
110+ itemprop="commentText"><p>New version of the firefox package
111 are available with SVG support enabled. You can use apt-get or adept to
112 upgrade.</p></div>
113
114@@ -356,9 +357,9 @@ The answer's message is also highlighted as the best answer.
115 No Privileges Person (no-priv)
116
117 >>> message = soup.find(
118- ... 'div', 'boardCommentBody highlighted')
119+ ... 'div', 'boardCommentBody highlighted editable-message-text')
120 >>> print(message)
121- <div class="boardCommentBody highlighted"
122+ <div class="boardCommentBody highlighted editable-message-text"
123 itemprop="commentText"><p>New version of the firefox package are
124 available with SVG support enabled. You can use apt-get or adept to
125 upgrade.</p></div>
126diff --git a/lib/lp/answers/templates/question-index.pt b/lib/lp/answers/templates/question-index.pt
127index e7e233d..13f6270 100644
128--- a/lib/lp/answers/templates/question-index.pt
129+++ b/lib/lp/answers/templates/question-index.pt
130@@ -17,7 +17,8 @@
131 </style>
132 <script type="text/javascript">
133 LPJS.use('base', 'node', 'event',
134- 'lp.app.comment', 'lp.answers.subscribers',
135+ 'lp.app.comment', 'lp.answers.subscribers',
136+ 'lp.services.messages.edit',
137 function(Y) {
138 Y.on('domready', function() {
139 LP.cache.comment_context = LP.cache.context;
140@@ -29,6 +30,7 @@
141 cl.render();
142 }
143 new Y.lp.answers.subscribers.createQuestionSubscribersLoader();
144+ Y.lp.services.messages.edit.setup();
145 });
146 });
147 </script>
148diff --git a/lib/lp/answers/templates/questionmessage-display.pt b/lib/lp/answers/templates/questionmessage-display.pt
149index b69b4b6..fc3651c 100644
150--- a/lib/lp/answers/templates/questionmessage-display.pt
151+++ b/lib/lp/answers/templates/questionmessage-display.pt
152@@ -7,7 +7,8 @@
153 itemtype="http://schema.org/UserComments"
154 tal:define="css_classes view/getBoardCommentCSSClass"
155 tal:attributes="class string:${css_classes};
156- id string:comment-${context/index}">
157+ id string:comment-${context/index};
158+ data-baseurl context/fmt:url">
159 <div class="boardCommentDetails">
160 <table>
161 <tbody>
162@@ -25,8 +26,18 @@
163 itemprop="commentTime"
164 tal:attributes="title context/datecreated/fmt:datetime;
165 datetime context/datecreated/fmt:isodate"
166- tal:content="context/datecreated/fmt:displaydate">Thursday
167- 13:21</time>:
168+ tal:content="context/datecreated/fmt:displaydate">Thursday 13:21
169+ </time><span class="editable-message-last-edit-date"><tal:last-edit condition="context/date_last_edited">
170+ (last edit <time
171+ itemprop="editTime"
172+ tal:attributes="title context/date_last_edited/fmt:datetime;
173+ datetime context/date_last_edited/fmt:isodate"
174+ tal:content="context/date_last_edited/fmt:displaydate" />)</tal:last-edit>:
175+ </span>
176+ </td>
177+ <td>
178+ <img class="sprite edit action-icon editable-message-edit-btn"
179+ tal:condition="view/can_edit"/>
180 </td>
181 <td class="bug-comment-index">
182 <a
183@@ -35,12 +46,21 @@
184 </tr></tbody></table>
185 </div>
186
187- <div class="boardCommentBody"
188- tal:attributes="class view/getBodyCSSClass"
189- itemprop="commentText"
190- tal:content="structure
191- context/text_contents/fmt:obfuscate-email/fmt:email-to-html">
192- Message text.
193+ <div class="editable-message-body">
194+ <div class="boardCommentBody"
195+ tal:attributes="class python: view.getBodyCSSClass() + ' editable-message-text'"
196+ itemprop="commentText"
197+ tal:content="structure
198+ context/text_contents/fmt:obfuscate-email/fmt:email-to-html">
199+ Message text.
200+ </div>
201+ </div>
202+
203+ <div class="editable-message-form" style="display: none">
204+ <textarea style="width: 100%" rows="10"
205+ tal:content="context/text_contents" />
206+ <input type="button" value="Update" class="editable-message-update-btn" />
207+ <input type="button" value="Cancel" class="editable-message-cancel-btn" />
208 </div>
209
210 <div class="confirmBox"
211diff --git a/lib/lp/services/messages/interfaces/message.py b/lib/lp/services/messages/interfaces/message.py
212index 598b665..a719d66 100644
213--- a/lib/lp/services/messages/interfaces/message.py
214+++ b/lib/lp/services/messages/interfaces/message.py
215@@ -58,7 +58,7 @@ class IMessageEdit(Interface):
216
217 @export_write_operation()
218 @operation_parameters(
219- new_content=TextLine(
220+ new_content=Text(
221 title=_("Message content"),
222 description=_("The new message content string"),
223 required=True))
224diff --git a/lib/lp/services/messages/javascript/messages.edit.js b/lib/lp/services/messages/javascript/messages.edit.js
225new file mode 100644
226index 0000000..3c4c0e5
227--- /dev/null
228+++ b/lib/lp/services/messages/javascript/messages.edit.js
229@@ -0,0 +1,211 @@
230+/* Copyright 2015-2021 Canonical Ltd. This software is licensed under the
231+ * GNU Affero General Public License version 3 (see the file LICENSE).
232+ *
233+ * This modules controls HTML comments in order to make them editable. To do
234+ * so, it requires:
235+ * - A div container with the class .editable-message containing everything
236+ * else related to the message
237+ * - A data-baseurl="/path/to/msg" on the .editable-message container
238+ * - A .editable-message-body container with the original msg content
239+ * - A .editable-message-edit-btn element inside the main container, that will
240+ * switch the view to edit form when clicked.
241+ * - A .editable-message-form, with a textarea and 2 buttons:
242+ * .editable-message-update-btn and .editable-message-cancel-btn.
243+ * - A .editable-message-last-edit-date span, where we update the date of the
244+ * last message editing.
245+ *
246+ * Once those HTML elements are available in the page, this module should be
247+ * initialized with `lp.services.messages.edit.setup()`.
248+ *
249+ * @module Y.lp.services.messages.edit
250+ * @requires node, DOM, lp.client
251+ */
252+YUI.add('lp.services.messages.edit', function(Y) {
253+ var module = Y.namespace('lp.services.messages.edit');
254+
255+ // XXX pappacena 2021-05-21: We should drop this message once we have a
256+ // way to list the old message revisions in the web UI.
257+ module.msg_edit_success_notification = (
258+ "Message edited, but the original content may still be publicly " +
259+ "visible using the API.<br />Please " +
260+ "<a href='https://launchpad.net/+apidoc/devel.html#message'>" +
261+ "check the API documentation</a> in case you " +
262+ "need to remove old message revisions."
263+ );
264+ module.msg_edit_error_notification = (
265+ "There was an error updating the comment. " +
266+ "Please try again in a few minutes."
267+ );
268+
269+ module.htmlify_msg = function(text) {
270+ text = text.replace(/&/g, "&amp;");
271+ text = text.replace(/</g, "&lt;");
272+ text = text.replace(/>/g, "&gt;");
273+ text = text.replace(/\n/g, "<br/>");
274+ return "<p>" + text + "</p>";
275+ };
276+
277+ module.showEditMessageField = function(msg_body, msg_form) {
278+ msg_body.setStyle('display', 'none');
279+ msg_form.setStyle('display', 'block');
280+ };
281+
282+ module.hideEditMessageField = function(msg_body, msg_form) {
283+ msg_body.setStyle('display', 'block');
284+ msg_form.setStyle('display', 'none');
285+ };
286+
287+ module.saveMessageContent = function(
288+ msg_path, new_content, on_success, on_failure) {
289+ var msg_url = "/api/devel" + msg_path;
290+ var config = {
291+ on: {
292+ success: on_success,
293+ failure: on_failure
294+ },
295+ parameters: {"new_content": new_content}
296+ };
297+ this.lp_client.named_post(msg_url, 'editContent', config);
298+ };
299+
300+ module.showNotification = function(container, msg, can_dismiss) {
301+ can_dismiss = can_dismiss || false;
302+ // Clean up previous notification.
303+ module.hideNotification(container);
304+ container.setStyle('position', 'relative');
305+ var node = Y.Node.create(
306+ "<div class='editable-message-notification'>" +
307+ " <p class='block-sprite large-warning'>" +
308+ msg +
309+ " </p>" +
310+ "</div>");
311+ container.append(node);
312+ if (can_dismiss) {
313+ var dismiss = Y.Node.create(
314+ "<div class='editable-message-notification-dismiss'>" +
315+ " <input type='button' value='Ok' />" +
316+ "</div>");
317+ dismiss.on('click', function() {
318+ module.hideNotification(container);
319+ });
320+ node.append(dismiss);
321+ }
322+ };
323+
324+ module.hideNotification = function(container) {
325+ var notification = container.one(".editable-message-notification");
326+ if(notification) {
327+ notification.remove();
328+ }
329+ };
330+
331+ module.showLoading = function(container) {
332+ module.showNotification(
333+ container,
334+ '<img class="spinner" src="/@@/spinner" alt="Loading..." />');
335+ };
336+
337+ module.hideLoading = function(container) {
338+ module.hideNotification(container);
339+ };
340+
341+ // What to do when a user clicks a message's "edit" button.
342+ module.onEditClick = function(elements) {
343+ // When clicking edit icon, show the edit form and focus on the
344+ // text area.
345+ module.showEditMessageField(elements.msg_body, elements.msg_form);
346+ elements.msg_form.one('textarea').getDOMNode().focus();
347+ }
348+
349+ // What to do when a user clicks "cancel edit" button.
350+ module.onEditCancelClick = function(elements) {
351+ module.hideEditMessageField(elements.msg_body, elements.msg_form);
352+ };
353+
354+ // What to do when a user clicks the update button after editing a msg.
355+ module.onUpdateClick = function(elements, baseurl) {
356+ // When clicking on "update" button, disable UI elements and send a
357+ // request to update the message at the backend.
358+ module.showLoading(elements.container);
359+ var textarea = elements.textarea.getDOMNode();
360+ var new_content = textarea.value;
361+ textarea.disabled = true;
362+ elements.update_btn.getDOMNode().disabled = true;
363+
364+ module.saveMessageContent(
365+ baseurl, new_content,
366+ function(err) { module.onMessageSaved(elements, new_content); },
367+ function(err) { module.onMessageSaveError(elements, err); }
368+ );
369+ };
370+
371+ // What to do when a message is saved in the backend.
372+ module.onMessageSaved = function(elements, new_content) {
373+ // When finished updating at the backend, re-enable UI
374+ // elements and display the new message.
375+ var html_msg = module.htmlify_msg(new_content);
376+ elements.msg_body_text.getDOMNode().innerHTML = html_msg;
377+ module.hideEditMessageField(
378+ elements.msg_body, elements.msg_form);
379+ elements.textarea.getDOMNode().disabled = false;
380+ elements.update_btn.getDOMNode().disabled = false;
381+ module.hideLoading(elements.container);
382+ module.showNotification(
383+ elements.container,
384+ module.msg_edit_success_notification, true);
385+ elements.last_edit.getDOMNode().innerHTML = (
386+ ' (last edit a moment ago): ');
387+ };
388+
389+ // What to do when a message fails to update on the backend.
390+ module.onMessageSaveError = function(elements, err) {
391+ // When something goes wrong at the backend, re-enable
392+ // UI elements and display an error.
393+ module.showNotification(
394+ elements.container,
395+ module.msg_edit_error_notification, true);
396+ elements.textarea.getDOMNode().disabled = false;
397+ elements.update_btn.getDOMNode().disabled = false;
398+ };
399+
400+ module.wireEventHandlers = function(container) {
401+ var node = container.getDOMNode();
402+ var baseurl = node.dataset.baseurl;
403+ var elements = {
404+ "container": container,
405+ "msg_body": container.one('.editable-message-body'),
406+ "msg_body_text": container.one('.editable-message-text'),
407+ "msg_form": container.one('.editable-message-form'),
408+ "edit_btn": container.one('.editable-message-edit-btn'),
409+ "update_btn": container.one('.editable-message-update-btn'),
410+ "cancel_btn": container.one('.editable-message-cancel-btn'),
411+ "last_edit": container.one('.editable-message-last-edit-date')
412+ };
413+ elements.textarea = elements.msg_form.one('textarea');
414+
415+ module.hideEditMessageField(elements.msg_body, elements.msg_form);
416+
417+ // If the edit button is not present, do not try to bind the
418+ // handlers.
419+ if (!elements.edit_btn || !baseurl) {
420+ return;
421+ }
422+
423+ elements.edit_btn.on('click', function(e) {
424+ module.onEditClick(elements);
425+ });
426+
427+ elements.update_btn.on('click', function(e) {
428+ module.onUpdateClick(elements, baseurl);
429+ });
430+
431+ elements.cancel_btn.on('click', function(e) {
432+ module.onEditCancelClick(elements);
433+ });
434+ };
435+
436+ module.setup = function() {
437+ this.lp_client = new Y.lp.client.Launchpad();
438+ Y.all('.editable-message').each(module.wireEventHandlers);
439+ };
440+}, '0.1', {'requires': ['lp.client', 'node', 'DOM']});
441diff --git a/lib/lp/services/messages/javascript/tests/test_messages.edit.html b/lib/lp/services/messages/javascript/tests/test_messages.edit.html
442new file mode 100644
443index 0000000..483f425
444--- /dev/null
445+++ b/lib/lp/services/messages/javascript/tests/test_messages.edit.html
446@@ -0,0 +1,95 @@
447+<!DOCTYPE html>
448+<!--
449+Copyright 2021 Canonical Ltd. This software is licensed under the
450+GNU Affero General Public License version 3 (see the file LICENSE).
451+-->
452+
453+<html>
454+ <head>
455+ <title>Test message edit</title>
456+
457+ <!-- YUI and test setup -->
458+ <script type="text/javascript"
459+ src="../../../../../../build/js/yui/yui/yui.js">
460+ </script>
461+ <link rel="stylesheet"
462+ href="../../../../../../build/js/yui/console/assets/console-core.css" />
463+ <link rel="stylesheet"
464+ href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
465+ <link rel="stylesheet"
466+ href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
467+
468+ <script type="text/javascript"
469+ src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
470+
471+ <link rel="stylesheet"
472+ href="../../../../app/javascript/testing/test.css" />
473+
474+ <!-- Dependencies -->
475+ <script type="text/javascript"
476+ src="../../../../../../build/js/lp/app/client.js"></script>
477+ <script type="text/javascript"
478+ src="../../../../../../build/js/lp/app/lp.js"></script>
479+ <script type="text/javascript"
480+ src="../../../../../../build/js/lp/app/anim/anim.js"></script>
481+ <script type="text/javascript"
482+ src="../../../../../../build/js/lp/app/extras/extras.js"></script>
483+ <script type="text/javascript"
484+ src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
485+
486+ <!-- The module under test. -->
487+ <script type="text/javascript" src="../messages.edit.js"></script>
488+
489+ <!-- Any css assert for this module. -->
490+ <!-- <link rel="stylesheet" href="../assets/archive-packages-core.css" /> -->
491+
492+ <!-- The test suite. -->
493+ <script type="text/javascript" src="test_messages.edit.js"></script>
494+
495+ </head>
496+ <body class="yui3-skin-sam">
497+ <ul id="suites">
498+ <li>lp.services.messages.edit.test</li>
499+ </ul>
500+
501+ <div class="editable-message" id="first-message"
502+ data-baseurl="/message/1">
503+ <div>
504+ Comment from @some-user a while ago
505+ <span class="editable-message-last-edit-date">:</span>
506+ </div>
507+ <div class="editable-message-body">
508+ <div class="editable-message-text"></div>
509+ The message is above :)
510+ </div>
511+ <img class="sprite edit action-icon editable-message-edit-btn">
512+
513+ <div class="editable-message-form">
514+ <textarea></textarea>
515+ <input type="button" value="Update" class="editable-message-update-btn" />
516+ <input type="button" value="Cancel" class="editable-message-cancel-btn" />
517+ </div>
518+ </div>
519+
520+ <div class="editable-message" id="second-message"
521+ data-baseurl="/message/2">
522+ <div>
523+ Comment from @some-user a while ago
524+ <span class="editable-message-last-edit-date">
525+ (last edit 5 minutes ago):
526+ </span>
527+ </div>
528+ <div class="editable-message-body">
529+ <div class="editable-message-text"></div>
530+ The message is above :)
531+ </div>
532+ <img class="sprite edit action-icon editable-message-edit-btn">
533+
534+ <div class="editable-message-form">
535+ <textarea></textarea>
536+ <input type="button" value="Update" class="editable-message-update-btn" />
537+ <input type="button" value="Cancel" class="editable-message-cancel-btn" />
538+ </div>
539+ </div>
540+ </body>
541+</html>
542diff --git a/lib/lp/services/messages/javascript/tests/test_messages.edit.js b/lib/lp/services/messages/javascript/tests/test_messages.edit.js
543new file mode 100644
544index 0000000..a8a982c
545--- /dev/null
546+++ b/lib/lp/services/messages/javascript/tests/test_messages.edit.js
547@@ -0,0 +1,175 @@
548+/**
549+ * Copyright 2012-2021 Canonical Ltd. This software is licensed under the
550+ * GNU Affero General Public License version 3 (see the file LICENSE).
551+ *
552+ * Tests for lp.services.messages.edit.
553+ *
554+ * @module lp.services.messages.edit
555+ * @submodule test
556+ */
557+
558+YUI.add('lp.services.messages.edit.test', function(Y) {
559+
560+ var namespace = Y.namespace('lp.services.messages.edit.test');
561+
562+ var suite = new Y.Test.Suite("lp.services.messages.edit Tests");
563+ var module = Y.lp.services.messages.edit;
564+
565+ function assertDisplayStyles(items, visibility) {
566+ for(var i=items ; i<items.length ; i++) {
567+ Y.Assert.areSame(visibility, items[i].getStyle("display"));
568+ }
569+ }
570+
571+ function assertDisplayStyle(item, visibility) {
572+ Y.Assert.areSame(visibility, item.getStyle("display"));
573+ }
574+
575+ var TestMessageEdit = {
576+ name: "TestMessageEdit",
577+
578+ setUp: function() {
579+ this.containers = [
580+ Y.one("#first-message"), Y.one("#second-message")];
581+ this.last_edit = [
582+ this.containers[0].one(".editable-message-last-edit-date"),
583+ this.containers[1].one(".editable-message-last-edit-date")
584+ ];
585+ this.msg_bodies = [
586+ this.containers[0].one(".editable-message-body"),
587+ this.containers[1].one(".editable-message-body")
588+ ];
589+ this.msg_texts = [
590+ this.containers[0].one(".editable-message-text"),
591+ this.containers[1].one(".editable-message-text")
592+ ];
593+ this.msg_forms = [
594+ this.containers[0].one(".editable-message-form"),
595+ this.containers[1].one(".editable-message-form")
596+ ];
597+ this.edit_icons = [
598+ this.containers[0].one(".editable-message-edit-btn"),
599+ this.containers[1].one(".editable-message-edit-btn")
600+ ];
601+ this.cancel_btns = [
602+ this.containers[0].one(".editable-message-cancel-btn"),
603+ this.containers[1].one(".editable-message-cancel-btn")
604+ ];
605+ this.textareas = [
606+ this.msg_forms[0].one("textarea"),
607+ this.msg_forms[1].one("textarea")
608+ ];
609+ this.update_btns = [
610+ this.containers[0].one(".editable-message-update-btn"),
611+ this.containers[1].one(".editable-message-update-btn")
612+ ];
613+
614+ for(var i=0 ; i<this.containers.length ; i++) {
615+ this.msg_texts[i].getDOMNode().innerHTML = (
616+ "Message number " + i);
617+ this.msg_bodies[i].setStyle('display', '');
618+ this.msg_forms[i].setStyle('display', '');
619+ this.textareas[i].getDOMNode().value = '';
620+ this.last_edit[0].getDOMNode().innerHTML = ':';
621+ this.last_edit[1].getDOMNode().innerHTML = (
622+ '(last edit 5 minutes ago):');
623+ }
624+ },
625+
626+ test_instantiation_hides_forms: function() {
627+ // When editable messages are initialized, the forms should be
628+ // hidden.
629+ module.setup();
630+
631+ assertDisplayStyles(this.msg_bodies, 'block');
632+ assertDisplayStyles(this.msg_forms, 'none');
633+ },
634+
635+ test_click_edit_icon_shows_form: function() {
636+ // Makes sure the form is shown when we click one of the edit icons.
637+ module.setup();
638+ this.edit_icons[1].simulate('click');
639+
640+ // Form 1 should be visible...
641+ assertDisplayStyle(this.msg_bodies[1], 'none');
642+ assertDisplayStyle(this.msg_forms[1], 'block');
643+
644+ // ... but form 0 should have not be affected.
645+ assertDisplayStyle(this.msg_bodies[0], 'block');
646+ assertDisplayStyle(this.msg_forms[0], 'none');
647+ },
648+
649+ test_cancel_button_hides_form: function() {
650+ // Makes sure the form is hidden again if the user, after clicking
651+ // edit icons, decides to cancel edition.
652+ module.setup();
653+ this.edit_icons[1].simulate('click');
654+ this.cancel_btns[1].simulate('click');
655+
656+ assertDisplayStyle(this.msg_bodies[0], 'block');
657+ assertDisplayStyle(this.msg_forms[0], 'none');
658+ assertDisplayStyle(this.msg_bodies[1], 'block');
659+ assertDisplayStyle(this.msg_forms[1], 'none');
660+ },
661+
662+ test_success_save_comment_edition: function() {
663+ module.setup();
664+ module.lp_client.io_provider = new Y.lp.testing.mockio.MockIo();
665+
666+ // Edit the comment index #1
667+ this.edit_icons[1].simulate('click');
668+ var new_message = 'edited\nmessage <foo>';
669+ var uri_encoded_new_message = encodeURI(new_message);
670+ this.textareas[1].getDOMNode().value = new_message;
671+ this.update_btns[1].simulate('click');
672+
673+ // Checks that only the current form interactions are blocked.
674+ Y.Assert.isTrue(this.textareas[1].getDOMNode().disabled);
675+ Y.Assert.isTrue(this.update_btns[1].getDOMNode().disabled);
676+ Y.Assert.isFalse(this.textareas[0].getDOMNode().disabled);
677+ Y.Assert.isFalse(this.update_btns[0].getDOMNode().disabled);
678+
679+ module.lp_client.io_provider.success({
680+ responseText:'null',
681+ responseHeaders: {'Content-Type': 'application/json'}
682+ });
683+ Y.Assert.areSame(
684+ '<p>edited<br>message &lt;foo&gt;</p>',
685+ this.msg_texts[1].getDOMNode().innerHTML);
686+
687+ // All forms should be released.
688+ Y.Assert.isFalse(this.textareas[1].getDOMNode().disabled);
689+ Y.Assert.isFalse(this.update_btns[1].getDOMNode().disabled);
690+ Y.Assert.isFalse(this.textareas[0].getDOMNode().disabled);
691+ Y.Assert.isFalse(this.update_btns[0].getDOMNode().disabled);
692+
693+ // Check forms and msg bodies visibility are back to normal.
694+ assertDisplayStyle(this.msg_bodies[0], 'block');
695+ assertDisplayStyle(this.msg_forms[0], 'none');
696+ assertDisplayStyle(this.msg_bodies[1], 'block');
697+ assertDisplayStyle(this.msg_forms[1], 'none');
698+
699+ // Check that the request was made correctly.
700+ var last_request = module.lp_client.io_provider.last_request;
701+ Y.Assert.areSame("/api/devel/message/2", last_request.url);
702+ Y.Assert.areSame("POST", last_request.config.method);
703+ Y.Assert.areSame(
704+ "ws.op=editContent&new_content=" + uri_encoded_new_message,
705+ last_request.config.data);
706+
707+ // Check that the "last edit" header changed.
708+ Y.Assert.areSame(":", this.last_edit[0].getDOMNode().innerHTML);
709+ Y.Assert.areSame(
710+ " (last edit a moment ago): ",
711+ this.last_edit[1].getDOMNode().innerHTML);
712+ }
713+
714+ };
715+
716+ suite.add(new Y.Test.Case(TestMessageEdit));
717+
718+ namespace.suite = suite;
719+
720+}, "0.1", {"requires": [
721+ "lp.services.messages.edit", "node", "lp.testing.mockio",
722+ "node-event-simulate", "test", "lp.anim"]});
723diff --git a/lib/lp/services/messages/tests/test_yuitests.py b/lib/lp/services/messages/tests/test_yuitests.py
724new file mode 100644
725index 0000000..e614a1c
726--- /dev/null
727+++ b/lib/lp/services/messages/tests/test_yuitests.py
728@@ -0,0 +1,26 @@
729+# Copyright 2011-2015 Canonical Ltd. This software is licensed under the
730+# GNU Affero General Public License version 3 (see the file LICENSE).
731+
732+"""Run YUI.test tests."""
733+
734+__metaclass__ = type
735+__all__ = []
736+
737+from lp.testing import (
738+ build_yui_unittest_suite,
739+ YUIUnitTestCase,
740+ )
741+from lp.testing.layers import YUITestLayer
742+
743+
744+class MessagesYUIUnitTestCase(YUIUnitTestCase):
745+
746+ layer = YUITestLayer
747+ suite_name = 'MessagesYUIUnitTests'
748+
749+
750+def test_suite():
751+ app_testing_path = 'lp/services/messages'
752+ return build_yui_unittest_suite(
753+ app_testing_path,
754+ MessagesYUIUnitTestCase)