author | Julien Cristau <julien.cristau@logilab.fr> |
Fri, 10 Jul 2015 18:18:58 +0200 | |
changeset 10536 | 887c6eef8077 |
parent 10510 | 51321946da37 |
child 10666 | 7f6b5f023884 |
permissions | -rw-r--r-- |
# copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved. # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr # # This file is part of CubicWeb. # # CubicWeb 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. # # CubicWeb 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 CubicWeb. If not, see <http://www.gnu.org/licenses/>. """abstract component class and base components definition for CubicWeb web client """ __docformat__ = "restructuredtext en" _ = unicode from warnings import warn from logilab.common.deprecation import class_deprecated, class_renamed, deprecated from logilab.mtconverter import xml_escape from cubicweb import Unauthorized, role, target, tags from cubicweb.schema import display_name from cubicweb.uilib import js, domid from cubicweb.utils import json_dumps, js_href from cubicweb.view import ReloadableMixIn, Component from cubicweb.predicates import (no_cnx, paginated_rset, one_line_rset, non_final_entity, partial_relation_possible, partial_has_related_entities) from cubicweb.appobject import AppObject from cubicweb.web import INTERNAL_FIELD_VALUE, stdmsgs # abstract base class for navigation components ################################ class NavigationComponent(Component): """abstract base class for navigation components""" __regid__ = 'navigation' __select__ = paginated_rset() cw_property_defs = { _('visible'): dict(type='Boolean', default=True, help=_('display the component or not')), } page_size_property = 'navigation.page-size' start_param = '__start' stop_param = '__stop' page_link_templ = u'<span class="slice"><a href="%s" title="%s">%s</a></span>' selected_page_link_templ = u'<span class="selectedSlice"><a href="%s" title="%s">%s</a></span>' previous_page_link_templ = next_page_link_templ = page_link_templ def __init__(self, req, rset, **kwargs): super(NavigationComponent, self).__init__(req, rset=rset, **kwargs) self.starting_from = 0 self.total = rset.rowcount def get_page_size(self): try: return self._page_size except AttributeError: page_size = self.cw_extra_kwargs.get('page_size') if page_size is None: if 'page_size' in self._cw.form: page_size = int(self._cw.form['page_size']) else: page_size = self._cw.property_value(self.page_size_property) self._page_size = page_size return page_size def set_page_size(self, page_size): self._page_size = page_size page_size = property(get_page_size, set_page_size) def page_boundaries(self): try: stop = int(self._cw.form[self.stop_param]) + 1 start = int(self._cw.form[self.start_param]) except KeyError: start, stop = 0, self.page_size if start >= len(self.cw_rset): start, stop = 0, self.page_size self.starting_from = start return start, stop def clean_params(self, params): if self.start_param in params: del params[self.start_param] if self.stop_param in params: del params[self.stop_param] def page_url(self, path, params, start=None, stop=None): params = dict(params) params['__fromnavigation'] = 1 if start is not None: params[self.start_param] = start if stop is not None: params[self.stop_param] = stop view = self.cw_extra_kwargs.get('view') if view is not None and hasattr(view, 'page_navigation_url'): url = view.page_navigation_url(self, path, params) elif path in ('json', 'ajax'): # 'ajax' is the new correct controller, but the old 'json' # controller should still be supported url = self.ajax_page_url(**params) else: url = self._cw.build_url(path, **params) # XXX hack to avoid opening a new page containing the evaluation of the # js expression on ajax call if url.startswith('javascript:'): url += '; $.noop();' return url def ajax_page_url(self, **params): divid = params.setdefault('divid', 'pageContent') params['rql'] = self.cw_rset.printable_rql() return js_href("$(%s).loadxhtml(AJAX_PREFIX_URL, %s, 'get', 'swap')" % ( json_dumps('#'+divid), js.ajaxFuncArgs('view', params))) def page_link(self, path, params, start, stop, content): url = xml_escape(self.page_url(path, params, start, stop)) if start == self.starting_from: return self.selected_page_link_templ % (url, content, content) return self.page_link_templ % (url, content, content) @property def prev_icon_url(self): return xml_escape(self._cw.data_url('go_prev.png')) @property def next_icon_url(self): return xml_escape(self._cw.data_url('go_next.png')) @property def no_previous_page_link(self): return (u'<img src="%s" alt="%s" class="prevnext_nogo"/>' % (self.prev_icon_url, self._cw._('there is no previous page'))) @property def no_next_page_link(self): return (u'<img src="%s" alt="%s" class="prevnext_nogo"/>' % (self.next_icon_url, self._cw._('there is no next page'))) @property def no_content_prev_link(self): return (u'<img src="%s" alt="%s" class="prevnext"/>' % ( (self.prev_icon_url, self._cw._('no content prev link')))) @property def no_content_next_link(self): return (u'<img src="%s" alt="%s" class="prevnext"/>' % (self.next_icon_url, self._cw._('no content next link'))) def previous_link(self, path, params, content=None, title=_('previous_results')): if not content: content = self.no_content_prev_link start = self.starting_from if not start : return self.no_previous_page_link start = max(0, start - self.page_size) stop = start + self.page_size - 1 url = xml_escape(self.page_url(path, params, start, stop)) return self.previous_page_link_templ % (url, self._cw._(title), content) def next_link(self, path, params, content=None, title=_('next_results')): if not content: content = self.no_content_next_link start = self.starting_from + self.page_size if start >= self.total: return self.no_next_page_link stop = start + self.page_size - 1 url = xml_escape(self.page_url(path, params, start, stop)) return self.next_page_link_templ % (url, self._cw._(title), content) # new contextual components system ############################################# def override_ctx(cls, **kwargs): cwpdefs = cls.cw_property_defs.copy() cwpdefs['context'] = cwpdefs['context'].copy() cwpdefs['context'].update(kwargs) return cwpdefs class EmptyComponent(Exception): """some selectable component has actually no content and should not be rendered """ class Link(object): """a link to a view or action in the ui. Use this rather than `cw.web.htmlwidgets.BoxLink`. Note this class could probably be avoided with a proper DOM on the server side. """ newstyle = True def __init__(self, href, label, **attrs): self.href = href self.label = label self.attrs = attrs def __unicode__(self): return tags.a(self.label, href=self.href, **self.attrs) def render(self, w): w(tags.a(self.label, href=self.href, **self.attrs)) def __repr__(self): return '<%s: href=%r label=%r %r>' % (self.__class__.__name__, self.href, self.label, self.attrs) class Separator(object): """a menu separator. Use this rather than `cw.web.htmlwidgets.BoxSeparator`. """ newstyle = True def render(self, w): w(u'<hr class="boxSeparator"/>') def _bwcompatible_render_item(w, item): if hasattr(item, 'render'): if getattr(item, 'newstyle', False): if isinstance(item, Separator): w(u'</ul>') item.render(w) w(u'<ul>') else: w(u'<li>') item.render(w) w(u'</li>') else: item.render(w) # XXX displays <li> by itself else: w(u'<li>%s</li>' % item) class Layout(Component): __regid__ = 'component_layout' __abstract__ = True def init_rendering(self): """init view for rendering. Return true if we should go on, false if we should stop now. """ view = self.cw_extra_kwargs['view'] try: view.init_rendering() except Unauthorized as ex: self.warning("can't render %s: %s", view, ex) return False except EmptyComponent: return False return True class LayoutableMixIn(object): layout_id = None # to be defined in concret class layout_args = {} def layout_render(self, w, **kwargs): getlayout = self._cw.vreg['components'].select layout = getlayout(self.layout_id, self._cw, **self.layout_select_args()) layout.render(w) def layout_select_args(self): args = dict(rset=self.cw_rset, row=self.cw_row, col=self.cw_col, view=self) args.update(self.layout_args) return args class CtxComponent(LayoutableMixIn, AppObject): """base class for contextual components. The following contexts are predefined: * boxes: 'left', 'incontext', 'right' * section: 'navcontenttop', 'navcontentbottom', 'navtop', 'navbottom' * other: 'ctxtoolbar' The 'incontext', 'navcontenttop', 'navcontentbottom' and 'ctxtoolbar' contexts are handled by the default primary view, others by the default main template. All subclasses may not support all those contexts (for instance if it can't be displayed as box, or as a toolbar icon). You may restrict allowed context as follows: .. sourcecode:: python class MyComponent(CtxComponent): cw_property_defs = override_ctx(CtxComponent, vocabulary=[list of contexts]) context = 'my default context' You can configure a component's default context by simply giving an appropriate value to the `context` class attribute, as seen above. """ __registry__ = 'ctxcomponents' __select__ = ~no_cnx() categories_in_order = () cw_property_defs = { _('visible'): dict(type='Boolean', default=True, help=_('display the box or not')), _('order'): dict(type='Int', default=99, help=_('display order of the box')), _('context'): dict(type='String', default='left', vocabulary=(_('left'), _('incontext'), _('right'), _('navtop'), _('navbottom'), _('navcontenttop'), _('navcontentbottom'), _('ctxtoolbar')), help=_('context where this component should be displayed')), } visible = True order = 0 context = 'left' contextual = False title = None layout_id = 'component_layout' def render(self, w, **kwargs): self.layout_render(w, **kwargs) def layout_select_args(self): args = super(CtxComponent, self).layout_select_args() try: # XXX ensure context is given when the component is reloaded through # ajax args['context'] = self.cw_extra_kwargs['context'] except KeyError: args['context'] = self.cw_propval('context') return args def init_rendering(self): """init rendering callback: that's the good time to check your component has some content to display. If not, you can still raise :exc:`EmptyComponent` to inform it should be skipped. Also, :exc:`Unauthorized` will be caught, logged, then the component will be skipped. """ self.items = [] @property def domid(self): """return the HTML DOM identifier for this component""" return domid(self.__regid__) @property def cssclass(self): """return the CSS class name for this component""" return domid(self.__regid__) def render_title(self, w): """return the title for this component""" if self.title: w(self._cw._(self.title)) def render_body(self, w): """return the body (content) for this component""" raise NotImplementedError() def render_items(self, w, items=None, klass=u'boxListing'): if items is None: items = self.items assert items w(u'<ul class="%s">' % klass) for item in items: _bwcompatible_render_item(w, item) w(u'</ul>') def append(self, item): self.items.append(item) def action_link(self, action): return self.link(self._cw._(action.title), action.url()) def link(self, title, url, **kwargs): if self._cw.selected(url): try: kwargs['klass'] += ' selected' except KeyError: kwargs['klass'] = 'selected' return Link(url, title, **kwargs) def separator(self): return Separator() class EntityCtxComponent(CtxComponent): """base class for boxes related to a single entity""" __select__ = CtxComponent.__select__ & non_final_entity() & one_line_rset() context = 'incontext' contextual = True def __init__(self, *args, **kwargs): super(EntityCtxComponent, self).__init__(*args, **kwargs) try: entity = kwargs['entity'] except KeyError: entity = self.cw_rset.get_entity(self.cw_row or 0, self.cw_col or 0) self.entity = entity def layout_select_args(self): args = super(EntityCtxComponent, self).layout_select_args() args['entity'] = self.entity return args @property def domid(self): return domid(self.__regid__) + unicode(self.entity.eid) def lazy_view_holder(self, w, entity, oid, registry='views'): """add a holder and return a URL that may be used to replace this holder by the html generate by the view specified by registry and identifier. Registry defaults to 'views'. """ holderid = '%sHolder' % self.domid w(u'<div id="%s"></div>' % holderid) params = self.cw_extra_kwargs.copy() params.pop('view', None) params.pop('entity', None) form = params.pop('formparams', {}) if entity.has_eid(): eid = entity.eid else: eid = None form['etype'] = entity.cw_etype form['tempEid'] = entity.eid args = [json_dumps(x) for x in (registry, oid, eid, params)] return self._cw.ajax_replace_url( holderid, fname='render', arg=args, **form) # high level abstract classes ################################################## class RQLCtxComponent(CtxComponent): """abstract box for boxes displaying the content of a rql query not related to the current result set. Notice that this class's init_rendering implemention is overwriting context result set (eg `cw_rset`) with the result set returned by execution of `to_display_rql()`. """ rql = None def to_display_rql(self): """return arguments to give to self._cw.execute, as a tuple, to build the result set to be displayed by this box. """ assert self.rql is not None, self.__regid__ return (self.rql,) def init_rendering(self): super(RQLCtxComponent, self).init_rendering() self.cw_rset = self._cw.execute(*self.to_display_rql()) if not self.cw_rset: raise EmptyComponent() def render_body(self, w): rset = self.cw_rset if len(rset[0]) == 2: items = [] for i, (eid, label) in enumerate(rset): entity = rset.get_entity(i, 0) items.append(self.link(label, entity.absolute_url())) else: items = [self.link(e.dc_title(), e.absolute_url()) for e in rset.entities()] self.render_items(w, items) class EditRelationMixIn(ReloadableMixIn): def box_item(self, entity, etarget, fname, label): """builds HTML link to edit relation between `entity` and `etarget`""" args = {role(self) : entity.eid, target(self): etarget.eid} # for each target, provide a link to edit the relation jscall = js.cw.utils.callAjaxFuncThenReload(fname, self.rtype, args['subject'], args['object']) return u'[<a href="javascript: %s" class="action">%s</a>] %s' % ( xml_escape(unicode(jscall)), label, etarget.view('incontext')) def related_boxitems(self, entity): return [self.box_item(entity, etarget, 'delete_relation', u'-') for etarget in self.related_entities(entity)] def related_entities(self, entity): return entity.related(self.rtype, role(self), entities=True) def unrelated_boxitems(self, entity): return [self.box_item(entity, etarget, 'add_relation', u'+') for etarget in self.unrelated_entities(entity)] def unrelated_entities(self, entity): """returns the list of unrelated entities, using the entity's appropriate vocabulary function """ skip = set(unicode(e.eid) for e in entity.related(self.rtype, role(self), entities=True)) skip.add(None) skip.add(INTERNAL_FIELD_VALUE) filteretype = getattr(self, 'etype', None) entities = [] form = self._cw.vreg['forms'].select('edition', self._cw, rset=self.cw_rset, row=self.cw_row or 0) field = form.field_by_name(self.rtype, role(self), entity.e_schema) for _, eid in field.vocabulary(form): if eid not in skip: entity = self._cw.entity_from_eid(eid) if filteretype is None or entity.cw_etype == filteretype: entities.append(entity) return entities # XXX should be a view usable using uicfg class EditRelationCtxComponent(EditRelationMixIn, EntityCtxComponent): """base class for boxes which let add or remove entities linked by a given relation subclasses should define at least id, rtype and target class attributes. """ # to be defined in concrete classes rtype = None def render_title(self, w): w(display_name(self._cw, self.rtype, role(self), context=self.entity.cw_etype)) def render_body(self, w): self._cw.add_js('cubicweb.ajax.js') related = self.related_boxitems(self.entity) unrelated = self.unrelated_boxitems(self.entity) self.items.extend(related) if related and unrelated: self.items.append(u'<hr class="boxSeparator"/>') self.items.extend(unrelated) self.render_items(w) class AjaxEditRelationCtxComponent(EntityCtxComponent): __select__ = EntityCtxComponent.__select__ & ( partial_relation_possible(action='add') | partial_has_related_entities()) # view used to display related entties item_vid = 'incontext' # values separator when multiple values are allowed separator = ',' # msgid of the message to display when some new relation has been added/removed added_msg = None removed_msg = None # to be defined in concrete classes rtype = role = target_etype = None # class attributes below *must* be set in concrete classes (additionaly to # rtype / role [/ target_etype]. They should correspond to js_* methods on # the json controller # function(eid) # -> expected to return a list of values to display as input selector # vocabulary fname_vocabulary = None # function(eid, value) # -> handle the selector's input (eg create necessary entities and/or # relations). If the relation is multiple, you'll get a list of value, else # a single string value. fname_validate = None # function(eid, linked entity eid) # -> remove the relation fname_remove = None def __init__(self, *args, **kwargs): super(AjaxEditRelationCtxComponent, self).__init__(*args, **kwargs) self.rdef = self.entity.e_schema.rdef(self.rtype, self.role, self.target_etype) def render_title(self, w): w(self.rdef.rtype.display_name(self._cw, self.role, context=self.entity.cw_etype)) def add_js_css(self): self._cw.add_js(('jquery.ui.js', 'cubicweb.widgets.js')) self._cw.add_js(('cubicweb.ajax.js', 'cubicweb.ajax.box.js')) self._cw.add_css('jquery.ui.css') return True def render_body(self, w): req = self._cw entity = self.entity related = entity.related(self.rtype, self.role) if self.role == 'subject': mayadd = self.rdef.has_perm(req, 'add', fromeid=entity.eid) else: mayadd = self.rdef.has_perm(req, 'add', toeid=entity.eid) js_css_added = False if mayadd: js_css_added = self.add_js_css() _ = req._ if related: maydel = None w(u'<table class="ajaxEditRelationTable">') for rentity in related.entities(): if maydel is None: # Only check permission for the first related. if self.role == 'subject': fromeid, toeid = entity.eid, rentity.eid else: fromeid, toeid = rentity.eid, entity.eid maydel = self.rdef.has_perm( req, 'delete', fromeid=fromeid, toeid=toeid) # for each related entity, provide a link to remove the relation subview = rentity.view(self.item_vid) if maydel: if not js_css_added: js_css_added = self.add_js_css() jscall = unicode(js.ajaxBoxRemoveLinkedEntity( self.__regid__, entity.eid, rentity.eid, self.fname_remove, self.removed_msg and _(self.removed_msg))) w(u'<tr><td class="dellink">[<a href="javascript: %s">-</a>]</td>' '<td class="entity"> %s</td></tr>' % (xml_escape(jscall), subview)) else: w(u'<tr><td class="entity">%s</td></tr>' % (subview)) w(u'</table>') else: w(_('no related entity')) if mayadd: multiple = self.rdef.role_cardinality(self.role) in '*+' w(u'<table><tr><td>') jscall = unicode(js.ajaxBoxShowSelector( self.__regid__, entity.eid, self.fname_vocabulary, self.fname_validate, self.added_msg and _(self.added_msg), _(stdmsgs.BUTTON_OK[0]), _(stdmsgs.BUTTON_CANCEL[0]), multiple and self.separator)) w('<a class="button sglink" href="javascript: %s">%s</a>' % ( xml_escape(jscall), multiple and _('add_relation') or _('update_relation'))) w(u'</td><td>') w(u'<div id="%sHolder"></div>' % self.domid) w(u'</td></tr></table>') class RelatedObjectsCtxComponent(EntityCtxComponent): """a contextual component to display entities related to another""" __select__ = EntityCtxComponent.__select__ & partial_has_related_entities() context = 'navcontentbottom' rtype = None role = 'subject' vid = 'list' def render_body(self, w): rset = self.entity.related(self.rtype, role(self)) self._cw.view(self.vid, rset, w=w) # old contextual components, deprecated ######################################## class EntityVComponent(Component): """abstract base class for additinal components displayed in content headers and footer according to: * the displayed entity's type * a context (currently 'header' or 'footer') it should be configured using .accepts, .etype, .rtype, .target and .context class attributes """ __metaclass__ = class_deprecated __deprecation_warning__ = '[3.10] *VComponent classes are deprecated, use *CtxComponent instead (%(cls)s)' __registry__ = 'ctxcomponents' __select__ = one_line_rset() cw_property_defs = { _('visible'): dict(type='Boolean', default=True, help=_('display the component or not')), _('order'): dict(type='Int', default=99, help=_('display order of the component')), _('context'): dict(type='String', default='navtop', vocabulary=(_('navtop'), _('navbottom'), _('navcontenttop'), _('navcontentbottom'), _('ctxtoolbar')), help=_('context where this component should be displayed')), } context = 'navcontentbottom' def call(self, view=None): if self.cw_rset is None: self.entity_call(self.cw_extra_kwargs.pop('entity')) else: self.cell_call(0, 0, view=view) def cell_call(self, row, col, view=None): self.entity_call(self.cw_rset.get_entity(row, col), view=view) def entity_call(self, entity, view=None): raise NotImplementedError() class RelatedObjectsVComponent(EntityVComponent): """a section to display some related entities""" __select__ = EntityVComponent.__select__ & partial_has_related_entities() vid = 'list' # to be defined in concrete classes rtype = title = None def rql(self): """override this method if you want to use a custom rql query""" return None def cell_call(self, row, col, view=None): rql = self.rql() if rql is None: entity = self.cw_rset.get_entity(row, col) rset = entity.related(self.rtype, role(self)) else: eid = self.cw_rset[row][col] rset = self._cw.execute(self.rql(), {'x': eid}) if not rset.rowcount: return self.w(u'<div class="%s">' % self.cssclass) self.w(u'<h4>%s</h4>\n' % self._cw._(self.title).capitalize()) self.wview(self.vid, rset) self.w(u'</div>')