[BREAK THE WORLD] single search field with global multifield autocompletion full of awesome

bzr revid: xmo@openerp.com-20120319164304-r48l8gdteaekr3hm
This commit is contained in:
Xavier Morel 2012-03-19 17:43:04 +01:00
parent 72cae881f1
commit 16fddf9207
2 changed files with 229 additions and 66 deletions

View File

@ -3,13 +3,50 @@ var QWeb = openerp.web.qweb,
_t = openerp.web._t,
_lt = openerp.web._lt;
// Replace VS.ui.SearchFacet by a factory function returning potentially
// customized backbone views for custom facets
var SearchFacet = VS.ui.SearchFacet;
VS.ui.SearchFacet = function (options) { return new SearchFacet(options); };
// Have SearchBox optionally use callback function to produce inputs and facets
// (views) set on callbacks.make_facet and callbacks.make_input keys when
// initializing VisualSearch
var SearchBox_renderFacet = function (facet, position) {
var view = new (this.app.options.callbacks['make_facet'] || VS.ui.SearchFacet)({
app : this.app,
model : facet,
order : position
});
// Input first, facet second.
this.renderSearchInput();
this.facetViews.push(view);
this.$('.VS-search-inner').children().eq(position*2).after(view.render().el);
view.calculateSize();
_.defer(_.bind(view.calculateSize, view));
return view;
}; // warning: will not match
// Ensure we're replacing the function we think
if (SearchBox_renderFacet.toString() !== VS.ui.SearchBox.prototype.renderFacet.toString().replace(/(VS\.ui\.SearchFacet)/, "(this.app.options.callbacks['make_facet'] || $1)")) {
throw new Error(
"Trying to replace wrong version of VS.ui.SearchBox#renderFacet. "
+ "Please fix replacement.");
}
var SearchBox_renderSearchInput = function () {
var input = new (this.app.options.callbacks['make_input'] || VS.ui.SearchInput)({position: this.inputViews.length, app: this.app});
this.$('.VS-search-inner').append(input.render().el);
this.inputViews.push(input);
};
// Ensure we're replacing the function we think
if (SearchBox_renderSearchInput.toString() !== VS.ui.SearchBox.prototype.renderSearchInput.toString().replace(/(VS\.ui\.SearchInput)/, "(this.app.options.callbacks['make_input'] || $1)")) {
throw new Error(
"Trying to replace wrong version of VS.ui.SearchBox#renderSearchInput. "
+ "Please fix replacement.");
}
_.extend(VS.ui.SearchBox.prototype, {
renderFacet: SearchBox_renderFacet,
renderSearchInput: SearchBox_renderSearchInput
});
openerp.web.SearchView = openerp.web.Widget.extend(/** @lends openerp.web.SearchView# */{
template: "EmptyComponent",
template: "SearchView",
/**
* @constructs openerp.web.SearchView
* @extends openerp.web.OldWidget
@ -42,53 +79,22 @@ openerp.web.SearchView = openerp.web.Widget.extend(/** @lends openerp.web.Search
start: function() {
var p = this._super();
this.field = VS.init({
this.setup_global_completion();
this.vs = VS.init({
container: this.$element,
query: '',
callbacks: {
make_facet: this.proxy('make_visualsearch_facet'),
make_input: this.proxy('make_visualsearch_input'),
search: function (query, searchCollection) {
console.log(query, searchCollection);
},
facetMatches: function (callback) {
callback([
'account', 'filter', 'access', 'title',
{ label: 'city', category: 'location' },
{ label: 'address', category: 'location' },
{ label: 'country', category: 'location' },
{ label: 'state', category: 'location' }
]);
},
valueMatches : function(facet, searchTerm, callback) {
switch (facet) {
case 'account':
callback([
{ value: '1-amanda', label: 'Amanda' },
{ value: '2-aron', label: 'Aron' },
{ value: '3-eric', label: 'Eric' },
{ value: '4-jeremy', label: 'Jeremy' },
{ value: '5-samuel', label: 'Samuel' },
{ value: '6-scott', label: 'Scott' }
]);
break;
case 'filter':
callback(['published', 'unpublished', 'draft']);
break;
case 'access':
callback(['public', 'private', 'protected']);
break;
case 'title':
callback([
'Pentagon Papers',
'CoffeeScript Manual',
'Laboratory for Object Oriented Thinking',
'A Repository Grows in Brooklyn'
]);
break;
}
}
}
});
return p;
if (this.hidden) {
this.$element.hide();
@ -102,7 +108,7 @@ openerp.web.SearchView = openerp.web.Widget.extend(/** @lends openerp.web.Search
context: this.dataset.get_context()
}, this.on_loaded);
}
return this.ready.promise();
return $.when(p, this.ready);
},
show: function () {
this.$element.show();
@ -110,6 +116,121 @@ openerp.web.SearchView = openerp.web.Widget.extend(/** @lends openerp.web.Search
hide: function () {
this.$element.hide();
},
/**
* Sets up search view's view-wide auto-completion widget
*/
setup_global_completion: function () {
var self = this;
this.$element.autocomplete({
source: this.proxy('complete_global_search'),
select: this.proxy('select_completion'),
focus: function (e) { e.preventDefault(); },
html: true,
minLength: 0,
delay: 0
}).data('autocomplete')._renderItem = function (ul, item) {
// item of completion list
var $item = $( "<li></li>" )
.data( "item.autocomplete", item )
.appendTo( ul );
if (item.type === 'section') {
$item.text(item.label)
.css({
borderTop: '1px solid #cccccc',
margin: 0,
padding: 0,
zoom: 1,
'float': 'left',
clear: 'left',
width: '100%'
});
} else if (item.type === 'base') {
// FIXME: translation
$item.append("<a>Search for: \"<strong>" + item.value + "</strong>\"</a>");
} else {
$item.append($("<a>").text(item.label));
}
return $item;
}
},
/**
* Provide auto-completion result for req.term (an array to `resp`)
*
* @param {Object} req request to complete
* @param {String} req.term searched term to complete
* @param {Function} resp response callback
*/
complete_global_search: function (req, resp) {
var completion = [{value: req.term, type: 'base'}];
$.when.apply(null, _(this.inputs).chain()
.invoke('complete', req.term)
.value()).then(function () {
var results = completion.concat.apply(
completion, _(arguments).compact());
resp(results);
});
},
/**
* Action to perform in case of selection: create a facet (model)
* and add it to the search collection
*
* @param {Object} e selection event, preventDefault to avoid setting value on object
* @param {Object} ui selection information
* @param {Object} ui.item selected completion item
*/
select_completion: function (e, ui) {
e.preventDefault();
if (ui.item.type === 'base') {
this.vs.searchQuery.add(new VS.model.SearchFacet({
category: null,
value: ui.item.value,
app: this.vs
}));
return;
}
this.vs.searchQuery.add(new VS.model.SearchFacet({
category: ui.item.category,
value: ui.item.label,
real_value: ui.item.value,
app: this.vs
}));
},
/**
* Builds the right SearchFacet view based on the facet object to render
* (e.g. readonly facets for filters)
*
* @param {Object} options
* @param {VS.model.SearchFacet} options.model facet object to render
*/
make_visualsearch_facet: function (options) {
return new VS.ui.SearchFacet(options);
},
/**
* Proxies searches on a SearchInput to the search view's global completion
*
* Also disables SearchInput.autocomplete#_move so search view's
* autocomplete can get the corresponding events, or something.
*
* @param options
*/
make_visualsearch_input: function (options) {
var self = this, input = new VS.ui.SearchInput(options);
input.setupAutocomplete = function () {
this.box.autocomplete({
minLength: 1,
delay: 0,
search: function () {
self.$element.autocomplete('search', input.box.val());
return false;
}
}).data('autocomplete')._move = function () {};
};
return input;
},
/**
* Builds a list of widget rows (each row is an array of widgets)
*
@ -200,28 +321,19 @@ openerp.web.SearchView = openerp.web.Widget.extend(/** @lends openerp.web.Search
"Got non-search view after asking for a search view: type %s, arch root %s",
data.fields_view.type, data.fields_view.arch.tag));
}
var self = this,
lines = this.make_widgets(
data.fields_view['arch'].children,
data.fields_view.fields);
return this.ready.resolve().promise();
// for extended search view
var ext = new openerp.web.search.ExtendedSearch(this, this.model);
lines.push([ext]);
this.extended_search = ext;
var render = QWeb.render("SearchView", {
'view': data.fields_view['arch'],
'lines': lines,
'defaults': this.defaults
});
this.$element.html(render);
var f = this.$element.find('form');
this.$element.find('form')
.submit(this.do_search)
.bind('reset', this.do_clear);
// start() all the widgets
var widget_starts = _(lines).chain().flatten().map(function (widget) {
return widget.start();
@ -390,9 +502,10 @@ openerp.web.SearchView = openerp.web.Widget.extend(/** @lends openerp.web.Search
* @param e jQuery event object coming from the "Search" button
*/
do_search: function (e) {
console.log(this.field.searchBox.value());
console.log(this.field.searchBox.facets());
console.log(this.vs.searchBox.value());
console.log(this.vs.searchQuery.facets());
return this.on_search([], [], []);
if (this.headless && !this.has_defaults) {
return this.on_search([], [], []);
}
@ -661,6 +774,18 @@ openerp.web.search.Input = openerp.web.search.Widget.extend( /** @lends openerp.
this.view.inputs.push(this);
this.style = undefined;
},
/**
* Fetch auto-completion values for the widget.
*
* The completion values should be an array of objects with keys category,
* label, value prefixed with an object with keys type=section and label
*
* @param {String} value value to complete
* @returns {jQuery.Deferred<null|Object>}
*/
complete: function (value) {
return $.when(null)
},
get_context: function () {
throw new Error(
"get_context not implemented for widget " + this.attrs.type);
@ -873,6 +998,14 @@ openerp.web.search.Field = openerp.web.search.Input.extend( /** @lends openerp.w
*/
openerp.web.search.CharField = openerp.web.search.Field.extend( /** @lends openerp.web.search.CharField# */ {
default_operator: 'ilike',
complete: function (value) {
// FIXME: formatting
var label = _.str.sprintf(_t('Search "%s" for "%s"'),
this.attrs.string, value);
return $.when([
{category: this.attrs.name, label: label, value:value}
]);
},
get_value: function () {
return this.$element.val();
}
@ -944,6 +1077,25 @@ openerp.web.search.SelectionField = openerp.web.search.Field.extend(/** @lends o
return !item[1];
});
},
complete: function (needle) {
var self = this;
var results = _(this.attrs.selection).chain()
.filter(function (sel) {
var value = sel[0], label = sel[1];
if (!value) { return false; }
return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
})
.map(function (sel) {
return {
category: self.attrs.name,
label: sel[1],
value: sel[0]
};
}).value();
if (_.isEmpty(results)) { return $.when(null); }
return $.when.apply(null,
[{type: 'section', label: this.attrs.string}].concat(results));
},
get_value: function () {
var index = parseInt(this.$element.val(), 10);
if (isNaN(index)) { return null; }
@ -1070,6 +1222,26 @@ openerp.web.search.ManyToOneField = openerp.web.search.CharField.extend({
this.dataset = new openerp.web.DataSet(
this.view, this.attrs['relation']);
},
complete: function (needle) {
var self = this;
// TODO: context
// FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
return new openerp.web.Model(this.attrs.relation).call('name_search', [], {
name: needle,
limit: 8,
context: {}
}).pipe(function (results) {
if (_.isEmpty(results)) { return null; }
return [{type: 'section', label: self.attrs.string}].concat(
_(results).map(function (result) {
return {
category: self.attrs.name,
label: result[1],
value: result[0]
};
}));
});
},
start: function () {
this._super();
this.setup_autocomplete();

View File

@ -1242,17 +1242,8 @@
</t>
</t>
<t t-name="SearchView">
<form class="oe_forms">
<t t-call="SearchView.render_lines"/>
<div class="oe_search-view-buttons">
<button class="oe_button">Search</button>
<button class="oe_button" type="reset">Clear</button>
<select class="oe_search-view-filters-management">
</select>
</div>
</form>
</t>
<div t-name="SearchView" class="oe_searchview"/>
<t t-name="SearchView.managed-filters">
<option class="oe-filters-title" value="">Filters</option>
<optgroup label="-- Filters --">