widgets.py 13.6 KB
Newer Older
1
# copyright 2015-2021 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 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"""

18
19
from logilab.common.decorators import monkeypatch

20
from cubicweb import tags, utils
21
22
from cubicweb.uilib import js
from cubicweb.web import formfields as ff, formwidgets as fw
23
from cubicweb.web.views import ajaxcontroller, autoform, formrenderers
24

25
from cubicweb_relationwidget import views as rwdg
26
27
28
29
30
31


# deactivate relation widget's creation form by default, it causes some js error if e.g. there are
# some calendar widgets. Also, we don't really want that from an UX POV, IMO.
rwdg.SearchForRelatedEntitiesView.has_creation_form = False

32

33
34
35
36
37
38
39
40
41
42
43
44
def configure_relation_widget(req, div, search_url, title, multiple, validate):
    """Build a javascript link to invoke a relation widget

    Widget will be linked to div `div`, with a title `title`. It will display selectable entities
    matching url `search_url`. bool `multiple` indicates whether several entities can be selected or
    just one, `validate` identifies the javascript callback that must be used to validate the
    selection.
    """
    req.add_js(('jquery.ui.js',
                'cubicweb.ajax.js',
                'cubicweb.widgets.js',
                'cubicweb.facets.js',
45
                'cubicweb_relationwidget.js',
46
47
48
49
50
                'cubes.editionext.js'))
    return 'javascript: %s' % js.editext.relateWidget(div, search_url, title, multiple,
                                                      utils.JSString(validate))


51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
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) \
104
105
           or (entity.has_eid()
               and set(x.eid for x in entity.equivalent_concept) != equivalent_eids):
106
107
108
109
110
111
112
113
            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)


114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
class KeywordTypeMasterWidget(fw.Select):
    """
    Usage::

      affk = uicfg.autoform_field_kwargs
      affk.set_field_kwargs('KeywordType', 'keyword_type_to',
                            widget=widgets.KeywordTypeMasterWidget(
                            slave_base_name='seda_keyword_reference_to_scheme'))
    """

    def __init__(self, slave_base_name, **kwargs):
        super(KeywordTypeMasterWidget, self).__init__(**kwargs)
        self.slave_base_name = slave_base_name

    def _render(self, form, field, render):
        req = form._cw

        vocabularies_data = []
        for eid, title, uri, keyword_type in req.execute(
                'Any CS,CST,CSU,CSKT WHERE CS title CST, CS cwuri CSU, CS code_keyword_type CSKT?'):
            vocabularies_data.append({'eid': eid, 'title': title or uri,
                                      'keyword_type': keyword_type})
        vocabularies_data.sort(key=lambda x: x['title'])

        req.add_js(('cubicweb.js', 'cubicweb.ajax.js', 'cubes.skoscomplete.js'))
        req.add_onload(js.typed_vocabularies.initKeywordTypeMasterWidget(
            field.dom_id(form, self.suffix), self.slave_base_name, vocabularies_data))

        return super(KeywordTypeMasterWidget, self)._render(form, field, render)


145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
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))
    """
169
170
171
172
    needs_css = ('jquery.ui.css',)
    needs_js = ('jquery.ui.js',
                'cubicweb.js', 'cubicweb.ajax.js',
                'cubes.skoscomplete.js')
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199

    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_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()
Noé Gaumont's avatar
Noé Gaumont committed
200
            eid = str(concept.eid)
201
202
203
        # 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',
204
                       klass='form-control', type='text',
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
                       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})]
223
224
225
226
227
228
229
230
231
232
233
234


@monkeypatch(formrenderers.EntityInlinedFormRenderer)
def render_title(self, w, form, values):
    """Monkey-patched to remove counter"""
    w(u'<div class="iformTitle">')
    w(u'<span>%(title)s</span>' % values)
    if values['removejs']:
        values['removemsg'] = self._cw.__('remove-inlined-entity-form')
        w(u' [<a href="javascript: %(removejs)s;$.noop();">%(removemsg)s</a>]'
          % values)
    w(u'</div>')
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267


class SEDAMetaField(ff.StringField):
    """Extends user_cardinality field to allow edition of a `user_annotation` all together.
    """

    @staticmethod
    def annotation_field(form):
        """Return the field for `user_annotation` entity. """
        eschema = form.edited_entity.e_schema
        rschema = eschema.subjrels['user_annotation']
        return ff.guess_field(eschema, rschema, 'subject', form._cw)

    def actual_fields(self, form):
        """Overriden from :class:`Field`"""
        yield self
        yield self.annotation_field(form)

    def render(self, form, renderer):
        """Overriden from :class:`Field` to render fields in a div which is hidden by default"""
        form._cw.add_js('cubes.seda.js')
        wdgs = [self.get_widget(form).render(form, self, renderer)]
        self._render_hidden_section(wdgs, form, renderer)
        return u'\n'.join(wdgs)

    def _render_hidden_section(self, wdgs, form, renderer):
        divid = '%s-advanced' % self.input_name(form)
        if form.edited_entity.has_eid() and form.edited_entity.user_annotation:
            hidden = ''
            icon = 'icon-up-open'
        else:
            hidden = ' hidden'
            icon = 'icon-list-add'
Noé Gaumont's avatar
Noé Gaumont committed
268
        wdgs.append(tags.a(u'', onclick=str(js.seda.toggleFormMetaVisibility(divid)),
269
                           href='javascript:$.noop()', title=form._cw._('show/hide meta fields'),
270
                           # take care, related js relies on the icon class position
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
                           klass=icon + ' metaFieldSwitch'))
        wdgs.append(u'<div id="{0}" class="metaField{1}">'.format(divid, hidden))
        wdgs.append(self._render_subfield(form, self.annotation_field(form), renderer))
        wdgs.append(u'</div>')

    @staticmethod
    def _render_subfield(form, field, renderer):
        """Render a sub-field: label + widget + help + EOL"""
        data = utils.UStringIO()
        w = data.write
        w(u'<div class="row">')
        w(renderer.render_label(form, field))
        w(u'<div class="col-md-9">')
        w(field.render(form, renderer))
        w(u'</div>')
        w(u'</div>')
        w(u'<div class="row">')
        w(u'<div class="col-md-offset-3 col-md-9">')
        w(renderer.render_help(form, field))
        w(u'</div>')
        w(u'</div>')
        return data.getvalue()
293
294
295
296
297


class SimplifiedAutomaticEntityForm(autoform.AutomaticEntityForm):
    """Custom autoform, forcing display of creation form instead of add new link.
    """
298

299
300
301
302
303
304
305
306
307
308
309
    __abstract__ = True

    def should_display_inline_creation_form(self, rschema, existing, card):
        return not existing

    def should_display_add_new_relation_link(self, rschema, existing, card):
        return False


class NoTitleEntityInlinedFormRenderer(formrenderers.EntityInlinedFormRenderer):
    """Custom inlined form renderer that doesn't display any title nor remove link.
310
311

    This is intended to be subclassed with a custom selector.
312
    """
313

314
315
316
317
    __abstract__ = True

    def render_title(self, w, form, values):
        pass
318
319
320
321
322
323
324
325
326


class ConcretNoTitleEntityInlinedFormRenderer(NoTitleEntityInlinedFormRenderer):
    """Concret implementation of `NoTitleEntityInlinedFormRenderer` with a custom regid.

    Use this one by specifying the renderer id explicitly, for case where you can't easily specify a
    selector.
    """
    __regid__ = 'notitle'