cubicweb/web/views/tableview.py
changeset 11057 0b59724cb3f2
parent 10907 9ae707db5265
child 11767 432f87a63057
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/views/tableview.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,1338 @@
+# copyright 2003-2012 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/>.
+"""This module contains table views, with the following features that may be
+provided (depending on the used implementation):
+
+* facets filtering
+* pagination
+* actions menu
+* properly sortable content
+* odd/row/hover line styles
+
+The three main implementation are described below. Each implementation is
+suitable for a particular case, but they each attempt to display tables that
+looks similar.
+
+.. autoclass:: cubicweb.web.views.tableview.RsetTableView
+   :members:
+
+.. autoclass:: cubicweb.web.views.tableview.EntityTableView
+   :members:
+
+.. autoclass:: cubicweb.web.views.pyviews.PyValTableView
+   :members:
+
+All those classes are rendered using a *layout*:
+
+.. autoclass:: cubicweb.web.views.tableview.TableLayout
+   :members:
+
+There is by default only one table layout, using the 'table_layout' identifier,
+that is referenced by table views
+:attr:`cubicweb.web.views.tableview.TableMixIn.layout_id`.  If you want to
+customize the look and feel of your table, you can either replace the default
+one by yours, having multiple variants with proper selectors, or change the
+`layout_id` identifier of your table to use your table specific implementation.
+
+Notice you can gives options to the layout using a `layout_args` dictionary on
+your class.
+
+If you still can't find a view that suit your needs, you should take a look at the
+class below that is the common abstract base class for the three views defined
+above and implement your own class.
+
+.. autoclass:: cubicweb.web.views.tableview.TableMixIn
+   :members:
+"""
+
+__docformat__ = "restructuredtext en"
+from cubicweb import _
+
+from warnings import warn
+from copy import copy
+from types import MethodType
+
+from six import string_types, add_metaclass, create_bound_method
+from six.moves import range
+
+from logilab.mtconverter import xml_escape
+from logilab.common.decorators import cachedproperty
+from logilab.common.deprecation import class_deprecated
+from logilab.common.registry import yes
+
+from cubicweb import NoSelectableObject, tags
+from cubicweb.predicates import nonempty_rset, match_kwargs, objectify_predicate
+from cubicweb.schema import display_name
+from cubicweb.utils import make_uid, js_dumps, JSString, UStringIO
+from cubicweb.uilib import toggle_action, limitsize, htmlescape, sgml_attributes, domid
+from cubicweb.view import EntityView, AnyRsetView
+from cubicweb.web import jsonize, component
+from cubicweb.web.htmlwidgets import (TableWidget, TableColumn, MenuWidget,
+                                      PopupBoxMenu)
+
+
+@objectify_predicate
+def unreloadable_table(cls, req, rset=None,
+                       displaycols=None, headers=None, cellvids=None,
+                       paginate=False, displayactions=False, displayfilter=False,
+                       **kwargs):
+    # one may wish to specify one of headers/displaycols/cellvids as long as he
+    # doesn't want pagination nor actions nor facets
+    if not kwargs and (displaycols or headers or cellvids) and not (
+        displayfilter or displayactions or paginate):
+        return 1
+    return 0
+
+
+class TableLayout(component.Component):
+    """The default layout for table. When `render` is called, this will use
+    the API described on :class:`TableMixIn` to feed the generated table.
+
+    This layout behaviour may be customized using the following attributes /
+    selection arguments:
+
+    * `cssclass`, a string that should be used as HTML class attribute. Default
+      to "listing".
+
+    * `needs_css`, the CSS files that should be used together with this
+      table. Default to ('cubicweb.tablesorter.css', 'cubicweb.tableview.css').
+
+    * `needs_js`, the Javascript files that should be used together with this
+      table. Default to ('jquery.tablesorter.js',)
+
+    * `display_filter`, tells if the facets filter should be displayed when
+      possible. Allowed values are:
+      - `None`, don't display it
+      - 'top', display it above the table
+      - 'bottom', display it below the table
+
+    * `display_actions`, tells if a menu for available actions should be
+      displayed when possible (see two following options). Allowed values are:
+      - `None`, don't display it
+      - 'top', display it above the table
+      - 'bottom', display it below the table
+
+    * `hide_filter`, when true (the default), facets filter will be hidden by
+      default, with an action in the actions menu allowing to show / hide it.
+
+    * `show_all_option`, when true, a *show all results* link will be displayed
+      below the navigation component.
+
+    * `add_view_actions`, when true, actions returned by view.table_actions()
+      will be included in the actions menu.
+
+    * `header_column_idx`, if not `None`, should be a colum index or a set of
+      column index where <th> tags should be generated instead of <td>
+    """ #'# make emacs happier
+    __regid__ = 'table_layout'
+    cssclass = "listing"
+    needs_css = ('cubicweb.tableview.css',)
+    needs_js = ()
+    display_filter = None    # None / 'top' / 'bottom'
+    display_actions = 'top'  # None / 'top' / 'bottom'
+    hide_filter = True
+    show_all_option = True   # make navcomp generate a 'show all' results link
+    add_view_actions = False
+    header_column_idx = None
+    enable_sorting = True
+    sortvalue_limit = 10
+    tablesorter_settings = {
+        'textExtraction': JSString('cw.sortValueExtraction'),
+        'selectorHeaders': "thead tr:first th[class='sortable']", # only plug on the first row
+        }
+
+    def _setup_tablesorter(self, divid):
+        self._cw.add_css('cubicweb.tablesorter.css')
+        self._cw.add_js('jquery.tablesorter.js')
+        self._cw.add_onload('''$(document).ready(function() {
+    $("#%s table").tablesorter(%s);
+});''' % (divid, js_dumps(self.tablesorter_settings)))
+
+    def __init__(self, req, view, **kwargs):
+        super(TableLayout, self).__init__(req, **kwargs)
+        for key, val in list(self.cw_extra_kwargs.items()):
+            if hasattr(self.__class__, key) and not key[0] == '_':
+                setattr(self, key, val)
+                self.cw_extra_kwargs.pop(key)
+        self.view = view
+        if self.header_column_idx is None:
+            self.header_column_idx = frozenset()
+        elif isinstance(self.header_column_idx, int):
+            self.header_column_idx = frozenset( (self.header_column_idx,) )
+
+    @cachedproperty
+    def initial_load(self):
+        """We detect a bit heuristically if we are built for the first time or
+        from subsequent calls by the form filter or by the pagination hooks.
+        """
+        form = self._cw.form
+        return 'fromformfilter' not in form and '__fromnavigation' not in form
+
+    def render(self, w, **kwargs):
+        assert self.display_filter in (None, 'top', 'bottom'), self.display_filter
+        if self.needs_css:
+            self._cw.add_css(self.needs_css)
+        if self.needs_js:
+            self._cw.add_js(self.needs_js)
+        if self.enable_sorting:
+            self._setup_tablesorter(self.view.domid)
+        # Notice facets form must be rendered **outside** the main div as it
+        # shouldn't be rendered on ajax call subsequent to facet restriction
+        # (hence the 'fromformfilter' parameter added by the form
+        generate_form = self.initial_load
+        if self.display_filter and generate_form:
+            facetsform = self.view.facets_form()
+        else:
+            facetsform = None
+        if facetsform and self.display_filter == 'top':
+            cssclass = u'hidden' if self.hide_filter else u''
+            facetsform.render(w, vid=self.view.__regid__, cssclass=cssclass,
+                              divid=self.view.domid)
+        actions = []
+        if self.display_actions:
+            if self.add_view_actions:
+                actions = self.view.table_actions()
+            if self.display_filter and self.hide_filter and (facetsform or not generate_form):
+                actions += self.show_hide_filter_actions(not generate_form)
+        self.render_table(w, actions, self.view.paginable)
+        if facetsform and self.display_filter == 'bottom':
+            cssclass = u'hidden' if self.hide_filter else u''
+            facetsform.render(w, vid=self.view.__regid__, cssclass=cssclass,
+                              divid=self.view.domid)
+
+    def render_table_headers(self, w, colrenderers):
+        w(u'<thead><tr>')
+        for colrenderer in colrenderers:
+            if colrenderer.sortable:
+                w(u'<th class="sortable">')
+            else:
+                w(u'<th>')
+            colrenderer.render_header(w)
+            w(u'</th>')
+        w(u'</tr></thead>\n')
+
+    def render_table_body(self, w, colrenderers):
+        w(u'<tbody>')
+        for rownum in range(self.view.table_size):
+            self.render_row(w, rownum, colrenderers)
+        w(u'</tbody>')
+
+    def render_table(self, w, actions, paginate):
+        view = self.view
+        divid = view.domid
+        if divid is not None:
+            w(u'<div id="%s">' % divid)
+        else:
+            assert not (actions or paginate)
+        nav_html = UStringIO()
+        if paginate:
+            view.paginate(w=nav_html.write, show_all_option=self.show_all_option)
+        w(nav_html.getvalue())
+        if actions and self.display_actions == 'top':
+            self.render_actions(w, actions)
+        colrenderers = view.build_column_renderers()
+        attrs = self.table_attributes()
+        w(u'<table %s>' % sgml_attributes(attrs))
+        if self.view.has_headers:
+            self.render_table_headers(w, colrenderers)
+        self.render_table_body(w, colrenderers)
+        w(u'</table>')
+        if actions and self.display_actions == 'bottom':
+            self.render_actions(w, actions)
+        w(nav_html.getvalue())
+        if divid is not None:
+            w(u'</div>')
+
+    def table_attributes(self):
+        return {'class': self.cssclass}
+
+    def render_row(self, w, rownum, renderers):
+        attrs = self.row_attributes(rownum)
+        w(u'<tr %s>' % sgml_attributes(attrs))
+        for colnum, renderer in enumerate(renderers):
+            self.render_cell(w, rownum, colnum, renderer)
+        w(u'</tr>\n')
+
+    def row_attributes(self, rownum):
+        return {'class': 'odd' if (rownum%2==1) else 'even',
+                'onmouseover': '$(this).addClass("highlighted");',
+                'onmouseout': '$(this).removeClass("highlighted")'}
+
+    def render_cell(self, w, rownum, colnum, renderer):
+        attrs = self.cell_attributes(rownum, colnum, renderer)
+        if colnum in self.header_column_idx:
+            tag = u'th'
+        else:
+            tag = u'td'
+        w(u'<%s %s>' % (tag, sgml_attributes(attrs)))
+        renderer.render_cell(w, rownum)
+        w(u'</%s>' % tag)
+
+    def cell_attributes(self, rownum, _colnum, renderer):
+        attrs = renderer.attributes.copy()
+        if renderer.sortable:
+            sortvalue = renderer.sortvalue(rownum)
+            if isinstance(sortvalue, string_types):
+                sortvalue = sortvalue[:self.sortvalue_limit]
+            if sortvalue is not None:
+                attrs[u'cubicweb:sortvalue'] = js_dumps(sortvalue)
+        return attrs
+
+    def render_actions(self, w, actions):
+        box = MenuWidget('', '', _class='tableActionsBox', islist=False)
+        label = tags.span(self._cw._('action menu'))
+        menu = PopupBoxMenu(label, isitem=False, link_class='actionsBox',
+                            ident='%sActions' % self.view.domid)
+        box.append(menu)
+        for action in actions:
+            menu.append(action)
+        box.render(w=w)
+        w(u'<div class="clear"></div>')
+
+    def show_hide_filter_actions(self, currentlydisplayed=False):
+        divid = self.view.domid
+        showhide = u';'.join(toggle_action('%s%s' % (divid, what))[11:]
+                             for what in ('Form', 'Show', 'Hide', 'Actions'))
+        showhide = 'javascript:' + showhide
+        self._cw.add_onload(u'''\
+$(document).ready(function() {
+  if ($('#%(id)sForm[class=\"hidden\"]').length) {
+    $('#%(id)sHide').attr('class', 'hidden');
+  } else {
+    $('#%(id)sShow').attr('class', 'hidden');
+  }
+});''' % {'id': divid})
+        showlabel = self._cw._('show filter form')
+        hidelabel = self._cw._('hide filter form')
+        return [component.Link(showhide, showlabel, id='%sShow' % divid),
+                component.Link(showhide, hidelabel, id='%sHide' % divid)]
+
+
+class AbstractColumnRenderer(object):
+    """Abstract base class for column renderer. Interface of a column renderer follows:
+
+    .. automethod:: cubicweb.web.views.tableview.AbstractColumnRenderer.bind
+    .. automethod:: cubicweb.web.views.tableview.AbstractColumnRenderer.render_header
+    .. automethod:: cubicweb.web.views.tableview.AbstractColumnRenderer.render_cell
+    .. automethod:: cubicweb.web.views.tableview.AbstractColumnRenderer.sortvalue
+
+    Attributes on this base class are:
+
+    :attr: `header`, the column header. If None, default to `_(colid)`
+    :attr: `addcount`, if True, add the table size in parenthezis beside the header
+    :attr: `trheader`, should the header be translated
+    :attr: `escapeheader`, should the header be xml_escaped
+    :attr: `sortable`, tell if the column is sortable
+    :attr: `view`, the table view
+    :attr: `_cw`, the request object
+    :attr: `colid`, the column identifier
+    :attr: `attributes`, dictionary of attributes to put on the HTML tag when
+            the cell is rendered
+    """ #'# make emacs
+    attributes = {}
+    empty_cell_content = u'&#160;'
+
+    def __init__(self, header=None, addcount=False, trheader=True,
+                 escapeheader=True, sortable=True):
+        self.header = header
+        self.trheader = trheader
+        self.escapeheader = escapeheader
+        self.addcount = addcount
+        self.sortable = sortable
+        self.view = None
+        self._cw = None
+        self.colid = None
+
+    def __str__(self):
+        return '<%s.%s (column %s) at 0x%x>' % (self.view.__class__.__name__,
+                                        self.__class__.__name__,
+                                        self.colid, id(self))
+
+    def bind(self, view, colid):
+        """Bind the column renderer to its view. This is where `_cw`, `view`,
+        `colid` are set and the method to override if you want to add more
+        view/request depending attributes on your column render.
+        """
+        self.view = view
+        self._cw = view._cw
+        self.colid = colid
+
+    def copy(self):
+        assert self.view is None
+        return copy(self)
+
+    def default_header(self):
+        """Return header for this column if one has not been specified."""
+        return self._cw._(self.colid)
+
+    def render_header(self, w):
+        """Write label for the specified column by calling w()."""
+        header = self.header
+        if header is None:
+            header = self.default_header()
+        elif self.trheader and header:
+           header = self._cw._(header)
+        if self.addcount:
+            header = '%s (%s)' % (header, self.view.table_size)
+        if header:
+            if self.escapeheader:
+                header = xml_escape(header)
+        else:
+            header = self.empty_cell_content
+        if self.sortable:
+            header = tags.span(
+                header, escapecontent=False,
+                title=self._cw._('Click to sort on this column'))
+        w(header)
+
+    def render_cell(self, w, rownum):
+        """Write value for the specified cell by calling w().
+
+         :param `rownum`: the row number in the table
+         """
+        raise NotImplementedError()
+
+    def sortvalue(self, _rownum):
+        """Return typed value to be used for sorting on the specified column.
+
+        :param `rownum`: the row number in the table
+        """
+        return None
+
+
+class TableMixIn(component.LayoutableMixIn):
+    """Abstract mix-in class for layout based tables.
+
+    This default implementation's call method simply delegate to
+    meth:`layout_render` that will select the renderer whose identifier is given
+    by the :attr:`layout_id` attribute.
+
+    Then it provides some default implementation for various parts of the API
+    used by that layout.
+
+    Abstract method you will have to override is:
+
+    .. automethod:: build_column_renderers
+
+    You may also want to overridde:
+
+    .. autoattribute:: cubicweb.web.views.tableview.TableMixIn.table_size
+
+    The :attr:`has_headers` boolean attribute tells if the table has some
+    headers to be displayed. Default to `True`.
+    """
+    __abstract__ = True
+    # table layout to use
+    layout_id = 'table_layout'
+    # true if the table has some headers
+    has_headers = True
+    # dictionary {colid : column renderer}
+    column_renderers = {}
+    # default renderer class to use when no renderer specified for the column
+    default_column_renderer_class = None
+    # default layout handles inner pagination
+    handle_pagination = True
+
+    def call(self, **kwargs):
+        self._cw.add_js('cubicweb.ajax.js') # for pagination
+        self.layout_render(self.w)
+
+    def column_renderer(self, colid, *args, **kwargs):
+        """Return a column renderer for column of the given id."""
+        try:
+            crenderer = self.column_renderers[colid].copy()
+        except KeyError:
+            crenderer = self.default_column_renderer_class(*args, **kwargs)
+        crenderer.bind(self, colid)
+        return crenderer
+
+    # layout callbacks #########################################################
+
+    def facets_form(self, **kwargs):# XXX extracted from jqplot cube
+        return self._cw.vreg['views'].select_or_none(
+            'facet.filtertable', self._cw, rset=self.cw_rset, view=self,
+            **kwargs)
+
+    @cachedproperty
+    def domid(self):
+        return self._cw.form.get('divid') or domid('%s-%s' % (self.__regid__, make_uid()))
+
+    @property
+    def table_size(self):
+        """Return the number of rows (header excluded) to be displayed.
+
+        By default return the number of rows in the view's result set. If your
+        table isn't reult set based, override this method.
+        """
+        return self.cw_rset.rowcount
+
+    def build_column_renderers(self):
+        """Return a list of column renderers, one for each column to be
+        rendered. Prototype of a column renderer is described below:
+
+        .. autoclass:: cubicweb.web.views.tableview.AbstractColumnRenderer
+        """
+        raise NotImplementedError()
+
+    def table_actions(self):
+        """Return a list of actions (:class:`~cubicweb.web.component.Link`) that
+        match the view's result set, and return those in the 'mainactions'
+        category.
+        """
+        req = self._cw
+        actions = []
+        actionsbycat = req.vreg['actions'].possible_actions(req, self.cw_rset)
+        for action in actionsbycat.get('mainactions', ()):
+            for action in action.actual_actions():
+                actions.append(component.Link(action.url(), req._(action.title),
+                                              klass=action.html_class()) )
+        return actions
+
+    # interaction with navigation component ####################################
+
+    def page_navigation_url(self, navcomp, _path, params):
+        params['divid'] = self.domid
+        params['vid'] = self.__regid__
+        return navcomp.ajax_page_url(**params)
+
+
+class RsetTableColRenderer(AbstractColumnRenderer):
+    """Default renderer for :class:`RsetTableView`."""
+
+    def __init__(self, cellvid, **kwargs):
+        super(RsetTableColRenderer, self).__init__(**kwargs)
+        self.cellvid = cellvid
+
+    def bind(self, view, colid):
+        super(RsetTableColRenderer, self).bind(view, colid)
+        self.cw_rset = view.cw_rset
+    def render_cell(self, w, rownum):
+        self._cw.view(self.cellvid, self.cw_rset, 'empty-cell',
+                      row=rownum, col=self.colid, w=w)
+
+    # limit value's length as much as possible (e.g. by returning the 10 first
+    # characters of a string)
+    def sortvalue(self, rownum):
+        colid = self.colid
+        val = self.cw_rset[rownum][colid]
+        if val is None:
+            return u''
+        etype = self.cw_rset.description[rownum][colid]
+        if etype is None:
+            return u''
+        if self._cw.vreg.schema.eschema(etype).final:
+            entity, rtype = self.cw_rset.related_entity(rownum, colid)
+            if entity is None:
+                return val # remove_html_tags() ?
+            return entity.sortvalue(rtype)
+        entity = self.cw_rset.get_entity(rownum, colid)
+        return entity.sortvalue()
+
+
+class RsetTableView(TableMixIn, AnyRsetView):
+    """This table view accepts any non-empty rset. It uses introspection on the
+    result set to compute column names and the proper way to display the cells.
+
+    It is highly configurable and accepts a wealth of options, but take care to
+    check what you're trying to achieve wouldn't be a job for the
+    :class:`EntityTableView`. Basically the question is: does this view should
+    be tied to the result set query's shape or no? If yes, than you're fine. If
+    no, you should take a look at the other table implementation.
+
+    The following class attributes may be used to control the table:
+
+    * `finalvid`, a view identifier that should be called on final entities
+      (e.g. attribute values). Default to 'final'.
+
+    * `nonfinalvid`, a view identifier that should be called on
+      entities. Default to 'incontext'.
+
+    * `displaycols`, if not `None`, should be a list of rset's columns to be
+      displayed.
+
+    * `headers`, if not `None`, should be a list of headers for the table's
+      columns.  `None` values in the list will be replaced by computed column
+      names.
+
+    * `cellvids`, if not `None`, should be a dictionary with table column index
+      as key and a view identifier as value, telling the view that should be
+      used in the given column.
+
+    Notice `displaycols`, `headers` and `cellvids` may be specified at selection
+    time but then the table won't have pagination and shouldn't be configured to
+    display the facets filter nor actions (as they wouldn't behave as expected).
+
+    This table class use the :class:`RsetTableColRenderer` as default column
+    renderer.
+
+    .. autoclass:: RsetTableColRenderer
+    """    #'# make emacs happier
+    __regid__ = 'table'
+    # selector trick for bw compath with the former :class:TableView
+    __select__ = AnyRsetView.__select__ & (~match_kwargs(
+        'title', 'subvid', 'displayfilter', 'headers', 'displaycols',
+        'displayactions', 'actions', 'divid', 'cellvids', 'cellattrs',
+        'mainindex', 'paginate', 'page_size', mode='any')
+                                            | unreloadable_table())
+    title = _('table')
+    # additional configuration parameters
+    finalvid = 'final'
+    nonfinalvid = 'incontext'
+    displaycols = None
+    headers = None
+    cellvids = None
+    default_column_renderer_class = RsetTableColRenderer
+
+    def linkable(self):
+        # specific subclasses of this view usually don't want to be linkable
+        # since they depends on a particular shape (being linkable meaning view
+        # may be listed in possible views
+        return self.__regid__ == 'table'
+
+    def call(self, headers=None, displaycols=None, cellvids=None,
+             paginate=None, **kwargs):
+        if self.headers:
+            self.headers = [h and self._cw._(h) for h in self.headers]
+        if (headers or displaycols or cellvids or paginate):
+            if headers is not None:
+                self.headers = headers
+            if displaycols is not None:
+                self.displaycols = displaycols
+            if cellvids is not None:
+                self.cellvids = cellvids
+            if paginate is not None:
+                self.paginable = paginate
+        if kwargs:
+            # old table view arguments that we can safely ignore thanks to
+            # selectors
+            if len(kwargs) > 1:
+                msg = '[3.14] %s arguments are deprecated' % ', '.join(kwargs)
+            else:
+                msg = '[3.14] %s argument is deprecated' % ', '.join(kwargs)
+            warn(msg, DeprecationWarning, stacklevel=2)
+        super(RsetTableView, self).call(**kwargs)
+
+    def main_var_index(self):
+        """returns the index of the first non-attribute variable among the RQL
+        selected variables
+        """
+        eschema = self._cw.vreg.schema.eschema
+        for i, etype in enumerate(self.cw_rset.description[0]):
+            if not eschema(etype).final:
+                return i
+        return None
+
+    # layout callbacks #########################################################
+
+    @property
+    def table_size(self):
+        """return the number of rows (header excluded) to be displayed"""
+        return self.cw_rset.rowcount
+
+    def build_column_renderers(self):
+        headers = self.headers
+        # compute displayed columns
+        if self.displaycols is None:
+            if headers is not None:
+                displaycols = list(range(len(headers)))
+            else:
+                rqlst = self.cw_rset.syntax_tree()
+                displaycols = list(range(len(rqlst.children[0].selection)))
+        else:
+            displaycols = self.displaycols
+        # compute table headers
+        main_var_index = self.main_var_index()
+        computed_titles = self.columns_labels(main_var_index)
+        # compute build renderers
+        cellvids = self.cellvids
+        renderers = []
+        for colnum, colid in enumerate(displaycols):
+            addcount = False
+            # compute column header
+            title = None
+            if headers is not None:
+                title = headers[colnum]
+            if title is None:
+                title = computed_titles[colid]
+            if colid == main_var_index:
+                addcount = True
+            # compute cell vid for the column
+            if cellvids is not None and colnum in cellvids:
+                cellvid = cellvids[colnum]
+            else:
+                coltype = self.cw_rset.description[0][colid]
+                if coltype is not None and self._cw.vreg.schema.eschema(coltype).final:
+                    cellvid = self.finalvid
+                else:
+                    cellvid = self.nonfinalvid
+            # get renderer
+            renderer = self.column_renderer(colid, header=title, trheader=False,
+                                            addcount=addcount, cellvid=cellvid)
+            renderers.append(renderer)
+        return renderers
+
+
+class EntityTableColRenderer(AbstractColumnRenderer):
+    """Default column renderer for :class:`EntityTableView`.
+
+    You may use the :meth:`entity` method to retrieve the main entity for a
+    given row number.
+
+    .. automethod:: cubicweb.web.views.tableview.EntityTableColRenderer.entity
+    .. automethod:: cubicweb.web.views.tableview.EntityTableColRenderer.render_entity
+    .. automethod:: cubicweb.web.views.tableview.EntityTableColRenderer.entity_sortvalue
+    """
+    def __init__(self, renderfunc=None, sortfunc=None, sortable=None, **kwargs):
+        if renderfunc is None:
+            renderfunc = self.render_entity
+            # if renderfunc nor sortfunc nor sortable specified, column will be
+            # sortable using the default implementation.
+            if sortable is None:
+                sortable = True
+        # no sortfunc given but asked to be sortable: use the default sort
+        # method. Sub-class may set `entity_sortvalue` to None if they don't
+        # support sorting.
+        if sortfunc is None and sortable:
+            sortfunc = self.entity_sortvalue
+        # at this point `sortable` may still be unspecified while `sortfunc` is
+        # sure to be set to someting else than None if the column is sortable.
+        sortable = sortfunc is not None
+        super(EntityTableColRenderer, self).__init__(sortable=sortable, **kwargs)
+        self.renderfunc = renderfunc
+        self.sortfunc = sortfunc
+
+    def copy(self):
+        assert self.view is None
+        # copy of attribute referencing a method doesn't work with python < 2.7
+        renderfunc = self.__dict__.pop('renderfunc')
+        sortfunc = self.__dict__.pop('sortfunc')
+        try:
+            acopy =  copy(self)
+            for aname, member in[('renderfunc', renderfunc),
+                                 ('sortfunc', sortfunc)]:
+                if isinstance(member, MethodType):
+                    member = create_bound_method(member.__func__, acopy)
+                setattr(acopy, aname, member)
+            return acopy
+        finally:
+            self.renderfunc = renderfunc
+            self.sortfunc = sortfunc
+
+    def render_cell(self, w, rownum):
+        entity = self.entity(rownum)
+        if entity is None:
+            w(self.empty_cell_content)
+        else:
+            self.renderfunc(w, entity)
+
+    def sortvalue(self, rownum):
+        entity = self.entity(rownum)
+        if entity is None:
+            return None
+        else:
+            return self.sortfunc(entity)
+
+    def entity(self, rownum):
+        """Convenience method returning the table's main entity."""
+        return self.view.entity(rownum)
+
+    def render_entity(self, w, entity):
+        """Sort value if `renderfunc` nor `sortfunc` specified at
+        initialization.
+
+        This default implementation consider column id is an entity attribute
+        and print its value.
+        """
+        w(entity.printable_value(self.colid))
+
+    def entity_sortvalue(self, entity):
+        """Cell rendering implementation if `renderfunc` nor `sortfunc`
+        specified at initialization.
+
+        This default implementation consider column id is an entity attribute
+        and return its sort value by calling `entity.sortvalue(colid)`.
+        """
+        return entity.sortvalue(self.colid)
+
+
+class MainEntityColRenderer(EntityTableColRenderer):
+    """Renderer to be used for the column displaying the 'main entity' of a
+    :class:`EntityTableView`.
+
+    By default display it using the 'incontext' view. You may specify another
+    view identifier using the `vid` argument.
+
+    If header not specified, it would be built using entity types in the main
+    column.
+    """
+    def __init__(self, vid='incontext', addcount=True, **kwargs):
+        super(MainEntityColRenderer, self).__init__(addcount=addcount, **kwargs)
+        self.vid = vid
+
+    def default_header(self):
+        view = self.view
+        if len(view.cw_rset) > 1:
+            suffix = '_plural'
+        else:
+            suffix = ''
+        return u', '.join(self._cw.__(et + suffix)
+                          for et in view.cw_rset.column_types(view.cw_col or 0))
+
+    def render_entity(self, w, entity):
+        entity.view(self.vid, w=w)
+
+    def entity_sortvalue(self, entity):
+        return entity.sortvalue()
+
+
+class RelatedEntityColRenderer(MainEntityColRenderer):
+    """Renderer to be used for column displaying an entity related the 'main
+    entity' of a :class:`EntityTableView`.
+
+    By default display it using the 'incontext' view. You may specify another
+    view identifier using the `vid` argument.
+
+    If header not specified, it would be built by translating the column id.
+    """
+    def __init__(self, getrelated, addcount=False, **kwargs):
+        super(RelatedEntityColRenderer, self).__init__(addcount=addcount, **kwargs)
+        self.getrelated = getrelated
+
+    def entity(self, rownum):
+        entity = super(RelatedEntityColRenderer, self).entity(rownum)
+        return self.getrelated(entity)
+
+    def default_header(self):
+        return self._cw._(self.colid)
+
+
+class RelationColRenderer(EntityTableColRenderer):
+    """Renderer to be used for column displaying a list of entities related the
+    'main entity' of a :class:`EntityTableView`. By default, the main entity is
+    considered as the subject of the relation but you may specify otherwise
+    using the `role` argument.
+
+    By default display the related rset using the 'csv' view, using
+    'outofcontext' sub-view for each entity. You may specify another view
+    identifier using respectivly the `vid` and `subvid` arguments.
+
+    If you specify a 'rtype view', such as 'reledit', you should add a
+    is_rtype_view=True parameter.
+
+    If header not specified, it would be built by translating the column id,
+    properly considering role.
+    """
+    def __init__(self, role='subject', vid='csv', subvid=None,
+                 fallbackvid='empty-cell', is_rtype_view=False, **kwargs):
+        super(RelationColRenderer, self).__init__(**kwargs)
+        self.role = role
+        self.vid = vid
+        if subvid is None and vid in ('csv', 'list'):
+            subvid = 'outofcontext'
+        self.subvid = subvid
+        self.fallbackvid = fallbackvid
+        self.is_rtype_view = is_rtype_view
+
+    def render_entity(self, w, entity):
+        kwargs = {'w': w}
+        if self.is_rtype_view:
+            rset = None
+            kwargs['entity'] = entity
+            kwargs['rtype'] = self.colid
+            kwargs['role'] = self.role
+        else:
+            rset = entity.related(self.colid, self.role)
+        if self.subvid is not None:
+            kwargs['subvid'] = self.subvid
+        self._cw.view(self.vid, rset, self.fallbackvid, **kwargs)
+
+    def default_header(self):
+        return display_name(self._cw, self.colid, self.role)
+
+    entity_sortvalue = None # column not sortable by default
+
+
+class EntityTableView(TableMixIn, EntityView):
+    """This abstract table view is designed to be used with an
+    :class:`is_instance()` or :class:`adaptable` predicate, hence doesn't depend
+    the result set shape as the :class:`RsetTableView` does.
+
+    It will display columns that should be defined using the `columns` class
+    attribute containing a list of column ids. By default, each column is
+    renderered by :class:`EntityTableColRenderer` which consider that the column
+    id is an attribute of the table's main entity (ie the one for which the view
+    is selected).
+
+    You may wish to specify :class:`MainEntityColRenderer` or
+    :class:`RelatedEntityColRenderer` renderer for a column in the
+    :attr:`column_renderers` dictionary.
+
+    .. autoclass:: cubicweb.web.views.tableview.EntityTableColRenderer
+    .. autoclass:: cubicweb.web.views.tableview.MainEntityColRenderer
+    .. autoclass:: cubicweb.web.views.tableview.RelatedEntityColRenderer
+    .. autoclass:: cubicweb.web.views.tableview.RelationColRenderer
+    """
+    __abstract__ = True
+    default_column_renderer_class = EntityTableColRenderer
+    columns = None # to be defined in concret class
+
+    def call(self, columns=None, **kwargs):
+        if columns is not None:
+            self.columns = columns
+        self.layout_render(self.w)
+
+    @property
+    def table_size(self):
+        return self.cw_rset.rowcount
+
+    def build_column_renderers(self):
+        return [self.column_renderer(colid) for colid in self.columns]
+
+    def entity(self, rownum):
+        """Return the table's main entity"""
+        return self.cw_rset.get_entity(rownum, self.cw_col or 0)
+
+
+class EmptyCellView(AnyRsetView):
+    __regid__ = 'empty-cell'
+    __select__ = yes()
+    def call(self, **kwargs):
+        self.w(u'&#160;')
+    cell_call = call
+
+
+################################################################################
+# DEPRECATED tables ############################################################
+################################################################################
+
+
+@add_metaclass(class_deprecated)
+class TableView(AnyRsetView):
+    """The table view accepts any non-empty rset. It uses introspection on the
+    result set to compute column names and the proper way to display the cells.
+
+    It is however highly configurable and accepts a wealth of options.
+    """
+    __deprecation_warning__ = '[3.14] %(cls)s is deprecated'
+    __regid__ = 'table'
+    title = _('table')
+    finalview = 'final'
+
+    table_widget_class = TableWidget
+    table_column_class = TableColumn
+
+    tablesorter_settings = {
+        'textExtraction': JSString('cw.sortValueExtraction'),
+        'selectorHeaders': 'thead tr:first th', # only plug on the first row
+        }
+    handle_pagination = True
+
+    def form_filter(self, divid, displaycols, displayactions, displayfilter,
+                    paginate, hidden=True):
+        try:
+            filterform = self._cw.vreg['views'].select(
+                'facet.filtertable', self._cw, rset=self.cw_rset)
+        except NoSelectableObject:
+            return ()
+        vidargs = {'paginate': paginate,
+                   'displaycols': displaycols,
+                   'displayactions': displayactions,
+                   'displayfilter': displayfilter}
+        cssclass = hidden and 'hidden' or ''
+        filterform.render(self.w, vid=self.__regid__, divid=divid,
+                          vidargs=vidargs, cssclass=cssclass)
+        return self.show_hide_actions(divid, not hidden)
+
+    def main_var_index(self):
+        """Returns the index of the first non final variable of the rset.
+
+        Used to select the main etype to help generate accurate column headers.
+        XXX explain the concept
+
+        May return None if none is found.
+        """
+        eschema = self._cw.vreg.schema.eschema
+        for i, etype in enumerate(self.cw_rset.description[0]):
+            try:
+                if not eschema(etype).final:
+                    return i
+            except KeyError: # XXX possible?
+                continue
+        return None
+
+    def displaycols(self, displaycols, headers):
+        if displaycols is None:
+            if 'displaycols' in self._cw.form:
+                displaycols = [int(idx) for idx in self._cw.form['displaycols']]
+            elif headers is not None:
+                displaycols = list(range(len(headers)))
+            else:
+                displaycols = list(range(len(self.cw_rset.syntax_tree().children[0].selection)))
+        return displaycols
+
+    def _setup_tablesorter(self, divid):
+        req = self._cw
+        req.add_js('jquery.tablesorter.js')
+        req.add_onload('''$(document).ready(function() {
+    $("#%s table.listing").tablesorter(%s);
+});''' % (divid, js_dumps(self.tablesorter_settings)))
+        req.add_css(('cubicweb.tablesorter.css', 'cubicweb.tableview.css'))
+
+    @cachedproperty
+    def initial_load(self):
+        """We detect a bit heuristically if we are built for the first time or
+        from subsequent calls by the form filter or by the pagination
+        hooks.
+
+        """
+        form = self._cw.form
+        return 'fromformfilter' not in form and '__start' not in form
+
+    def call(self, title=None, subvid=None, displayfilter=None, headers=None,
+             displaycols=None, displayactions=None, actions=(), divid=None,
+             cellvids=None, cellattrs=None, mainindex=None,
+             paginate=False, page_size=None):
+        """Produces a table displaying a composite query
+
+        :param title: title added before table
+        :param subvid: cell view
+        :param displayfilter: filter that selects rows to display
+        :param headers: columns' titles
+        :param displaycols: indexes of columns to display (first column is 0)
+        :param displayactions: if True, display action menu
+        """
+        req = self._cw
+        divid = divid or req.form.get('divid') or 'rs%s' % make_uid(id(self.cw_rset))
+        self._setup_tablesorter(divid)
+        # compute label first  since the filter form may remove some necessary
+        # information from the rql syntax tree
+        if mainindex is None:
+            mainindex = self.main_var_index()
+        computed_labels = self.columns_labels(mainindex)
+        if not subvid and 'subvid' in req.form:
+            subvid = req.form.pop('subvid')
+        actions = list(actions)
+        if mainindex is None:
+            displayfilter, displayactions = False, False
+        else:
+            if displayfilter is None and req.form.get('displayfilter'):
+                displayfilter = True
+            if displayactions is None and req.form.get('displayactions'):
+                displayactions = True
+        displaycols = self.displaycols(displaycols, headers)
+        if self.initial_load:
+            self.w(u'<div class="section">')
+            if not title and 'title' in req.form:
+                title = req.form['title']
+            if title:
+                self.w(u'<h2 class="tableTitle">%s</h2>\n' % title)
+            if displayfilter:
+                actions += self.form_filter(divid, displaycols, displayfilter,
+                                            displayactions, paginate)
+        elif displayfilter:
+            actions += self.show_hide_actions(divid, True)
+        self.w(u'<div id="%s">' % divid)
+        if displayactions:
+            actionsbycat = self._cw.vreg['actions'].possible_actions(req, self.cw_rset)
+            for action in actionsbycat.get('mainactions', ()):
+                for action in action.actual_actions():
+                    actions.append( (action.url(), req._(action.title),
+                                     action.html_class(), None) )
+        # render actions menu
+        if actions:
+            self.render_actions(divid, actions)
+        # render table
+        if paginate:
+            self.divid = divid # XXX iirk (see usage in page_navigation_url)
+            self.paginate(page_size=page_size, show_all_option=False)
+        table = self.table_widget_class(self)
+        for column in self.get_columns(computed_labels, displaycols, headers,
+                                       subvid, cellvids, cellattrs, mainindex):
+            table.append_column(column)
+        table.render(self.w)
+        self.w(u'</div>\n')
+        if self.initial_load:
+            self.w(u'</div>\n')
+
+    def page_navigation_url(self, navcomp, path, params):
+        """Build a URL to the current view using the <navcomp> attributes
+
+        :param navcomp: a NavigationComponent to call a URL method on.
+        :param path:    expected to be json here?
+        :param params: params to give to build_url method
+
+        this is called by :class:`cubiweb.web.component.NavigationComponent`
+        """
+        if hasattr(self, 'divid'):
+            # XXX this assert a single call
+            params['divid'] = self.divid
+        params['vid'] = self.__regid__
+        return navcomp.ajax_page_url(**params)
+
+    def show_hide_actions(self, divid, currentlydisplayed=False):
+        showhide = u';'.join(toggle_action('%s%s' % (divid, what))[11:]
+                             for what in ('Form', 'Show', 'Hide', 'Actions'))
+        showhide = 'javascript:' + showhide
+        showlabel = self._cw._('show filter form')
+        hidelabel = self._cw._('hide filter form')
+        if currentlydisplayed:
+            return [(showhide, showlabel, 'hidden', '%sShow' % divid),
+                    (showhide, hidelabel, None, '%sHide' % divid)]
+        return [(showhide, showlabel, None, '%sShow' % divid),
+                (showhide, hidelabel, 'hidden', '%sHide' % divid)]
+
+    def render_actions(self, divid, actions):
+        box = MenuWidget('', 'tableActionsBox', _class='', islist=False)
+        label = tags.img(src=self._cw.uiprops['PUCE_DOWN'],
+                         alt=xml_escape(self._cw._('action(s) on this selection')))
+        menu = PopupBoxMenu(label, isitem=False, link_class='actionsBox',
+                            ident='%sActions' % divid)
+        box.append(menu)
+        for url, label, klass, ident in actions:
+            menu.append(component.Link(url, label, klass=klass, id=ident))
+        box.render(w=self.w)
+        self.w(u'<div class="clear"></div>')
+
+    def get_columns(self, computed_labels, displaycols, headers, subvid,
+                    cellvids, cellattrs, mainindex):
+        """build columns description from various parameters
+
+        : computed_labels: columns headers computed from rset to be used if there is no headers entry
+        : displaycols: see :meth:`call`
+        : headers: explicitly define columns headers
+        : subvid: see :meth:`call`
+        : cellvids: see :meth:`call`
+        : cellattrs: see :meth:`call`
+        : mainindex: see :meth:`call`
+
+        return a list of columns description to be used by
+               :class:`~cubicweb.web.htmlwidgets.TableWidget`
+        """
+        columns = []
+        eschema = self._cw.vreg.schema.eschema
+        for colindex, label in enumerate(computed_labels):
+            if colindex not in displaycols:
+                continue
+            # compute column header
+            if headers is not None:
+                _label = headers[displaycols.index(colindex)]
+                if _label is not None:
+                    label = _label
+            if colindex == mainindex and label is not None:
+                label += ' (%s)' % self.cw_rset.rowcount
+            column = self.table_column_class(label, colindex)
+            coltype = self.cw_rset.description[0][colindex]
+            # compute column cell view (if coltype is None, it's a left outer
+            # join, use the default non final subvid)
+            if cellvids and colindex in cellvids:
+                column.append_renderer(cellvids[colindex], colindex)
+            elif coltype is not None and eschema(coltype).final:
+                column.append_renderer(self.finalview, colindex)
+            else:
+                column.append_renderer(subvid or 'incontext', colindex)
+            if cellattrs and colindex in cellattrs:
+                for name, value in cellattrs[colindex].items():
+                    column.add_attr(name, value)
+            # add column
+            columns.append(column)
+        return columns
+
+
+    def render_cell(self, cellvid, row, col, w):
+        self._cw.view('cell', self.cw_rset, row=row, col=col, cellvid=cellvid, w=w)
+
+    def get_rows(self):
+        return self.cw_rset
+
+    @htmlescape
+    @jsonize
+    @limitsize(10)
+    def sortvalue(self, row, col):
+        # XXX it might be interesting to try to limit value's
+        #     length as much as possible (e.g. by returning the 10
+        #     first characters of a string)
+        val = self.cw_rset[row][col]
+        if val is None:
+            return u''
+        etype = self.cw_rset.description[row][col]
+        if etype is None:
+            return u''
+        if self._cw.vreg.schema.eschema(etype).final:
+            entity, rtype = self.cw_rset.related_entity(row, col)
+            if entity is None:
+                return val # remove_html_tags() ?
+            return entity.sortvalue(rtype)
+        entity = self.cw_rset.get_entity(row, col)
+        return entity.sortvalue()
+
+
+class EditableTableView(TableView):
+    __regid__ = 'editable-table'
+    finalview = 'editable-final'
+    title = _('editable-table')
+
+
+@add_metaclass(class_deprecated)
+class CellView(EntityView):
+    __deprecation_warning__ = '[3.14] %(cls)s is deprecated'
+    __regid__ = 'cell'
+    __select__ = nonempty_rset()
+
+    def cell_call(self, row, col, cellvid=None):
+        """
+        :param row, col: indexes locating the cell value in view's result set
+        :param cellvid: cell view (defaults to 'outofcontext')
+        """
+        etype, val = self.cw_rset.description[row][col], self.cw_rset[row][col]
+        if etype is None or not self._cw.vreg.schema.eschema(etype).final:
+            if val is None:
+                # This is usually caused by a left outer join and in that case,
+                # regular views will most certainly fail if they don't have
+                # a real eid
+                # XXX if cellvid is e.g. reledit, we may wanna call it anyway
+                self.w(u'&#160;')
+            else:
+                self.wview(cellvid or 'outofcontext', self.cw_rset, row=row, col=col)
+        else:
+            # XXX why do we need a fallback view here?
+            self.wview(cellvid or 'final', self.cw_rset, 'null', row=row, col=col)
+
+
+class InitialTableView(TableView):
+    """same display as  table view but consider two rql queries :
+
+    * the default query (ie `rql` form parameter), which is only used to select
+      this view and to build the filter form. This query should have the same
+      structure as the actual without actual restriction (but link to
+      restriction variables) and usually with a limit for efficiency (limit set
+      to 2 is advised)
+
+    * the actual query (`actualrql` form parameter) whose results will be
+      displayed with default restrictions set
+    """
+    __regid__ = 'initialtable'
+    __select__ = nonempty_rset()
+    # should not be displayed in possible view since it expects some specific
+    # parameters
+    title = None
+
+    def call(self, title=None, subvid=None, headers=None, divid=None,
+             paginate=False, displaycols=None, displayactions=None,
+             mainindex=None):
+        """Dumps a table displaying a composite query"""
+        try:
+            actrql = self._cw.form['actualrql']
+        except KeyError:
+            actrql = self.cw_rset.printable_rql()
+        else:
+            self._cw.ensure_ro_rql(actrql)
+        displaycols = self.displaycols(displaycols, headers)
+        if displayactions is None and 'displayactions' in self._cw.form:
+            displayactions = True
+        if divid is None and 'divid' in self._cw.form:
+            divid = self._cw.form['divid']
+        self.w(u'<div class="section">')
+        if not title and 'title' in self._cw.form:
+            # pop title so it's not displayed by the table view as well
+            title = self._cw.form.pop('title')
+        if title:
+            self.w(u'<h2>%s</h2>\n' % title)
+        if mainindex is None:
+            mainindex = self.main_var_index()
+        if mainindex is not None:
+            actions = self.form_filter(divid, displaycols, displayactions,
+                                       displayfilter=True, paginate=paginate,
+                                       hidden=True)
+        else:
+            actions = ()
+        if not subvid and 'subvid' in self._cw.form:
+            subvid = self._cw.form.pop('subvid')
+        self._cw.view('table', self._cw.execute(actrql),
+                      'noresult', w=self.w, displayfilter=False, subvid=subvid,
+                      displayactions=displayactions, displaycols=displaycols,
+                      actions=actions, headers=headers, divid=divid)
+        self.w(u'</div>\n')
+
+
+class EditableInitialTableTableView(InitialTableView):
+    __regid__ = 'editable-initialtable'
+    finalview = 'editable-final'
+
+
+@add_metaclass(class_deprecated)
+class EntityAttributesTableView(EntityView):
+    """This table displays entity attributes in a table and allow to set a
+    specific method to help building cell content for each attribute as well as
+    column header.
+
+    Table will render entity cell by using the appropriate build_COLNAME_cell
+    methods if defined otherwise cell content will be entity.COLNAME.
+
+    Table will render column header using the method header_for_COLNAME if
+    defined otherwise COLNAME will be used.
+    """
+    __deprecation_warning__ = '[3.14] %(cls)s is deprecated'
+    __abstract__ = True
+    columns = ()
+    table_css = "listing"
+    css_files = ()
+
+    def call(self, columns=None):
+        if self.css_files:
+            self._cw.add_css(self.css_files)
+        _ = self._cw._
+        self.columns = columns or self.columns
+        sample = self.cw_rset.get_entity(0, 0)
+        self.w(u'<table class="%s">' % self.table_css)
+        self.table_header(sample)
+        self.w(u'<tbody>')
+        for row in range(self.cw_rset.rowcount):
+            self.cell_call(row=row, col=0)
+        self.w(u'</tbody>')
+        self.w(u'</table>')
+
+    def cell_call(self, row, col):
+        _ = self._cw._
+        entity = self.cw_rset.get_entity(row, col)
+        entity.complete()
+        infos = {}
+        for col in self.columns:
+            meth = getattr(self, 'build_%s_cell' % col, None)
+            # find the build method or try to find matching attribute
+            if meth:
+                content = meth(entity)
+            else:
+                content = entity.printable_value(col)
+            infos[col] = content
+        self.w(u"""<tr onmouseover="$(this).addClass('highlighted');"
+            onmouseout="$(this).removeClass('highlighted')">""")
+        line = u''.join(u'<td>%%(%s)s</td>' % col for col in self.columns)
+        self.w(line % infos)
+        self.w(u'</tr>\n')
+
+    def table_header(self, sample):
+        """builds the table's header"""
+        self.w(u'<thead><tr>')
+        for column in self.columns:
+            meth = getattr(self, 'header_for_%s' % column, None)
+            if meth:
+                colname = meth(sample)
+            else:
+                colname = self._cw._(column)
+            self.w(u'<th>%s</th>' % xml_escape(colname))
+        self.w(u'</tr></thead>\n')