Merge lp:~wallyworld/launchpad/remove-dupe-link-133622 into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 15726
Proposed branch: lp:~wallyworld/launchpad/remove-dupe-link-133622
Merge into: lp:launchpad
Diff against target: 337 lines (+142/-9)
7 files modified
lib/lp/bugs/browser/bug.py (+25/-2)
lib/lp/bugs/browser/tests/test_bug_views.py (+52/-0)
lib/lp/bugs/javascript/bug_picker.js (+36/-1)
lib/lp/bugs/javascript/bugtask_index.js (+1/-0)
lib/lp/bugs/javascript/tests/test_duplicates.js (+23/-1)
lib/lp/bugs/stories/bugs/xx-duplicate-of-private-bug.txt (+1/-1)
lib/lp/bugs/stories/guided-filebug/xx-displaying-similar-bugs.txt (+4/-4)
To merge this branch: bzr merge lp:~wallyworld/launchpad/remove-dupe-link-133622
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+117398@code.launchpad.net

Commit message

Add a Reemove button/link to the HTML bug +duplicate form and the Javascript bug duplicate popup.

Description of the change

== Implementation ==

The bug duplicate forms (both Javascript and HTML) required the entry of a blank duplicate followed by clicking Save/Change in order to remove a bug duplicate. The bug id field on the form was optional.

The branch makes the bug id field mandatory and adds an explicit 'Remove Duplicate' link. For the Javascript form, the link is centred and rendered similar to the equivalent link in the person picker. For the HTML form, a submit button is rendered.

1. Javascript form

I'm not sure if the link should be centred. But I'm not sure it looks great left aligned either. Thoughts?

2. HTML form

I changed the validation processing for the form so that the bug id field is only validated when 'Save' is clicked. No error is raised if 'Remove' is clicked. This allows the confusing "Optional" label to be removed.

The save button was called 'Change' but I renamed it to 'Set Duplicate'. This pairs up nicely with the 'Remove Duplicate' button. The remove button is only rendered if the bug has a duplicate. Also, I don't understand why the Cancel link was missing so I added that too.

== Demo and QA ==

Screenshots of the new forms:

http://people.canonical.com/~ianb/bug-duplicate-form.png
http://people.canonical.com/~ianb/bug-duplicate-popup.png

== Tests ==

I found some incidental doctests for the +duplicate functionality but nothing that seemed specifically targetted to test that functionality. I created a new test case TestBugMarkAsDuplicateView and some tests

The yui tests for the javascript duplicate form were also updated, as well as some doctests.

== Lint ==

Clean except for some doctest noise.

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/browser/bug.py
  lib/lp/bugs/browser/tests/test_bug_views.py
  lib/lp/bugs/javascript/bug_picker.js
  lib/lp/bugs/javascript/bugtask_index.js
  lib/lp/bugs/javascript/tests/test_duplicates.js
  lib/lp/bugs/model/bugsubscriptionfilterimportance.py
  lib/lp/bugs/model/bugsubscriptionfilterstatus.py
  lib/lp/bugs/model/tests/test_bugsubscriptionfilterimportance.py
  lib/lp/bugs/model/tests/test_bugsubscriptionfilterstatus.py
  lib/lp/bugs/stories/bugs/xx-duplicate-of-private-bug.txt
  lib/lp/bugs/stories/guided-filebug/xx-displaying-similar-bugs.txt

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you. This looks fine to land I have one suggestion

Replace
    <div style="text-align: center">
with
    <div class="center">

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bug.py'
2--- lib/lp/bugs/browser/bug.py 2012-07-27 04:24:59 +0000
3+++ lib/lp/bugs/browser/bug.py 2012-07-31 23:26:22 +0000
4@@ -782,12 +782,17 @@
5 label = "Mark bug report as a duplicate"
6 page_title = label
7
8+ @property
9+ def cancel_url(self):
10+ """See `LaunchpadFormView`."""
11+ return canonical_url(self.context)
12+
13 def setUpFields(self):
14 """Make the readonly version of duplicateof available."""
15 super(BugMarkAsDuplicateView, self).setUpFields()
16
17 duplicateof_field = DuplicateBug(
18- __name__='duplicateof', title=_('Duplicate Of'), required=False)
19+ __name__='duplicateof', title=_('Duplicate Of'), required=True)
20
21 self.form_fields = self.form_fields.omit('duplicateof')
22 self.form_fields = formlib.form.Fields(duplicateof_field)
23@@ -797,7 +802,12 @@
24 """See `LaunchpadFormView.`"""
25 return {'duplicateof': self.context.bug.duplicateof}
26
27- @action('Change', name='change')
28+ def _validate(self, action, data):
29+ if action.name != 'remove':
30+ return super(BugMarkAsDuplicateView, self)._validate(action, data)
31+ return []
32+
33+ @action('Set Duplicate', name='change')
34 def change_action(self, action, data):
35 """Update the bug."""
36 data = dict(data)
37@@ -812,6 +822,19 @@
38 # Apply other changes.
39 self.updateBugFromData(data)
40
41+ def shouldShowRemoveButton(self, action):
42+ return self.context.bug.duplicateof is not None
43+
44+ @action('Remove Duplicate', name='remove',
45+ condition=shouldShowRemoveButton)
46+ def remove_action(self, action, data):
47+ """Update the bug."""
48+ bug = self.context.bug
49+ bug_before_modification = Snapshot(bug, providing=providedBy(bug))
50+ bug.markAsDuplicate(None)
51+ notify(
52+ ObjectModifiedEvent(bug, bug_before_modification, 'duplicateof'))
53+
54
55 class BugSecrecyEditView(LaunchpadFormView, BugSubscriptionPortletDetails):
56 """Form for marking a bug as a private/public."""
57
58=== modified file 'lib/lp/bugs/browser/tests/test_bug_views.py'
59--- lib/lp/bugs/browser/tests/test_bug_views.py 2012-07-26 07:54:40 +0000
60+++ lib/lp/bugs/browser/tests/test_bug_views.py 2012-07-31 23:26:22 +0000
61@@ -515,3 +515,55 @@
62 view = create_initialized_view(
63 bug.default_bugtask, '+addcomment', form=form)
64 self.assertEqual(0, len(view.errors))
65+
66+
67+class TestBugMarkAsDuplicateView(TestCaseWithFactory):
68+ """Tests for marking a bug as a duplicate."""
69+
70+ layer = DatabaseFunctionalLayer
71+
72+ def setUp(self):
73+ super(TestBugMarkAsDuplicateView, self).setUp()
74+ self.bug_owner = self.factory.makePerson()
75+ self.bug = self.factory.makeBug(owner=self.bug_owner)
76+ self.duplicate_bug = self.factory.makeBug(owner=self.bug_owner)
77+
78+ def test_remove_link_not_shown_if_no_duplicate(self):
79+ with person_logged_in(self.bug_owner):
80+ view = create_initialized_view(
81+ self.bug.default_bugtask, name="+duplicate",
82+ principal=self.bug_owner)
83+ soup = BeautifulSoup(view.render())
84+ self.assertIsNone(soup.find(attrs={'id': 'field.actions.remove'}))
85+
86+ def test_remove_link_shown_if_duplicate(self):
87+ with person_logged_in(self.bug_owner):
88+ self.bug.markAsDuplicate(self.duplicate_bug)
89+ view = create_initialized_view(
90+ self.bug.default_bugtask, name="+duplicate",
91+ principal=self.bug_owner)
92+ soup = BeautifulSoup(view.render())
93+ self.assertIsNotNone(
94+ soup.find(attrs={'id': 'field.actions.remove'}))
95+
96+ def test_create_duplicate(self):
97+ with person_logged_in(self.bug_owner):
98+ form = {
99+ 'field.actions.change': u'Set Duplicate',
100+ 'field.duplicateof': u'%s' % self.duplicate_bug.id
101+ }
102+ create_initialized_view(
103+ self.bug.default_bugtask, name="+duplicate",
104+ principal=self.bug_owner, form=form)
105+ self.assertEqual(self.duplicate_bug, self.bug.duplicateof)
106+
107+ def test_remove_duplicate(self):
108+ with person_logged_in(self.bug_owner):
109+ self.bug.markAsDuplicate(self.duplicate_bug)
110+ form = {
111+ 'field.actions.remove': u'Remove Duplicate',
112+ }
113+ create_initialized_view(
114+ self.bug.default_bugtask, name="+duplicate",
115+ principal=self.bug_owner, form=form)
116+ self.assertIsNone(self.bug.duplicateof)
117
118=== modified file 'lib/lp/bugs/javascript/bug_picker.js'
119--- lib/lp/bugs/javascript/bug_picker.js 2012-07-30 23:55:05 +0000
120+++ lib/lp/bugs/javascript/bug_picker.js 2012-07-31 23:26:22 +0000
121@@ -33,6 +33,15 @@
122 }
123 },
124
125+ _remove_link_html: function() {
126+ return [
127+ '<div class="centered">',
128+ '<a class="sprite remove ',
129+ 'js-action" href="javascript:void(0)">',
130+ this.get('remove_link_text'),
131+ '</a></div>'].join('');
132+ },
133+
134 renderUI: function() {
135 var select_bug_link = this.get('select_bug_link');
136 if (!Y.Lang.isValue(select_bug_link)) {
137@@ -56,6 +65,7 @@
138 'the bug you specify here. This cannot be undone.' +
139 '</p>';
140 }
141+ form_header = form_header + this._remove_link_html();
142
143 this.find_button = Y.Node.create(find_button_html);
144 this.picker_form = new Y.lazr.FormOverlay({
145@@ -92,12 +102,31 @@
146 Y.DOM.byId('field.duplicateof').focus();
147 }
148 });
149+ // Wire up the Remove link.
150+ this.picker_form.after('contentUpdate', function() {
151+ var remove_link = that.picker_form.form_header_node
152+ .one('a.remove');
153+ if (Y.Lang.isValue(remove_link)) {
154+ remove_link.on('click', function(e) {
155+ e.halt();
156+ that._submit_bug('');
157+ });
158+ }
159+ });
160 // When the dupe form overlay is hidden, we need to reset the form.
161 this.picker_form.after('visibleChange', function() {
162 if (!this.get('visible')) {
163 that._hide_bug_details_node();
164 } else {
165 Y.DOM.byId('field.duplicateof').value = '';
166+ var remove_link = that.picker_form.form_header_node
167+ .one('a.remove');
168+ var existing_dupe = LP.cache.bug.duplicate_of_link;
169+ if (Y.Lang.isString(existing_dupe) && existing_dupe !== '') {
170+ remove_link.removeClass('hidden');
171+ } else {
172+ remove_link.addClass('hidden');
173+ }
174 }
175 });
176 },
177@@ -130,7 +159,8 @@
178 // If there's no bug data entered then we are deleting the duplicate
179 // link.
180 if (new_dup_id === '') {
181- this._submit_bug(new_dup_id);
182+ this.picker_form.showError(
183+ 'Please enter a valid bug number.');
184 return;
185 }
186
187@@ -364,6 +394,7 @@
188 new_dup_id) {
189 this.picker_form.hide();
190 updated_entry.set('duplicate_of_link', new_dup_url);
191+ LP.cache.bug.duplicate_of_link = updated_entry.duplicate_of_link;
192 this.set('lp_bug_entry', updated_entry);
193
194 var dupe_span = this.get('dupe_span').ancestor('li');
195@@ -572,6 +603,10 @@
196 return Y.one('#portlet-duplicates');
197 }
198 },
199+ // The text used for the remove link.
200+ remove_link_text: {
201+ value: "Remove this bug"
202+ },
203 // Override for testing.
204 use_animation: {
205 value: true
206
207=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
208--- lib/lp/bugs/javascript/bugtask_index.js 2012-07-30 23:49:42 +0000
209+++ lib/lp/bugs/javascript/bugtask_index.js 2012-07-31 23:26:22 +0000
210@@ -42,6 +42,7 @@
211 var dup_widget = new Y.lp.bugs.bug_picker.BugPicker({
212 srcNode: '#duplicate-actions',
213 lp_bug_entry: lp_bug_entry,
214+ remove_link_text: 'Bug is not a duplicate',
215 private_warning_message:
216 'Marking this bug as a duplicate of a private bug means '+
217 'that it won\'t be visible to contributors and encourages '+
218
219=== modified file 'lib/lp/bugs/javascript/tests/test_duplicates.js'
220--- lib/lp/bugs/javascript/tests/test_duplicates.js 2012-07-30 23:49:42 +0000
221+++ lib/lp/bugs/javascript/tests/test_duplicates.js 2012-07-31 23:26:22 +0000
222@@ -86,10 +86,14 @@
223 Y.Assert.areEqual('http://foo/+duplicate', url);
224 Y.Assert.isNotNull(
225 Y.one('#mark-duplicate-text a.menu-link-mark-dupe'));
226+ this.widget.get('select_bug_link').simulate('click');
227+ var remove_dupe = Y.one('#duplicate-form-container a.remove');
228+ Y.Assert.isTrue(remove_dupe.hasClass('hidden'));
229 },
230
231 // The widget is created when there are bug duplicates.
232 test_widget_creation_duplicate_exists: function() {
233+ window.LP.cache.bug.duplicate_of_link = 'bug/5';
234 this.widget = this._createWidget(true);
235 Y.Assert.isInstanceOf(
236 Y.lp.bugs.bug_picker.BugPicker,
237@@ -97,6 +101,9 @@
238 "Mark bug duplicate failed to be instantiated");
239 var url = this.widget.get('select_bug_link').get('href');
240 Y.Assert.areEqual('http://foo/+duplicate', url);
241+ this.widget.get('select_bug_link').simulate('click');
242+ var remove_dupe = Y.one('#duplicate-form-container a.remove');
243+ Y.Assert.isFalse(remove_dupe.hasClass('hidden'));
244 },
245
246 // The search form renders and submits the expected data.
247@@ -149,6 +156,20 @@
248 this._assert_form_state(true);
249 },
250
251+ // Attempt to enter an empty bug number and an error is displayed.
252+ test_no_bug_id_entered: function() {
253+ this.widget = this._createWidget(false);
254+ this.widget.get('select_bug_link').simulate('click');
255+ this.mockio.last_request = null;
256+ Y.DOM.byId('field.duplicateof').value = '';
257+ var form = Y.one('#duplicate-form-container');
258+ form.one('[name="field.actions.find"]').simulate('click');
259+ Y.Assert.isNull(this.mockio.last_request);
260+ this._assert_error_display(
261+ 'Please enter a valid bug number.');
262+ this._assert_form_state(false);
263+ },
264+
265 // Attempt to make a bug as a duplicate of itself is detected and an
266 // error is displayed immediately.
267 test_mark_bug_as_dupe_of_self: function() {
268@@ -342,7 +363,6 @@
269 // Submitting a dupe removal request works as expected.
270 test_picker_form_submission_remove_dupe: function() {
271 this.widget = this._createWidget(false);
272- this._assert_search_form_submission('');
273 var success_called = false;
274 this.widget._submit_bug_success =
275 function(updated_entry, new_dup_url, new_dup_id) {
276@@ -351,6 +371,8 @@
277 Y.Assert.areEqual('', new_dup_id);
278 success_called = true;
279 };
280+ var remove_dupe = Y.one('#duplicate-form-container a.remove');
281+ remove_dupe.simulate('click');
282 var expected_updated_entry =
283 '{"duplicate_of_link":""}';
284 this.mockio.success({
285
286=== modified file 'lib/lp/bugs/stories/bugs/xx-duplicate-of-private-bug.txt'
287--- lib/lp/bugs/stories/bugs/xx-duplicate-of-private-bug.txt 2012-07-17 14:29:17 +0000
288+++ lib/lp/bugs/stories/bugs/xx-duplicate-of-private-bug.txt 2012-07-31 23:26:22 +0000
289@@ -26,7 +26,7 @@
290 ... 'http://bugs.launchpad.dev/'
291 ... 'ubuntu/+source/linux-source-2.6.15/+bug/10/+duplicate')
292 >>> admin_browser.getControl('Duplicate Of').value = '8'
293- >>> admin_browser.getControl('Change').click()
294+ >>> admin_browser.getControl('Set Duplicate').click()
295
296 As a privileged user the title of the private bug can be found in the
297 duplicate bug page:
298
299=== modified file 'lib/lp/bugs/stories/guided-filebug/xx-displaying-similar-bugs.txt'
300--- lib/lp/bugs/stories/guided-filebug/xx-displaying-similar-bugs.txt 2011-07-19 10:17:47 +0000
301+++ lib/lp/bugs/stories/guided-filebug/xx-displaying-similar-bugs.txt 2012-07-31 23:26:22 +0000
302@@ -66,7 +66,7 @@
303 >>> user_browser.open(
304 ... "http://bugs.launchpad.dev/firefox/+bug/4/+duplicate")
305 >>> user_browser.getControl('Duplicate Of').value = '8'
306- >>> user_browser.getControl('Change').click()
307+ >>> user_browser.getControl('Set Duplicate').click()
308
309 Then, if we match bug 1, only basic details of bug 8 are displayed:
310
311@@ -110,7 +110,7 @@
312 >>> user_browser.open(
313 ... "http://bugs.launchpad.dev/evolution/+bug/7/+duplicate")
314 >>> user_browser.getControl('Duplicate Of').value = '8'
315- >>> user_browser.getControl('Change').click()
316+ >>> user_browser.getControl('Set Duplicate').click()
317
318 >>> user_browser.open("http://bugs.launchpad.dev/gnome/+filebug")
319 >>> user_browser.getControl("Project", index=0).value = ['evolution']
320@@ -145,7 +145,7 @@
321 ... "http://bugs.launchpad.dev/ubuntu/+source/"
322 ... "thunderbird/+bug/9/+duplicate")
323 >>> user_browser.getControl('Duplicate Of').value = '8'
324- >>> user_browser.getControl('Change').click()
325+ >>> user_browser.getControl('Set Duplicate').click()
326
327 >>> user_browser.open("http://launchpad.dev/ubuntu/+filebug")
328 >>> user_browser.getControl("Summary", index=0).value = 'crashes'
329@@ -181,7 +181,7 @@
330 >>> user_browser.open(
331 ... "http://bugs.launchpad.dev/firefox/+bug/1/+duplicate")
332 >>> user_browser.getControl('Duplicate Of').value = '8'
333- >>> user_browser.getControl('Change').click()
334+ >>> user_browser.getControl('Set Duplicate').click()
335
336 >>> user_browser.open(
337 ... "http://launchpad.dev/ubuntu/+source/"