web/box.py
author Sylvain Thénault <sylvain.thenault@logilab.fr>
Wed, 25 Aug 2010 10:01:11 +0200
changeset 6140 65a619eb31c4
parent 6017 5f6a60ea8544
child 6141 b8287e54b528
permissions -rw-r--r--
[boxes] introduce new boxes system * separate box content generation from its layout * refactor css classes to allow moving boxes and still get consistent ui On the way to contentnavigation/boxes unification (in a later patch) * * * some fixes for the previous (default_new_boxes_system) patch * * * some fixes for the previous (default_new_boxes_system) patch

# copyright 2003-2010 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 box classes for CubicWeb web client"""

__docformat__ = "restructuredtext en"
_ = unicode

from logilab.mtconverter import xml_escape
from logilab.common.deprecation import class_deprecated, class_renamed

from cubicweb import Unauthorized, role as get_role, target as get_target, tags
from cubicweb.schema import display_name
from cubicweb.selectors import (no_cnx, one_line_rset,  primary_view,
                                match_context_prop, partial_relation_possible,
                                partial_has_related_entities)
from cubicweb.appobject import AppObject
from cubicweb.view import View, ReloadableMixIn, Component
from cubicweb.uilib import domid, js
from cubicweb.web import INTERNAL_FIELD_VALUE, stdmsgs
from cubicweb.web.htmlwidgets import (BoxLink, BoxWidget, SideBoxWidget,
                                      RawBoxItem, BoxSeparator)
from cubicweb.web.action import UnregisteredAction


def sort_by_category(actions, categories_in_order=None):
    """return a list of (category, actions_sorted_by_title)"""
    result = []
    actions_by_cat = {}
    for action in actions:
        actions_by_cat.setdefault(action.category, []).append(
            (action.title, action) )
    for key, values in actions_by_cat.items():
        actions_by_cat[key] = [act for title, act in sorted(values)]
    if categories_in_order:
        for cat in categories_in_order:
            if cat in actions_by_cat:
                result.append( (cat, actions_by_cat[cat]) )
    for item in sorted(actions_by_cat.items()):
        result.append(item)
    return result


class EditRelationMixIn(ReloadableMixIn):
    def box_item(self, entity, etarget, rql, label):
        """builds HTML link to edit relation between `entity` and `etarget`"""
        role, target = get_role(self), get_target(self)
        args = {role[0] : entity.eid, target[0] : etarget.eid}
        url = self._cw.user_rql_callback((rql, args))
        # for each target, provide a link to edit the relation
        return u'[<a href="%s">%s</a>] %s' % (xml_escape(url), label,
                                              etarget.view('incontext'))

    def related_boxitems(self, entity):
        rql = 'DELETE S %s O WHERE S eid %%(s)s, O eid %%(o)s' % self.rtype
        return [self.box_item(entity, etarget, rql, u'-')
                for etarget in self.related_entities(entity)]

    def related_entities(self, entity):
        return entity.related(self.rtype, get_role(self), entities=True)

    def unrelated_boxitems(self, entity):
        rql = 'SET S %s O WHERE S eid %%(s)s, O eid %%(o)s' % self.rtype
        return [self.box_item(entity, etarget, rql, 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, get_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, get_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.__regid__ == filteretype:
                    entities.append(entity)
        return entities


# generic classes for the new box system #######################################

from cubicweb.selectors import match_context, contextual

class EmptyComponent(Exception):
    """some selectable component has actually no content and should not be
    rendered
    """

class Layout(Component):
    __regid__ = 'layout'
    __abstract__ = True


class Box(AppObject): # XXX ContextComponent
    __registry__ = 'boxes'
    __select__ = ~no_cnx() & match_context_prop()

    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')),
        # XXX 'incontext' boxes are handled by the default primary view
        _('context'): dict(type='String', default='left',
                           vocabulary=(_('left'), _('incontext'), _('right')),
                           help=_('context where this box should be displayed')),
        }
    context = 'left'
    contextual = False
    title = None
    # XXX support kwargs for compat with old boxes which gets the view as
    # argument
    def render(self, w, **kwargs):
        getlayout = self._cw.vreg['components'].select
        try:
            # XXX ensure context is given when the component is reloaded through
            # ajax
            context = self.cw_extra_kwargs['context']
        except KeyError:
            context = self.cw_propval('context')
        layout = getlayout('layout', self._cw, rset=self.cw_rset,
                           row=self.cw_row, col=self.cw_col,
                           view=self, context=context)
        layout.render(w)

    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 catched, 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 is None:
            raise NotImplementedError()
        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:
            if hasattr(item, 'render'):
                item.render(w) # XXX display <li> by itself
            else:
                w(u'<li>')
                w(item)
                w(u'</li>')
        w(u'</ul>')

    def append(self, item):
        self.items.append(item)

    def box_action(self, action): # XXX action_link
        return self.build_link(self._cw._(action.title), action.url())

    def build_link(self, title, url, **kwargs):
        if self._cw.selected(url):
            try:
                kwargs['klass'] += ' selected'
            except KeyError:
                kwargs['klass'] = 'selected'
        return tags.a(title, href=url, **kwargs)


class EntityBox(Box): # XXX ContextEntityComponent
    """base class for boxes related to a single entity"""
    __select__ = Box.__select__ & one_line_rset()
    context = 'incontext'
    contextual = True

    def __init__(self, *args, **kwargs):
        super(EntityBox, 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

    @property
    def domid(self):
        return domid(self.__regid__) + unicode(self.entity.eid)


# high level abstract box classes ##############################################


class RQLBox(Box):
    """abstract box for boxes displaying the content of a rql query not
    related to the current result set.
    """
    rql  = None

    def to_display_rql(self):
        assert self.rql is not None, self.__regid__
        return (self.rql,)

    def init_rendering(self):
        rset = self._cw.execute(*self.to_display_rql())
        if not rset:
            raise EmptyComponent()
        if len(rset[0]) == 2:
            self.items = []
            for i, (eid, label) in enumerate(rset):
                entity = rset.get_entity(i, 0)
                self.items.append(self.build_link(label, entity.absolute_url()))
        else:
            self.items = [self.build_link(e.dc_title(), e.absolute_url())
                          for e in rset.entities()]

    def render_body(self, w):
        self.render_items(w)


class EditRelationBox(EditRelationMixIn, EntityBox):
    """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.
    """
    def render_title(self, w):
        return display_name(self._cw, self.rtype, get_role(self),
                            context=self.entity.__regid__)

    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(BoxSeparator())
        self.items.extend(unrelated)
        self.render_items(w)


class AjaxEditRelationBox(EntityBox):
    __select__ = EntityBox.__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

    # class attributes below *must* be set in concret 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(AjaxEditRelationBox, 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.__regid__))

    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)
            maydel = self.rdef.has_perm(req, 'delete', fromeid=entity.eid)
        else:
            mayadd = self.rdef.has_perm(req, 'add', toeid=entity.eid)
            maydel = self.rdef.has_perm(req, 'delete', toeid=entity.eid)
        if mayadd or maydel:
            req.add_js(('cubicweb.ajax.js', 'cubicweb.ajax.box.js'))
        _ = req._
        if related:
            w(u'<table>')
            for rentity in related.entities():
                # for each related entity, provide a link to remove the relation
                subview = rentity.view(self.item_vid)
                if maydel:
                    jscall = unicode(js.ajaxBoxRemoveLinkedEntity(
                        self.__regid__, entity.eid, rentity.eid,
                        self.fname_remove,
                        self.removed_msg and _(self.removed_msg)))
                    w(u'<tr><td>[<a href="javascript: %s">-</a>]</td>'
                      '<td class="tagged"> %s</td></tr>' % (xml_escape(jscall),
                                                            subview))
                else:
                    w(u'<tr><td class="tagged">%s</td></tr>' % (subview))
            w(u'</table>')
        else:
            w(_('no related entity'))
        if mayadd:
            req.add_js('jquery.autocomplete.js')
            req.add_css('jquery.autocomplete.css')
            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>')


# old box system, deprecated ###################################################

class BoxTemplate(View):
    """base template for boxes, usually a (contextual) list of possible

    actions. Various classes attributes may be used to control the box
    rendering.

    You may override on of the formatting callbacks is this is not necessary
    for your custom box.

    Classes inheriting from this class usually only have to override call
    to fetch desired actions, and then to do something like  ::

        box.render(self.w)
    """
    __metaclass__ = class_deprecated
    __deprecation_warning__ = '*BoxTemplate classes are deprecated, use *Box instead'

    __registry__ = 'boxes'
    __select__ = ~no_cnx() & match_context_prop()

    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')),
        # XXX 'incontext' boxes are handled by the default primary view
        _('context'): dict(type='String', default='left',
                           vocabulary=(_('left'), _('incontext'), _('right')),
                           help=_('context where this box should be displayed')),
        }
    context = 'left'

    def sort_actions(self, actions):
        """return a list of (category, actions_sorted_by_title)"""
        return sort_by_category(actions, self.categories_in_order)

    def mk_action(self, title, url, escape=True, **kwargs):
        """factory function to create dummy actions compatible with the
        .format_actions method
        """
        if escape:
            title = xml_escape(title)
        return self.box_action(self._action(title, url, **kwargs))

    def _action(self, title, url, **kwargs):
        return UnregisteredAction(self._cw, title, url, **kwargs)

    # formating callbacks

    def boxitem_link_tooltip(self, action):
        if action.__regid__:
            return u'keyword: %s' % action.__regid__
        return u''

    def box_action(self, action):
        klass = getattr(action, 'html_class', lambda: None)()
        return BoxLink(action.url(), self._cw._(action.title),
                       klass, self.boxitem_link_tooltip(action))


class RQLBoxTemplate(BoxTemplate):
    """abstract box for boxes displaying the content of a rql query not
    related to the current result set.
    """

    rql  = None

    def to_display_rql(self):
        assert self.rql is not None, self.__regid__
        return (self.rql,)

    def call(self, **kwargs):
        try:
            rset = self._cw.execute(*self.to_display_rql())
        except Unauthorized:
            # can't access to something in the query, forget this box
            return
        if len(rset) == 0:
            return
        box = BoxWidget(self._cw._(self.title), self.__regid__)
        for i, (teid, tname) in enumerate(rset):
            entity = rset.get_entity(i, 0)
            box.append(self.mk_action(tname, entity.absolute_url()))
        box.render(w=self.w)


class UserRQLBoxTemplate(RQLBoxTemplate):
    """same as rql box template but the rql is build using the eid of the
    request's user
    """

    def to_display_rql(self):
        assert self.rql is not None, self.__regid__
        return (self.rql, {'x': self._cw.user.eid})


class EntityBoxTemplate(BoxTemplate):
    """base class for boxes related to a single entity"""
    __select__ = BoxTemplate.__select__ & one_line_rset() & primary_view()
    context = 'incontext'

    def call(self, row=0, col=0, **kwargs):
        """classes inheriting from EntityBoxTemplate should define cell_call"""
        self.cell_call(row, col, **kwargs)


class EditRelationBoxTemplate(EditRelationMixIn, EntityBoxTemplate):
    """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.
    """

    def cell_call(self, row, col, view=None, **kwargs):
        self._cw.add_js('cubicweb.ajax.js')
        entity = self.cw_rset.get_entity(row, col)
        title = display_name(self._cw, self.rtype, get_role(self),
                             context=entity.__regid__)
        box = SideBoxWidget(title, self.__regid__)
        related = self.related_boxitems(entity)
        unrelated = self.unrelated_boxitems(entity)
        box.extend(related)
        if related and unrelated:
            box.append(BoxSeparator())
        box.extend(unrelated)
        box.render(self.w)

    def box_item(self, entity, etarget, rql, label):
        label = super(EditRelationBoxTemplate, self).box_item(
            entity, etarget, rql, label)
        return RawBoxItem(label, liclass=u'invisible')


AjaxEditRelationBoxTemplate = class_renamed(
    'AjaxEditRelationBoxTemplate', AjaxEditRelationBox,
    '[3.10] AjaxEditRelationBoxTemplate has been renamed to AjaxEditRelationBox')