Commit 636bb411 authored by Sylvain Thénault's avatar Sylvain Thénault
Browse files

[ui] Backport magic autocompletion widget from saem

With a bit of cleanup and extra documentation along the way.
parent 1d0c50a5b1a6
// copyright 2015-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
// contact http://www.logilab.fr -- mailto:contact@logilab.fr
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Lesser General Public License as published by the Free
// Software Foundation, either version 2.1 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
// details.
//
// You should have received a copy of the GNU Lesser General Public License along
// with this program. If not, see <http://www.gnu.org/licenses/>.
// An autocompletion widget to select a concept from a vocabulary specified by another widget
concept_autocomplete = {
initConceptAutoCompleteWidget: function(masterSelectId, dependentSelectId, ajaxFuncName) {
var masterSelect = cw.jqNode(masterSelectId);
// bind vocabulary select to update concept autocompletion input on value change
masterSelect.change(function() {
concept_autocomplete.updateCurrentSchemeEid(this);
concept_autocomplete.resetConceptFormField(dependentSelectId);
});
// initialize currentSchemeEid by looking the value of the master field
concept_autocomplete.updateCurrentSchemeEid(masterSelect);
// also bind the autocompletion widget
cw.jqNode(dependentSelectId+'Label')
.autocomplete({
source: function(request, response) {
if (concept_autocomplete.currentSchemeEid) {
var form = ajaxFuncArgs(ajaxFuncName,
{'q': request.term,
'scheme': concept_autocomplete.currentSchemeEid});
var d = loadRemote(AJAX_BASE_URL, form, 'POST');
d.addCallback(function (suggestions) { response(suggestions); });
}
},
focus: function( event, ui ) {
cw.jqNode(dependentSelectId+'Label').val(ui.item.label);
return false;
},
select: function(event, ui) {
cw.jqNode(dependentSelectId+'Label').val(ui.item.label);
cw.jqNode(dependentSelectId).val(ui.item.value);
return false;
},
'mustMatch': true,
'limit': 100,
'delay': 300})
.tooltip({
tooltipClass: "ui-state-highlight"
});
// add key press and focusout event handlers so that value which isn't matching a vocabulary
// value will be erased
resetIfInvalidChoice = function() {
if (concept_autocomplete.currentSchemeEid) {
var validChoices = $.map($('ul.ui-autocomplete li'),
function(li) {return $(li).text();});
var value = cw.jqNode(dependentSelectId + 'Label').val();
if ($.inArray(value, validChoices) == -1) {
concept_autocomplete.resetConceptFormField(dependentSelectId);
}
}
};
cw.jqNode(dependentSelectId+'Label').keypress(function(evt) {
if (evt.keyCode == $.ui.keyCode.ENTER || evt.keyCode == $.ui.keyCode.TAB) {
resetIfInvalidChoice();
}
});
cw.jqNode(dependentSelectId+'Label').focusout(function(evt) {
resetIfInvalidChoice();
});
},
updateCurrentSchemeEid: function(masterSelect) {
concept_autocomplete.currentSchemeEid = $(masterSelect).val();
if (concept_autocomplete.currentSchemeEid == '__cubicweb_internal_field__') {
concept_autocomplete.currentSchemeEid = null;
}
},
resetConceptFormField: function(dependentSelectId) {
cw.jqNode(dependentSelectId+'Label').val('');
cw.jqNode(dependentSelectId).val('');
}
};
# copyright 2015-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr -- mailto:contact@logilab.fr
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""cubicweb-seda custom fields/widgets"""
from cubicweb import tags
from cubicweb.uilib import js
from cubicweb.web import formfields as ff, formwidgets as fw
from cubicweb.web.views import ajaxcontroller
class ConceptOrTextField(ff.Field):
"""Compound field, using the :class:`ConceptAutoCompleteWidget` to handle values which may be
either a relation to a `Concept` or a string
It's expected to be set on the text attribute. Also, as you should prefer specifying a field's
class, you will usually sublcass this field to set the following attributes:
* 'scheme_relation', relation whose object will be the selected scheme
* 'concept_relation', relation whose object will be the linked concept
Example::
class KeywordConceptOrTextField(ConceptOrTextField):
scheme_relation = 'keyword_scheme'
concept_relation = 'keyword_concept'
def __init__(self, **kwargs):
super(EquivalentConceptOrTextField, self).__init__(required=True, **kwargs)
self.help = _('when linked to a vocabulary, value is enforced to the label of a '
'concept in this vocabulary. Remove it if you want to type text '
'freely.')
aff = uicfg.autoform_field
aff.tag_attribute(('Keyword', 'keyword_value'), KeywordConceptOrTextField)
You may also configure the ajax function that will be used for the completion values, by
specifying it using the 'ajax_autocomplete_func' attribute (or by overriding this ajax
func). Default is :func:`scheme_concepts_autocomplete`.
"""
scheme_relation = concept_relation = None
ajax_autocomplete_func = 'scheme_concepts_autocomplete'
def get_widget(self, form):
return ConceptAutoCompleteWidget(self.name, self.scheme_relation,
self.ajax_autocomplete_func, optional=True)
def has_been_modified(self, form):
return True # handled in process_posted below
def process_posted(self, form):
posted = form._cw.form
text_val = posted.get(self.input_name(form, 'Label'), '').strip()
equivalent_eid = posted.get(self.input_name(form), '').strip()
equivalent_eids = set()
if equivalent_eid:
equivalent_eids.add(int(equivalent_eid))
if self.required and not (text_val or equivalent_eid):
raise ff.ProcessFormError(form._cw.__("required field"))
entity = form.edited_entity
if not entity.has_eid() or getattr(entity, self.name) != text_val:
yield (ff.Field(name=self.name, role='subject', eidparam=True), text_val)
if (not entity.has_eid() and equivalent_eids) \
or (entity.has_eid() and
set(x.eid for x in entity.equivalent_concept) != equivalent_eids):
subfield = ff.Field(name=self.concept_relation, role='subject', eidparam=True)
# register the association between the value and the field, because on entity creation,
# process_posted will be recalled on the newly created field, and if we don't do that it
# won't find the proper value (this is very nasty)
form.formvalues[(subfield, form)] = equivalent_eids
yield (subfield, equivalent_eids)
class ConceptAutoCompleteWidget(fw.TextInput):
"""Derive from simple text input to create an autocompletion widget if a scheme is specified,
otherwise free text is fine if `optional` argument is true. In such case:
* `slave_name` is expected to be the name of the concept attribute, or the text attribute if
optional is true),
* you'll have to use this widget from a custom field that will handle the relation to the
concept (e.g. :class:`ConceptOrTextField`).
When optional is false, a regular :class:`RelationField` is fine.
You may configure the ajax function that will be used for the completion values by specifying it
using the 'ajax_autocomplete_func' argument (or by overriding this ajax func). Default is
:func:`scheme_concepts_autocomplete`.
Usage::
affk = uicfg.autoform_field_kwargs
affk.set_field_kwargs('Keyword', 'keyword_value',
widget=ConceptAutoCompleteWidget(slave_name='keyword_value',
master_name='keyword_scheme',
optional=True))
"""
def __init__(self, slave_name, master_name,
ajax_autocomplete_func='scheme_concepts_autocomplete',
optional=False,
**kwargs):
super(ConceptAutoCompleteWidget, self).__init__(**kwargs)
self.slave_name = slave_name
self.master_name = master_name
self.ajax_autocomplete_func = ajax_autocomplete_func
self.optional = optional
def _render(self, form, field, render):
entity = form.edited_entity
slave_id = field.dom_id(form, self.suffix)
master_id = slave_id.replace(self.slave_name, self.master_name)
if entity.has_eid():
concept = entity.concept
else:
concept = None
req = form._cw
req.add_js(('cubicweb.js', 'cubicweb.ajax.js', 'cubes.skoscomplete.js'))
req.add_onload(js.concept_autocomplete.initConceptAutoCompleteWidget(
master_id, slave_id, self.ajax_autocomplete_func))
if concept is None:
value = getattr(entity, self.slave_name) if self.optional else None
eid = u''
else:
value = concept.label()
eid = unicode(concept.eid)
# we need an hidden input to handle the value while the text input display the label
inputs = [
tags.input(name=field.input_name(form, 'Label'), id=slave_id + 'Label',
tabindex=req.next_tabindex(), klass='form-control', type='text',
value=value),
tags.input(name=field.input_name(form), id=slave_id, type='hidden',
value=eid)
]
return u'\n'.join(inputs)
@ajaxcontroller.ajaxfunc(output_type='json')
def scheme_concepts_autocomplete(self):
assert self._cw.form['scheme']
scheme = int(self._cw.form['scheme'])
term = self._cw.form['q']
limit = self._cw.form.get('limit', 50)
return [{'value': eid, 'label': label}
for eid, label in self._cw.execute(
'DISTINCT Any C,N ORDERBY N LIMIT %s WHERE C in_scheme S, S eid %%(s)s, '
'C preferred_label L, L label N, L label ILIKE %%(term)s' % limit,
{'s': scheme, 'term': u'%%%s%%' % term})]
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment