launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #04678
[Merge] lp:~wallyworld/launchpad/filter-target-pickers into lp:launchpad
Ian Booth has proposed merging lp:~wallyworld/launchpad/filter-target-pickers into lp:launchpad with lp:~wallyworld/launchpad/vocab-searchforterms-filter-2 as a prerequisite.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/filter-target-pickers/+merge/71999
This branch wires up filtering infrastructure done in previous branches to allow pickers to provide named filtering capability on search results.
== Implementation ==
Previous branches added filtering to the vocabularies. This branch:
1. adds a "vocabulary_filters" attribute to VocabularyPickerWidget to pass the details of any supported filters to the javascript
The attributes passed are: name (to use in the search url), title (the link text), description (the link tooltip)
2. provides some tweaks to the previous vocab filter work eg zope security config in zcml and fixes to FilteredVocabularyBase
3. provides the new picker.js and picker_patcher.js code to provide the client functionality
The implementation of the filter rendering in the picker follows that used for other display artifacts like results, errors etc. A div is rendered and innerHTMl to '' if there is to be no filter displayed.
The basic rules for how the filter works:
- when a search is done, if there are results, the filter is displayed; the default active filter is the first which is All
- if a user does a new search and there are no results, the filter is hidden
- when the filter is displayed, the user can click on a filter link to apply that filter to the search term entered into the search box
- clicking a filter link sends a new query to the server
- if there are no results when a filter link is clicked, the filter is not hidden
- the current filter link is rendered as an invalid-link so appears grey and is disabled
== Demo / QA ==
See screenshot: http://people.canonical.com/~ianb/picker-filter.png
To try it out, use a picker with a vocab which has vocab filters defined. The currently supported ones are Pillar vocabs eg DistributionOrProjectVocabulary.
Goto retarget a blueprint and select Choose. The picker will display the filter after a search returning results is performed.
== Tests ==
Add test to lp.app.widgets.tests.test_popup.py
test_widget_filtered_vocabulary
Add test to lp.app.browser.tests.test_vocabulary.py
test_vocab_filter
Add tests to test_picker.js (new test case picker_with_filter)
test_picker_has_elements
test_no_results_does_not_render_filter
test_set_results_renders_filter
test_filter_search
test_filter_search_no_results
test_search_resets_filter
Add test to test_picker_patcher.js
test_filter_options_initialisation
bin/test -vvc -t test_popup -t test_vocabulary -t test_pillar_vocabularies
bin/test -vvc --layer=YUI
== Lint ==
Checking for conflicts and issues in changed files.
Linting changed files:
lib/canonical/launchpad/icing/style-3-0.css
lib/canonical/launchpad/webapp/vocabulary.py
lib/lp/app/browser/vocabulary.py
lib/lp/app/browser/tests/test_vocabulary.py
lib/lp/app/javascript/picker/picker.js
lib/lp/app/javascript/picker/picker_patcher.js
lib/lp/app/javascript/picker/tests/test_picker.js
lib/lp/app/javascript/picker/tests/test_picker_patcher.js
lib/lp/app/widgets/popup.py
lib/lp/app/widgets/templates/form-picker-macros.pt
lib/lp/app/widgets/tests/test_popup.py
lib/lp/registry/vocabularies.py
lib/lp/registry/vocabularies.zcml
./lib/lp/app/browser/vocabulary.py
333: E251 no spaces around keyword / parameter equals
./lib/lp/app/javascript/picker/picker.js
70: 'Picker' has not been fully defined yet.
--
https://code.launchpad.net/~wallyworld/launchpad/filter-target-pickers/+merge/71999
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/filter-target-pickers into lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/style-3-0.css'
--- lib/canonical/launchpad/icing/style-3-0.css 2011-08-11 03:27:26 +0000
+++ lib/canonical/launchpad/icing/style-3-0.css 2011-08-19 06:11:29 +0000
@@ -1990,6 +1990,9 @@
.yui3-picker-results .affiliation-text {
padding-left: 20px;
}
+.yui3-picker-filter div {
+ padding-bottom: 1em;
+ }
/* ====================
Global notifications
=== modified file 'lib/canonical/launchpad/webapp/vocabulary.py'
--- lib/canonical/launchpad/webapp/vocabulary.py 2011-08-19 06:11:28 +0000
+++ lib/canonical/launchpad/webapp/vocabulary.py 2011-08-19 06:11:29 +0000
@@ -257,17 +257,17 @@
func = object.__getattribute__(self, name)
if (safe_hasattr(func, '__call__')
and func.__name__ == 'searchForTerms'):
- def searchForTerms(*args, **kwargs):
- vocab_filter = kwargs.get('vocab_filter')
+ def searchForTerms(
+ query=None, vocab_filter=None, *args, **kwargs):
if isinstance(vocab_filter, basestring):
for filter in self.supportedFilters():
if filter.name == vocab_filter:
- kwargs['vocab_filter'] = filter
+ vocab_filter = filter
break
else:
raise ValueError(
"Invalid vocab filter value: %s" % vocab_filter)
- return func(*args, **kwargs)
+ return func(query, vocab_filter, *args, **kwargs)
return searchForTerms
else:
return func
=== modified file 'lib/lp/app/browser/tests/test_vocabulary.py'
--- lib/lp/app/browser/tests/test_vocabulary.py 2011-08-19 06:11:28 +0000
+++ lib/lp/app/browser/tests/test_vocabulary.py 2011-08-19 06:11:29 +0000
@@ -26,6 +26,7 @@
from canonical.launchpad.webapp.vocabulary import (
CountableIterator,
IHugeVocabulary,
+ VocabularyFilter,
)
from canonical.testing.layers import DatabaseFunctionalLayer
from lp.app.browser.vocabulary import (
@@ -153,11 +154,28 @@
return SimpleTerm(person, person.name, person.displayname)
def searchForTerms(self, query=None, vocab_filter=None):
+ if vocab_filter is None:
+ filter_term = ''
+ else:
+ filter_term = vocab_filter.filter_terms[0]
found = [
- person for person in self.test_persons if query in person.name]
+ person for person in self.test_persons
+ if query in person.name and filter_term in person.name]
return CountableIterator(len(found), found, self.toTerm)
+class TestVocabularyFilter(VocabularyFilter):
+ # A filter returning all objects.
+
+ def __new__(cls):
+ return super(VocabularyFilter, cls).__new__(
+ cls, 'FILTER', 'Test Filter', 'Test')
+
+ @property
+ def filter_terms(self):
+ return ['xpting-person']
+
+
class HugeVocabularyJSONViewTestCase(TestCaseWithFactory):
layer = DatabaseFunctionalLayer
@@ -255,6 +273,21 @@
self.assertContentEqual(
expected[1].items(), result['entries'][1].items())
+ def test_vocab_filter(self):
+ # The vocab filter is used to filter results.
+ team = self.factory.makeTeam(name='xpting-team')
+ person = self.factory.makePerson(name='xpting-person')
+ TestPersonVocabulary.test_persons.extend([team, person])
+ product = self.factory.makeProduct(owner=team)
+ vocab_filter = TestVocabularyFilter()
+ form = dict(name='TestPerson',
+ search_text='xpting', search_filter=vocab_filter)
+ view = self.create_vocabulary_view(form, context=product)
+ result = simplejson.loads(view())
+ entries = result['entries']
+ self.assertEqual(1, len(entries))
+ self.assertEqual('pting-person', entries[0]['value'])
+
def test_max_description_size(self):
# Descriptions over 120 characters are truncated and ellipsised.
email = 'pting-' * 19 + '@example.dom'
=== modified file 'lib/lp/app/browser/vocabulary.py'
--- lib/lp/app/browser/vocabulary.py 2011-08-11 00:34:33 +0000
+++ lib/lp/app/browser/vocabulary.py 2011-08-19 06:11:29 +0000
@@ -280,6 +280,7 @@
search_text = self.request.form.get('search_text')
if search_text is None:
raise MissingInputError('search_text', '')
+ search_filter = self.request.form.get('search_filter')
try:
factory = getUtility(IVocabularyFactory, name)
@@ -290,7 +291,7 @@
vocabulary = factory(self.context)
if IHugeVocabulary.providedBy(vocabulary):
- matches = vocabulary.searchForTerms(search_text)
+ matches = vocabulary.searchForTerms(search_text, search_filter)
total_size = matches.count()
else:
matches = list(vocabulary)
=== modified file 'lib/lp/app/javascript/picker/picker.js'
--- lib/lp/app/javascript/picker/picker.js 2011-08-12 03:02:05 +0000
+++ lib/lp/app/javascript/picker/picker.js 2011-08-19 06:11:29 +0000
@@ -31,6 +31,7 @@
C_SEARCH_SLOT = getCN(PICKER, 'search-slot'),
C_FOOTER_SLOT = getCN(PICKER, 'footer-slot'),
C_SEARCH_MODE = getCN(PICKER, 'search-mode'),
+ C_FILTER = getCN(PICKER, 'filter'),
C_RESULTS = getCN(PICKER, 'results'),
C_RESULT_TITLE = getCN(PICKER, 'result-title'),
C_RESULT_DESCRIPTION = getCN(PICKER, 'result-description'),
@@ -60,7 +61,9 @@
BINDUI = "bindUI",
ASSOCIATED_FIELD_ID = 'associated_field_id',
SELECTED_VALUE = 'selected_value',
- SELECTED_VALUE_METADATA = 'selected_value_metadata';
+ SELECTED_VALUE_METADATA = 'selected_value_metadata',
+ FILTER_OPTIONS = 'filter_options',
+ CURRENT_FILTER_VALUE = 'current_filter_value';
var Picker = function () {
@@ -91,6 +94,15 @@
_search_button: null,
/**
+ * The node containing filter options.
+ *
+ * @property _filter_box
+ * @type Node
+ * @private
+ */
+ _filter_box: null,
+
+ /**
* The node containing search results.
*
* @property _results_box
@@ -200,6 +212,13 @@
if (Y.Lang.isValue(cfg[SELECTED_VALUE])) {
this.set(SELECTED_VALUE, cfg[SELECTED_VALUE]);
}
+ // Optional filter support
+ if (Y.Lang.isValue(cfg[FILTER_OPTIONS])) {
+ this.set(FILTER_OPTIONS, cfg[FILTER_OPTIONS]);
+ if (Y.Lang.isValue(cfg[CURRENT_FILTER_VALUE])) {
+ this.set(CURRENT_FILTER_VALUE, cfg[CURRENT_FILTER_VALUE]);
+ }
+ }
}
},
@@ -422,7 +441,7 @@
if (data.badges) {
var badges = Y.Node.create('<div>Affiliation:</div>')
.addClass('badge');
- var already_processed = new Array();
+ var already_processed = [];
Y.each(data.badges, function(badge_info) {
var badge_url = badge_info.url;
var badge_alt = badge_info.alt;
@@ -532,6 +551,7 @@
// Remove any previous results.
Y.Event.purgeElement(this._results_box, true);
this._results_box.set('innerHTML', '');
+ this._filter_box.set('innerHTML', '');
var expander_id = this.get(BOUNDING_BOX).get('id');
Y.Array.each(results, function(data, i) {
@@ -586,8 +606,18 @@
{query: this._search_input.get('value')})));
this._results_box.appendChild(msg);
this._results_box.addClass(C_NO_RESULTS);
+ this._syncFilterUI();
} else {
this._results_box.removeClass(C_NO_RESULTS);
+ if (results.length) {
+ var filters = this.get(FILTER_OPTIONS);
+ var current_filter_value = this.get(CURRENT_FILTER_VALUE);
+ if (filters.length > 0 &&
+ !Y.Lang.isValue(current_filter_value)) {
+ this.set(CURRENT_FILTER_VALUE, filters[0].title);
+ }
+ this._syncFilterUI();
+ }
}
if (results.length) {
@@ -600,6 +630,58 @@
},
/**
+ * Update the filter UI based on the current filter value used for the
+ * search.
+ */
+ _syncFilterUI: function() {
+ // Check that we need to display the filter UI.
+ var filters = this.get(FILTER_OPTIONS);
+ if( filters.length === 0 ) {
+ return;
+ }
+ var current_filter_value = this.get(CURRENT_FILTER_VALUE);
+ if (!Y.Lang.isValue(current_filter_value)) {
+ return;
+ }
+
+ var filter_msg = Y.substitute(
+ 'Showing <strong>{filter}</strong> matches for "{search_terms}".',
+ {filter: current_filter_value,
+ search_terms: this._search_input.get('value')});
+ this._filter_box.appendChild(Y.Node.create(filter_msg));
+
+ var filter_node = Y.Node.create('<div>Filter by: </div>');
+ var picker = this;
+ Y.Array.each(filters, function(filter, i) {
+ var filter_link = Y.Node.create('<a></a>')
+ .set('href', '#')
+ .set('text', filter.title)
+ .set('title', filter.description);
+ if( filter.title === current_filter_value) {
+ filter_link.addClass('invalid-link');
+ } else {
+ filter_link.addClass('js-action');
+ // When a filter link is clicked, we simply fire off a search
+ // event.
+ filter_link.on('click', function (e) {
+ e.halt();
+ picker.set(CURRENT_FILTER_VALUE, filter.title);
+ var search_string = Y.Lang.trim(
+ picker._search_input.get('value'));
+ picker._performSearch(search_string, filter.name);
+ });
+ }
+ filter_node.append(filter_link);
+ if (i < filters.length - 2) {
+ filter_node.append(Y.Node.create(', '));
+ } else if (i === filters.length - 2) {
+ filter_node.append(Y.Node.create(', or '));
+ }
+ });
+ this._filter_box.appendChild(filter_node);
+ },
+
+ /**
* Sync UI with search mode. Disable the search input and button.
*
* @method _syncSearchModeUI
@@ -671,6 +753,9 @@
this._search_slot_box.addClass(C_SEARCH_SLOT);
search_box.appendChild(this._search_slot_box);
+ this._filter_box = Y.Node.create('<div></div>');
+ this._filter_box.addClass(C_FILTER);
+
this._results_box = Y.Node.create('<ul></ul>');
this._results_box.addClass(C_RESULTS);
@@ -682,6 +767,7 @@
var body = Y.Node.create('<div></div>');
body.appendChild(search_box);
+ body.appendChild(this._filter_box);
body.appendChild(this._results_box);
body.appendChild(this._batches_box);
body.appendChild(this._footer_slot_box);
@@ -801,8 +887,10 @@
this.set(BATCHES, null);
this.set(BATCH_COUNT, null);
this.set(SELECTED_BATCH, 0);
+ this.set(CURRENT_FILTER_VALUE, null);
this._search_input.set('value', '');
this._results_box.set('innerHTML', '');
+ this._filter_box.set('innerHTML', '');
},
/**
@@ -815,7 +903,19 @@
*/
_defaultSearchUserAction: function(e) {
e.preventDefault();
+ this.set(CURRENT_FILTER_VALUE, null);
var search_string = Y.Lang.trim(this._search_input.get('value'));
+ this._performSearch(search_string);
+ },
+
+ /**
+ * Fires the search event after checking the search string and reseting
+ * the relevant picker data.
+ * search event.
+ * @param search_string The search term.
+ * @param filter_name The name of a filter to use to limit the results.
+ */
+ _performSearch: function(search_string, filter_name) {
if (search_string.length < this.get(MIN_SEARCH_CHARS)) {
var msg = Y.substitute(
"Please enter at least {min} characters.",
@@ -828,7 +928,8 @@
this.set(SELECTED_BATCH, 0);
}
this.set(CURRENT_SEARCH_STRING, search_string);
- this.fire(SEARCH, search_string);
+ this.fire(SEARCH, search_string, undefined, undefined,
+ filter_name);
}
},
@@ -978,6 +1079,23 @@
current_search_string: {value: ''},
/**
+ * The string representation of the current filter.
+ *
+ * @attribute current_filter_value
+ * @type String
+ */
+ current_filter_value: {value: null},
+
+ /**
+ * A list of attribute name values used to construct the filtering options
+ * for this picker..
+ *
+ * @attribute filter_options
+ * @type Object
+ */
+ filter_options: {value: []},
+
+ /**
* The string representation of the value selected by using this picker.
*
* @attribute selected_value
=== modified file 'lib/lp/app/javascript/picker/picker_patcher.js'
--- lib/lp/app/javascript/picker/picker_patcher.js 2011-08-11 15:38:27 +0000
+++ lib/lp/app/javascript/picker/picker_patcher.js 2011-08-19 06:11:29 +0000
@@ -266,8 +266,13 @@
* @param {String} associated_field_id Optional Id of the text field to
* to be updated with the value selected by the
* picker.
+ * @param {Object} vocabulary_filters Optional List of filters which are
+ * supported by the vocabulary. Filter objects are a
+ * dict of name, title, description values.
+ *
*/
-namespace.create = function (vocabulary, config, associated_field_id) {
+namespace.create = function (vocabulary, config, associated_field_id,
+ vocabulary_filters) {
if (Y.UA.ie) {
return;
}
@@ -313,7 +318,8 @@
headerContent: "<h2>" + header + "</h2>",
steptitle: step_title,
zIndex: 1000,
- visible: false
+ visible: false,
+ filter_options: vocabulary_filters
});
var picker = null;
@@ -378,6 +384,7 @@
// Was this search initiated automatically, perhaps to load
// suggestions?
var automated_search = e.details[2] || false;
+ var search_filter = e.details[3];
var start = BATCH_SIZE * selected_batch;
var batch = 0;
@@ -461,6 +468,10 @@
var qs = '';
qs = Y.lp.client.append_qs(qs, 'name', vocabulary);
qs = Y.lp.client.append_qs(qs, 'search_text', search_text);
+ if (Y.Lang.isValue(search_filter)) {
+ qs = Y.lp.client.append_qs(
+ qs, 'search_filter', search_filter);
+ }
qs = Y.lp.client.append_qs(qs, 'batch', BATCH_SIZE);
qs = Y.lp.client.append_qs(qs, 'start', start);
=== modified file 'lib/lp/app/javascript/picker/tests/test_picker.js'
--- lib/lp/app/javascript/picker/tests/test_picker.js 2011-08-11 03:27:26 +0000
+++ lib/lp/app/javascript/picker/tests/test_picker.js 2011-08-19 06:11:29 +0000
@@ -7,6 +7,8 @@
var Assert = Y.Assert,
ArrayAssert = Y.ArrayAssert;
+var module = Y.lazr.picker;
+
/*
* A wrapper for the Y.Event.simulate() function. The wrapper accepts
* CSS selectors and Node instances instead of raw nodes.
@@ -344,7 +346,7 @@
var img_node = li.one(
'div.badge img.badge:nth-child(' + (i + 1) + ')');
// Check that duplicate badge urls are not displayed.
- if (i==2) {
+ if (i===2) {
Assert.isNull(img_node);
break;
}
@@ -1140,6 +1142,195 @@
suite.add(new Y.Test.Case({
+ name: 'picker_with_filter',
+
+ setUp: function() {
+ this.picker = new Y.lazr.picker.Picker({
+ "selected_value": 'foo',
+ "selected_value_metadata": 'foobar',
+ "filter_options": [
+ {'name': 'ALL',
+ 'title': 'All',
+ 'description': 'Display all'},
+ {'name': 'PROJECT',
+ 'title': 'Product',
+ 'description': 'Display products'}
+ ]
+ });
+ },
+
+ tearDown: function() {
+ cleanup_widget(this.picker);
+ },
+
+ test_picker_has_elements: function () {
+ // Test renderUI() adds filter container container to the widget.
+ this.picker.render();
+
+ var bb = this.picker.get('boundingBox');
+ Assert.isNotNull(
+ bb.one('.yui3-picker-filter'),
+ "Missing filter box.");
+ },
+
+ _check_filter: function (filter_data) {
+ // Check the expected filter links are rendered with the correct data.
+ var filter_div = this.picker.get('boundingBox')
+ .one('.yui3-picker-filter');
+ var i;
+ for (i=0; i<filter_data.length; i++) {
+ var link = filter_div.one('a:nth-child(' + (i + 1) + ')');
+ Assert.isTrue(link.hasClass(filter_data[i].css));
+ Assert.areEqual(link.get('text'), filter_data[i].title);
+ Assert.areEqual(link.get('title'), filter_data[i].description);
+ }
+ },
+
+ test_no_results_does_not_render_filter: function () {
+ // Rendering empty results doesn't render the filter and clears it
+ // if it is already visible.
+ this.picker.render();
+ this.picker._search_input.set('value', 'Joe');
+ var filter_div = this.picker.get('boundingBox')
+ .one('.yui3-picker-filter');
+ // Make the filter visible by rendering some results.
+ this.picker.set('results', [
+ {
+ value: 'jschmo',
+ title: 'Joe Schmo',
+ description: 'joe@xxxxxxxxxxx'
+ }
+ ]);
+ Assert.areNotEqual('', filter_div.get('innerHTML'));
+ // Reset and render empty results.
+ this.picker.set('current_filter_value', null);
+ this.picker.set('results', []);
+ Assert.areEqual('', filter_div.get('innerHTML'));
+ },
+
+ test_set_results_renders_filter: function () {
+ // Rendering results also renders the filter elements.
+ this.picker.render();
+ this.picker._search_input.set('value', 'Joe');
+ this.picker.set('results', [
+ {
+ value: 'jschmo',
+ title: 'Joe Schmo',
+ description: 'joe@xxxxxxxxxxx'
+ }
+ ]);
+ var filter_div = this.picker.get('boundingBox')
+ .one('.yui3-picker-filter');
+ Assert.isNotNull(filter_div, "Filter not found");
+ Assert.areEqual('Showing All matches for "Joe".' +
+ 'Filter by:\u00A0All,\u00A0or\u00A0Product',
+ filter_div.get('textContent'));
+ this._check_filter([
+ {css: 'invalid-link', title: 'All', description: 'Display all'},
+ {css: 'js-action', title: 'Product',
+ description: 'Display products'}
+ ]);
+ },
+
+ test_filter_search: function () {
+ // When a filter link is clicked a new search is performed and the
+ // filter is updated.
+ this.picker.render();
+ this.picker._search_input.set('value', 'Joe');
+ this.picker.set('results', [
+ {
+ value: 'jschmo',
+ title: 'Joe Schmo',
+ description: 'joe@xxxxxxxxxxx'
+ }
+ ]);
+ var search_ok = false;
+ var picker = this.picker;
+ this.picker.subscribe('search', function(e) {
+ var search_string = e.details[0];
+ var filter_name = e.details[3];
+ Assert.areEqual('Joe', search_string);
+ Assert.areEqual('PROJECT', filter_name);
+ Assert.areEqual('Product', picker.get('current_filter_value'));
+ search_ok = true;
+ picker.set('results', [
+ {
+ value: 'jschmo',
+ title: 'Joe Schmo',
+ description: 'joe@xxxxxxxxxxx'
+ }
+ ]);
+ });
+ var filter_div = this.picker.get('boundingBox')
+ .one('.yui3-picker-filter');
+ var filter_link = filter_div.one('a:nth-child(2)');
+ filter_link.simulate('click');
+ Assert.isTrue(search_ok);
+ this._check_filter([
+ {css: 'js-action', title: 'All', description: 'Display all'},
+ {css: 'invalid-link', title: 'Product',
+ description: 'Display products'}
+ ]);
+ },
+
+ test_filter_search_no_results: function () {
+ // When a filter link is clicked and no results are returned, the
+ // filter is still visible.
+ this.picker.render();
+ this.picker._search_input.set('value', 'Joe');
+ this.picker.set('results', [
+ {
+ value: 'jschmo',
+ title: 'Joe Schmo',
+ description: 'joe@xxxxxxxxxxx'
+ }
+ ]);
+ var search_ok = false;
+ var picker = this.picker;
+ this.picker.subscribe('search', function(e) {
+ var search_string = e.details[0];
+ var filter_name = e.details[3];
+ Assert.areEqual('Joe', search_string);
+ Assert.areEqual('PROJECT', filter_name);
+ Assert.areEqual('Product', picker.get('current_filter_value'));
+ search_ok = true;
+ picker.set('results', []);
+ });
+ var filter_div = this.picker.get('boundingBox')
+ .one('.yui3-picker-filter');
+ var filter_link = filter_div.one('a:nth-child(2)');
+ filter_link.simulate('click');
+ Assert.isTrue(search_ok);
+ this._check_filter([
+ {css: 'js-action', title: 'All', description: 'Display all'},
+ {css: 'invalid-link', title: 'Product',
+ description: 'Display products'}
+ ]);
+ },
+
+ test_search_resets_filter: function () {
+ // When a new search is performed the current filter is reset.
+ this.picker.render();
+ var picker = this.picker;
+ var search_ok = false;
+ this.picker.set('current_filter_value', 'Product');
+ this.picker._search_input.set('value', 'Joe');
+ this.picker.subscribe('search', function(e) {
+ var search_string = e.details[0];
+ var filter_name = e.details[3];
+ Assert.areEqual('Joe', search_string);
+ Assert.isFalse(Y.Lang.isValue(filter_name));
+ Assert.isFalse(
+ Y.Lang.isValue(picker.get('current_filter_value')));
+ search_ok = true;
+ });
+ this.picker._search_button.simulate('click');
+ Assert.isTrue(search_ok);
+ }
+}));
+
+suite.add(new Y.Test.Case({
+
name: 'picker_text_field_plugin',
setUp: function() {
=== modified file 'lib/lp/app/javascript/picker/tests/test_picker_patcher.js'
--- lib/lp/app/javascript/picker/tests/test_picker_patcher.js 2011-08-11 15:51:04 +0000
+++ lib/lp/app/javascript/picker/tests/test_picker_patcher.js 2011-08-19 06:11:29 +0000
@@ -288,6 +288,14 @@
config);
},
+ test_filter_options_initialisation: function() {
+ // Filter options are correctly used to set up the picker.
+ this.picker = Y.lp.app.picker.create(
+ this.vocabulary, undefined, undefined, ['a', 'b', 'c']);
+ Y.ArrayAssert.itemsAreEqual(
+ ['a', 'b', 'c'], this.picker.get('filter_options'));
+ },
+
test_picker_displays_empty_list: function() {
// With too many results, the results will be empty.
this.create_picker(true);
=== modified file 'lib/lp/app/widgets/popup.py'
--- lib/lp/app/widgets/popup.py 2011-07-27 00:01:47 +0000
+++ lib/lp/app/widgets/popup.py 2011-08-19 06:11:29 +0000
@@ -138,6 +138,7 @@
show_remove_button=self.show_remove_button,
show_assign_me_button=self.show_assign_me_button,
vocabulary_name=self.vocabulary_name,
+ vocabulary_filters=self.vocabulary_filters,
input_element=self.input_id)
@property
@@ -156,6 +157,32 @@
return None
@property
+ def vocabulary_filters(self):
+ """The name of the field's vocabulary."""
+ choice = IChoice(self.context)
+ if choice.vocabulary is None:
+ # We need the vocabulary to get the supported filters.
+ raise ValueError(
+ "The %r.%s interface attribute doesn't have its "
+ "vocabulary specified."
+ % (choice.context, choice.__name__))
+ supported_filters = choice.vocabulary.supportedFilters()
+ # If we have no filters or just the ALL filter, then no filtering
+ # support is required.
+ filters = []
+ if (len(supported_filters) == 0 or
+ (len(supported_filters) == 1
+ and supported_filters[0].name == 'ALL')):
+ return filters
+ for filter in supported_filters:
+ filters.append({
+ 'name': filter.name,
+ 'title': filter.title,
+ 'description': filter.description,
+ })
+ return filters
+
+ @property
def vocabulary_name(self):
"""The name of the field's vocabulary."""
choice = IChoice(self.context)
=== modified file 'lib/lp/app/widgets/templates/form-picker-macros.pt'
--- lib/lp/app/widgets/templates/form-picker-macros.pt 2011-07-27 00:01:47 +0000
+++ lib/lp/app/widgets/templates/form-picker-macros.pt 2011-08-19 06:11:29 +0000
@@ -38,6 +38,7 @@
var picker = null;
var config = ${view/json_config};
var vocabulary = config.vocabulary_name;
+ var vocabulary_filters = config.vocabulary_filters;
var input_element = config.input_element;
Y.on('domready', function(e) {
// Sort out the Choose... link.
@@ -49,7 +50,8 @@
show_widget_node.on('click', function (e) {
if (picker === null) {
picker = Y.lp.app.picker.create(
- vocabulary, config, input_element);
+ vocabulary, config, input_element,
+ vocabulary_filters);
}
picker.show();
e.preventDefault();
=== modified file 'lib/lp/app/widgets/tests/test_popup.py'
--- lib/lp/app/widgets/tests/test_popup.py 2011-07-27 05:05:46 +0000
+++ lib/lp/app/widgets/tests/test_popup.py 2011-08-19 06:11:29 +0000
@@ -27,7 +27,9 @@
"test_invalid_chars+":
Choice(vocabulary='ValidTeamOwner'),
"test_valid.item":
- Choice(vocabulary='ValidTeamOwner')}
+ Choice(vocabulary='ValidTeamOwner'),
+ "test_filtered.item":
+ Choice(vocabulary='DistributionOrProduct')}
super(TestMetaClass, self).__init__(
name, bases=bases, attrs=attrs, __doc__=__doc__,
__module__=__module__)
@@ -45,8 +47,8 @@
def setUp(self):
super(TestVocabularyPickerWidget, self).setUp()
self.context = self.factory.makeTeam()
- vocabulary_registry = getVocabularyRegistry()
- self.vocabulary = vocabulary_registry.get(
+ self.vocabulary_registry = getVocabularyRegistry()
+ self.vocabulary = self.vocabulary_registry.get(
self.context, 'ValidTeamOwner')
self.request = LaunchpadTestRequest()
@@ -62,6 +64,7 @@
widget_config = simplejson.loads(picker_widget.json_config)
self.assertEqual(
'ValidTeamOwner', picker_widget.vocabulary_name)
+ self.assertEqual([], picker_widget.vocabulary_filters)
self.assertEqual(self.vocabulary.displayname, widget_config['header'])
self.assertEqual(self.vocabulary.step_title,
widget_config['step_title'])
@@ -74,6 +77,31 @@
self.assertIn("Y.lp.app.picker.create", markup)
self.assertIn('ValidTeamOwner', markup)
+ def test_widget_filtered_vocabulary(self):
+ # Check if a vocabulary supports filters, these are included in the
+ # widget configuration.
+ field = ITest['test_filtered.item']
+ bound_field = field.bind(self.context)
+ vocabulary = self.vocabulary_registry.get(
+ self.context, 'DistributionOrProduct')
+ picker_widget = VocabularyPickerWidget(
+ bound_field, vocabulary, self.request)
+
+ widget_config = simplejson.loads(picker_widget.json_config)
+ self.assertEqual([
+ {'name': 'ALL',
+ 'title': 'All',
+ 'description': 'Display all search results'},
+ {'name': 'PROJECT',
+ 'title': 'Product',
+ 'description':
+ 'Display search results associated with products'},
+ {'name': 'DISTRO',
+ 'title': 'Distribution',
+ 'description':
+ 'Display search results associated with distributions'}
+ ], widget_config['vocabulary_filters'])
+
def test_widget_fieldname_with_invalid_html_chars(self):
# Check the picker widget is correctly set up for a field which has a
# name containing some invalid HTML ID characters.
=== modified file 'lib/lp/registry/vocabularies.py'
--- lib/lp/registry/vocabularies.py 2011-08-19 06:11:28 +0000
+++ lib/lp/registry/vocabularies.py 2011-08-19 06:11:29 +0000
@@ -1948,8 +1948,8 @@
def __new__(cls):
return super(VocabularyFilter, cls).__new__(
- cls, 'PROJECT', 'Project',
- 'Display search results associated with projects')
+ cls, 'PROJECT', 'Product',
+ 'Display search results associated with products')
@property
def filter_terms(self):
@@ -1961,8 +1961,8 @@
def __new__(cls):
return super(VocabularyFilter, cls).__new__(
- cls, 'PROJECTGROUP', 'Project Group',
- 'Display search results associated with project groups')
+ cls, 'PROJECTGROUP', 'Project',
+ 'Display search results associated with projects')
@property
def filter_terms(self):
=== modified file 'lib/lp/registry/vocabularies.zcml'
--- lib/lp/registry/vocabularies.zcml 2011-08-01 14:55:52 +0000
+++ lib/lp/registry/vocabularies.zcml 2011-08-19 06:11:29 +0000
@@ -463,4 +463,28 @@
<allow interface="canonical.launchpad.webapp.vocabulary.IHugeVocabulary"/>
</class>
+ <class
+ class="canonical.launchpad.webapp.vocabulary.VocabularyFilterAll">
+ <require
+ permission="zope.Public"
+ attributes="name title description"/>
+ </class>
+ <class
+ class="lp.registry.vocabularies.VocabularyFilterProject">
+ <require
+ permission="zope.Public"
+ attributes="name title description"/>
+ </class>
+ <class
+ class="lp.registry.vocabularies.VocabularyFilterProjectGroup">
+ <require
+ permission="zope.Public"
+ attributes="name title description"/>
+ </class>
+ <class
+ class="lp.registry.vocabularies.VocabularyFilterDistribution">
+ <require
+ permission="zope.Public"
+ attributes="name title description"/>
+ </class>
</configure>