Merge lp:~sinzui/launchpad/bug-tag-completions into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 15522
Proposed branch: lp:~sinzui/launchpad/bug-tag-completions
Merge into: lp:launchpad
Diff against target: 381 lines (+198/-29)
10 files modified
lib/lp/app/javascript/autocomplete/autocomplete.js (+2/-0)
lib/lp/app/javascript/autocomplete/tests/test_autocomplete.js (+3/-3)
lib/lp/bugs/browser/widgets/bug.py (+34/-1)
lib/lp/bugs/browser/widgets/tests/test_bug.py (+81/-0)
lib/lp/bugs/javascript/bug_tags_entry.js (+20/-11)
lib/lp/bugs/javascript/tests/test_bug_tags_entry.html (+9/-0)
lib/lp/bugs/javascript/tests/test_bug_tags_entry.js (+45/-8)
lib/lp/bugs/templates/bugtask-index.pt (+2/-2)
lib/lp/bugs/templates/bugtask-macros-tableview.pt (+0/-2)
lib/lp/registry/javascript/milestoneoverlay.js (+2/-2)
To merge this branch: bzr merge lp:~sinzui/launchpad/bug-tag-completions
Reviewer Review Type Date Requested Status
Richard Harding (community) Approve
j.c.sackett (community) Approve
Review via email: mp+112580@code.launchpad.net

Commit message

Enable bug tag completions on +filebug and advanced bug search.

Description of the change

Recent refactorings of the bug tag entry code make it possible to
reuse the autocompletion part on +filebug and advanced bug search.

--------------------------------------------------------------------

RULES

    Pre-implementation: jcsackett
    * Extract the autocompletion from setup_tag_entry() so that
      other use cases can reuse it when the tag input field already
      exists.
    * Update +filebug to provide available_official_tags.
    * setup autocomplete in the template.
    * Update advanced search to provide available_official_tags.
    * setup autocomplete in the template.

    Addendum
    * I tried to extend the Autocomplete widget, but I discovered that
      I would also need to write a lot of duplicate css rules. The
      subclass effectively renames all the presentation rules :(
    * I tried a plugin, but it did not generate the markup before
      the widget was initialised.
    * I settled on a simple setup function.

QA

    * Visit https://bugs.qastaging.launchpad.net/gdp/+bugs?advanced
    * Verify the bug tags input will complete tags.
    * Choose Report a bug.
    * Enter "testing", Continue, expand Extra options.
    * Verify the bug tags input will complete tags.

LINT

    lib/lp/app/javascript/autocomplete/autocomplete.js
    lib/lp/app/javascript/autocomplete/tests/test_autocomplete.js
    lib/lp/bugs/browser/widgets/bug.py
    lib/lp/bugs/browser/widgets/tests/test_bug.py
    lib/lp/bugs/javascript/bug_tags_entry.js
    lib/lp/bugs/javascript/tests/test_bug_tags_entry.html
    lib/lp/bugs/javascript/tests/test_bug_tags_entry.js
    lib/lp/bugs/templates/bugtask-macros-tableview.pt

TEST

    ./bin/test -vvc lp.bugs.browser.widgets.tests.test_bug
    ./bin/test -vvc --layer=YUITest

IMPLEMENTATION

I turn of the browser's autocomplete when the widget is run. The bug tag
completions were hidden under Chromium's completions. The browser must
be turned off for every input we add completions to.
    lib/lp/app/javascript/autocomplete/autocomplete.js
    lib/lp/app/javascript/autocomplete/tests/test_autocomplete.js

I extracted the setup the autocomplete widget to a separate function
that could be called without the editor. I removed the "on('queryChange'"
because I discovered it did nothing when I wrote a test for it...it
was probably used to debug key input when the widget was created.
    lib/lp/bugs/javascript/bug_tags_entry.js
    lib/lp/bugs/javascript/tests/test_bug_tags_entry.html
    lib/lp/bugs/javascript/tests/test_bug_tags_entry.js

I Updated the BugTagsWidget to include the script to progressively
enhance any bug tag input to support autocomplete. This widget is used
both on advanced search and on +filebug. I moved the help link into the
widget to ensure it is always rendered and that it is rendered
consistently. I discovered that both +filebug and advanced bug had
duplicate ids in their crafted forms. A simple selector was not enough;
the selector must find the bug tag input field that is type="text".
    lib/lp/bugs/browser/widgets/bug.py
    lib/lp/bugs/browser/widgets/tests/test_bug.py
    lib/lp/bugs/templates/bugtask-macros-tableview.pt

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :

Curtis--

This looks good. Just one style note.

In a few places (line 226 or 311, for example): It looks like you're doing
pythonic brace/paren closure in places. I think for javascript we have the
closing brace at the same tab level as the opening call, rather than at the
tab level of the enclosed lines.

In other words, I believe we prefer:

  some_call(function () {
    stuff;
  });

Rather than:

  some_call(function () {
    stuff;
    });

review: Approve
Revision history for this message
Richard Harding (rharding) wrote :

Looks good. In addition to the alignment of the closing braces JC brought up, I've got a couple other suggestions.

As we discussed in IRC, widgets have a CSS_PREFIX value you can set to manually override the generated CSS prefix based on NAME. This might let you continue the idea of extending the Autocomplete widget.

http://yuilibrary.com/yui/docs/api/files/widget_js_Widget.js.html#l95

Small naming question/suggestion. Now that we're starting to properly build and namespace our JS, I'm trying to see if I can start to simplify naming. So could the module just be Y.lp.bugs.tags_entry [sans bug_]?

#197 is the autocomplete declared var?

#294/300/306/307 please surround attribute selectors in quotes: id="field.tag"

review: Approve
Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi. Jon, Rick.

I outdented the close of the methods calls that contain anonymous functions

I removed the redundant "bug_" from the namespace.

autocomplete is a private namespace var declared on line 24 of the module. The module has lots of hidden, untestible parts because of the structure of the entire module.

I quoted the attribute value in the selectors. I was inconsistent.

I will play with the widget and CSS in another branch.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/javascript/autocomplete/autocomplete.js'
2--- lib/lp/app/javascript/autocomplete/autocomplete.js 2012-05-18 22:56:13 +0000
3+++ lib/lp/app/javascript/autocomplete/autocomplete.js 2012-06-29 04:38:24 +0000
4@@ -198,6 +198,8 @@
5 'left': iregion.left + 'px',
6 'top': iregion.bottom + 'px'
7 });
8+ // Disable the browser autocomplete so that it does not conflict.
9+ input.setAttribute('autocomplete', 'off');
10 },
11
12 /**
13
14=== modified file 'lib/lp/app/javascript/autocomplete/tests/test_autocomplete.js'
15--- lib/lp/app/javascript/autocomplete/tests/test_autocomplete.js 2012-04-06 17:28:25 +0000
16+++ lib/lp/app/javascript/autocomplete/tests/test_autocomplete.js 2012-06-29 04:38:24 +0000
17@@ -329,13 +329,13 @@
18
19 /* A helper function to determine if two match result items are equal */
20 matches_are_equal: function(a, b) {
21- if (typeof a == 'undefined') {
22+ if (Y.Lang.isUndefined(a)) {
23 Assert.fail("Match set 'a' is of type 'undefined'!");
24 }
25- if (typeof b == 'undefined') {
26+ if (Y.Lang.isUndefined(b)) {
27 Assert.fail("Match set 'b' is of type 'undefined'!");
28 }
29- return (a.text == b.text) && (a.offset == b.offset);
30+ return (a.text === b.text) && (a.offset === b.offset);
31 },
32
33 test_no_matches_returns_an_empty_array: function() {
34
35=== modified file 'lib/lp/bugs/browser/widgets/bug.py'
36--- lib/lp/bugs/browser/widgets/bug.py 2011-02-24 15:30:54 +0000
37+++ lib/lp/bugs/browser/widgets/bug.py 2012-06-29 04:38:24 +0000
38@@ -10,7 +10,7 @@
39 ]
40
41 import re
42-
43+from simplejson import dumps
44 from zope.app.form.browser.textwidgets import (
45 IntWidget,
46 TextAreaWidget,
47@@ -26,6 +26,8 @@
48 from lp.app.errors import NotFoundError
49 from lp.app.validators import LaunchpadValidationError
50 from lp.bugs.interfaces.bug import IBugSet
51+from lp.registry.interfaces.distribution import IDistribution
52+from lp.registry.interfaces.product import IProduct
53
54
55 class BugWidget(IntWidget):
56@@ -135,6 +137,37 @@
57 def _getInputValue(self):
58 return TextWidget.getInputValue(self)
59
60+ def __call__(self):
61+ """Return the input with a script."""
62+ input_markup = super(BugTagsWidget, self).__call__()
63+ script_markup = """
64+ <a href="/+help-bugs/tag-search.html"
65+ class="sprite maybe action-icon"
66+ target="help">Tag search help</a>
67+ <script type="text/javascript">
68+ LPJS.use('event', 'lp.bugs.tags_entry', function(Y) {
69+ %s
70+ Y.on('domready', function(e) {
71+ Y.lp.bugs.tags_entry.setup_tag_complete(
72+ 'input[id="field.%s"][type="text"]', official_tags);
73+ });
74+ });
75+ </script>
76+ """ % (self.official_tags_js, self.context.__name__)
77+ return input_markup + script_markup
78+
79+ @property
80+ def official_tags_js(self):
81+ """Return the JavaScript of bug tags used by the bug tag completer."""
82+ bug_target = self.context.context
83+ pillar_target = (
84+ IProduct(bug_target, None) or IDistribution(bug_target, None))
85+ if pillar_target is not None:
86+ official_tags = list(pillar_target.official_bug_tags)
87+ else:
88+ official_tags = []
89+ return 'var official_tags = %s;' % dumps(official_tags)
90+
91
92 class BugTagsFrozenSetWidget(BugTagsWidget):
93 """A widget for editing bug tags in a `FrozenSet` field."""
94
95=== added file 'lib/lp/bugs/browser/widgets/tests/test_bug.py'
96--- lib/lp/bugs/browser/widgets/tests/test_bug.py 1970-01-01 00:00:00 +0000
97+++ lib/lp/bugs/browser/widgets/tests/test_bug.py 2012-06-29 04:38:24 +0000
98@@ -0,0 +1,81 @@
99+# Copyright 2012 Canonical Ltd. This software is licensed under the
100+# GNU Affero General Public License version 3 (see the file LICENSE).
101+
102+__metaclass__ = type
103+
104+from lp.bugs.browser.widgets.bug import BugTagsWidget
105+from lp.bugs.interfaces.bugtarget import IHasOfficialBugTags
106+from lp.services.webapp.servers import LaunchpadTestRequest
107+from lp.testing import (
108+ person_logged_in,
109+ TestCaseWithFactory,
110+ )
111+from lp.testing.layers import DatabaseFunctionalLayer
112+
113+
114+class BugTagsWidgetTestCase(TestCaseWithFactory):
115+
116+ layer = DatabaseFunctionalLayer
117+
118+ def get_widget(self, bug_target):
119+ field = IHasOfficialBugTags['official_bug_tags']
120+ bound_field = field.bind(bug_target)
121+ request = LaunchpadTestRequest()
122+ return BugTagsWidget(bound_field, None, request)
123+
124+ def test_official_tags_js_not_adaptable_to_product_or_distro(self):
125+ # project groups are not full bug targets so they have no tags.
126+ project_group = self.factory.makeProject()
127+ widget = self.get_widget(project_group)
128+ js = widget.official_tags_js
129+ self.assertEqual('var official_tags = [];', js)
130+
131+ def test_official_tags_js_product_without_tags(self):
132+ # Products without tags have an empty list.
133+ product = self.factory.makeProduct()
134+ widget = self.get_widget(product)
135+ js = widget.official_tags_js
136+ self.assertEqual('var official_tags = [];', js)
137+
138+ def test_official_tags_js_product_with_tags(self):
139+ # Products with tags have a list of tags.
140+ product = self.factory.makeProduct()
141+ with person_logged_in(product.owner):
142+ product.official_bug_tags = [u'cows', u'pigs', u'sheep']
143+ widget = self.get_widget(product)
144+ js = widget.official_tags_js
145+ self.assertEqual('var official_tags = ["cows", "pigs", "sheep"];', js)
146+
147+ def test_official_tags_js_distribution_without_tags(self):
148+ # Distributions without tags have an empty list.
149+ distribution = self.factory.makeDistribution()
150+ widget = self.get_widget(distribution)
151+ js = widget.official_tags_js
152+ self.assertEqual('var official_tags = [];', js)
153+
154+ def test_official_tags_js_distribution_with_tags(self):
155+ # Distributions with tags have a list of tags.
156+ distribution = self.factory.makeDistribution()
157+ with person_logged_in(distribution.owner):
158+ distribution.official_bug_tags = [u'cows', u'pigs', u'sheep']
159+ widget = self.get_widget(distribution)
160+ js = widget.official_tags_js
161+ self.assertEqual('var official_tags = ["cows", "pigs", "sheep"];', js)
162+
163+ def test_call(self):
164+ # __call__ renders the input, help link, and script with official tags.
165+ # Products with tags have a list of tags.
166+ product = self.factory.makeProduct()
167+ with person_logged_in(product.owner):
168+ product.official_bug_tags = [u'cows', u'pigs', u'sheep']
169+ widget = self.get_widget(product)
170+ markup = widget()
171+ self.assertIn(
172+ '<input class="textType" id="field.official_bug_tags"', markup)
173+ self.assertIn('<a href="/+help-bugs/tag-search.html"', markup)
174+ self.assertIn('var official_tags = ["cows", "pigs", "sheep"];', markup)
175+ self.assertIn(
176+ 'Y.lp.bugs.tags_entry.setup_tag_complete(', markup)
177+ self.assertIn(
178+ """'input[id="field.official_bug_tags"][type="text"]',""", markup)
179+ self.assertIn("official_tags)", markup)
180
181=== modified file 'lib/lp/bugs/javascript/bug_tags_entry.js'
182--- lib/lp/bugs/javascript/bug_tags_entry.js 2012-06-22 19:49:29 +0000
183+++ lib/lp/bugs/javascript/bug_tags_entry.js 2012-06-29 04:38:24 +0000
184@@ -7,9 +7,9 @@
185 * @submodule bug_tags_entry
186 */
187
188-YUI.add('lp.bugs.bug_tags_entry', function(Y) {
189+YUI.add('lp.bugs.tags_entry', function(Y) {
190
191-var namespace = Y.namespace('lp.bugs.bug_tags_entry');
192+var namespace = Y.namespace('lp.bugs.tags_entry');
193
194 var bug_tags_div;
195 var tags_heading;
196@@ -251,21 +251,30 @@
197 });
198 tags_trigger.addClass('js-action');
199
200- bug_tags_div.append(
201+ autocomplete = namespace.setup_tag_complete(
202+ '#tag-input', available_official_tags);
203+};
204+
205+
206+/**
207+ * Set up bug tag autocompletion on a text input.
208+ *
209+ * @method setup_tag_complete
210+ */
211+namespace.setup_tag_complete = function(input, official_tags) {
212+ Y.one('body').appendChild(
213 '<div id="tags-autocomplete"><div id="tags-autocomplete-content">' +
214 '</div></div>');
215- autocomplete = new Y.lazr.AutoComplete({
216- input: '#tag-input',
217- data: available_official_tags,
218+ var autocomplete = new Y.lazr.AutoComplete({
219+ input: input,
220+ data: official_tags,
221 boundingBox: '#tags-autocomplete',
222 contentBox: '#tags-autocomplete-content'
223 });
224- autocomplete.on('queryChange', function(e) {
225- var val = "null";
226- if (e.newVal !== null) {
227- val = "'" + e.newVal.text + "', " + e.newVal.offset;
228- }
229+ autocomplete.get('input').on('focus', function(e) {
230+ autocomplete.render();
231 });
232+ return autocomplete;
233 };
234 }, "0.1", {
235 "requires": [
236
237=== modified file 'lib/lp/bugs/javascript/tests/test_bug_tags_entry.html'
238--- lib/lp/bugs/javascript/tests/test_bug_tags_entry.html 2012-06-22 14:58:26 +0000
239+++ lib/lp/bugs/javascript/tests/test_bug_tags_entry.html 2012-06-29 04:38:24 +0000
240@@ -82,5 +82,14 @@
241 class="sprite maybe action-icon">Tag help</a>
242 </div>
243 </script>
244+
245+ <script type="text/x-template" id="form-with-bug-tags">
246+ <form>
247+ <div>
248+ <input type="text" name="field.tag" id="field.tag" />
249+ <a href="/help/tags.html" target="help" class="help">(?)</a>
250+ </div>
251+ </form>
252+ </script>
253 </body>
254 </html>
255
256=== modified file 'lib/lp/bugs/javascript/tests/test_bug_tags_entry.js'
257--- lib/lp/bugs/javascript/tests/test_bug_tags_entry.js 2012-06-22 19:49:29 +0000
258+++ lib/lp/bugs/javascript/tests/test_bug_tags_entry.js 2012-06-29 04:38:24 +0000
259@@ -1,8 +1,8 @@
260 YUI().use('lp.testing.runner', 'lp.testing.mockio', 'test', 'console',
261- 'lp.client', 'node-event-simulate', 'lp.bugs.bug_tags_entry',
262+ 'lp.client', 'node-event-simulate', 'lp.bugs.tags_entry',
263 function(Y) {
264
265- var module = Y.lp.bugs.bug_tags_entry;
266+ var module = Y.lp.bugs.tags_entry;
267 var suite = new Y.Test.Suite("Bug tags entry Tests");
268
269 suite.add(new Y.Test.Case({
270@@ -87,12 +87,7 @@
271 Y.Assert.isInstanceOf(Y.Node, form_node.one('#tags-edit-spinner'));
272 Y.Assert.isInstanceOf(Y.Node, form_node.one('#edit-tags-cancel'));
273 Y.Assert.isInstanceOf(Y.Node, form_node.one('#tag-input'));
274- // The Autocompleter nodes are provided.
275- var completer_node = this.bug_tags_div.one('#tags-autocomplete');
276- Y.Assert.isInstanceOf(Y.Node, completer_node);
277- var completer_content = completer_node.one(
278- '#tags-autocomplete-content');
279- Y.Assert.isInstanceOf(Y.Node, completer_content);
280+ Y.Assert.isInstanceOf(Y.Node, Y.one('#tags-autocomplete'));
281 },
282
283 test_show_activity: function() {
284@@ -206,5 +201,47 @@
285 }
286 }));
287
288+ suite.add(new Y.Test.Case({
289+ name: 'Completer',
290+
291+ setUp: function() {
292+ this.fixture = Y.one("#fixture");
293+ var template = Y.one('#form-with-bug-tags').getContent();
294+ this.fixture.append(template);
295+ },
296+
297+ tearDown: function() {
298+ if (this.fixture !== null) {
299+ this.fixture.empty();
300+ }
301+ delete this.fixture;
302+ },
303+
304+ test_setup_tag_complete: function() {
305+ // The Autocompleter nodes are provided.
306+ module.setup_tag_complete(
307+ 'input[id="field.tag"]',['project-tag']);
308+ var completer_node = Y.one('#tags-autocomplete');
309+ Y.Assert.isInstanceOf(Y.Node, completer_node);
310+ var completer_content = completer_node.one(
311+ '#tags-autocomplete-content');
312+ Y.Assert.isInstanceOf(Y.Node, completer_content);
313+ var input = Y.one('input[id="field.tag"]');
314+ },
315+
316+ test_render_on_focus: function() {
317+ // The Autocompleter nodes are provided.
318+ var completer = module.setup_tag_complete(
319+ 'input[id="field.tag"]',['project-tag']);
320+ var input = Y.one('input[id="field.tag"]');
321+ var called = false;
322+ completer.render = function() {
323+ called = true;
324+ };
325+ input.simulate('focus');
326+ Y.Assert.isTrue(called);
327+ }
328+ }));
329+
330 Y.lp.testing.Runner.run(suite);
331 });
332
333=== modified file 'lib/lp/bugs/templates/bugtask-index.pt'
334--- lib/lp/bugs/templates/bugtask-index.pt 2012-06-22 20:57:42 +0000
335+++ lib/lp/bugs/templates/bugtask-index.pt 2012-06-29 04:38:24 +0000
336@@ -153,10 +153,10 @@
337 </div>
338
339 <script type="text/javascript">
340- LPJS.use('event', 'node', 'lp.bugs.bug_tags_entry', function(Y) {
341+ LPJS.use('event', 'node', 'lp.bugs.tags_entry', function(Y) {
342 Y.on('domready',
343 function(e) {
344- Y.lp.bugs.bug_tags_entry.setup_tag_entry(
345+ Y.lp.bugs.tags_entry.setup_tag_entry(
346 available_official_tags);
347 },
348 window);
349
350=== modified file 'lib/lp/bugs/templates/bugtask-macros-tableview.pt'
351--- lib/lp/bugs/templates/bugtask-macros-tableview.pt 2012-03-01 19:12:34 +0000
352+++ lib/lp/bugs/templates/bugtask-macros-tableview.pt 2012-06-29 04:38:24 +0000
353@@ -516,8 +516,6 @@
354 tal:content="structure view/widgets/tag/label">
355 Tag
356 </label>: <input tal:replace="structure view/widgets/tag" />
357- <a href="/+help-bugs/tag-search.html"
358- target="help">Tag search help</a>
359 <div
360 tal:condition="error"
361 class="message"
362
363=== modified file 'lib/lp/registry/javascript/milestoneoverlay.js'
364--- lib/lp/registry/javascript/milestoneoverlay.js 2012-05-12 01:58:54 +0000
365+++ lib/lp/registry/javascript/milestoneoverlay.js 2012-06-29 04:38:24 +0000
366@@ -78,7 +78,7 @@
367
368 var set_milestone_tags = function(milestone) {
369 var tagstring = data['field.tags'][0].toLowerCase();
370- var tags = Y.lp.bugs.bug_tags_entry.parse_tags(tagstring);
371+ var tags = Y.lp.bugs.tags_entry.parse_tags(tagstring);
372
373 // Since we are creating a new milestone it cannot already
374 // have pre-existing tags, so if none are given we can
375@@ -201,5 +201,5 @@
376 "lazr.formoverlay",
377 "lp.app.calendar",
378 "lp.client",
379- "lp.bugs.bug_tags_entry"
380+ "lp.bugs.tags_entry"
381 ]});