[table views] closes #1986413: refactor TableView, EntityAttributesTableView, PyValTableView
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Fri, 21 Oct 2011 14:32:37 +0200
changeset 7992 4ff9f25cb06e
parent 7991 dcc5a4d48122
child 7993 aa0addce4dae
[table views] closes #1986413: refactor TableView, EntityAttributesTableView, PyValTableView See the tickets for a description of the why. * one should now use RsetTableView instead of TableView and EntityTableView instead of EntityAttributesTableView. * a flexible layout object has been extracted, TableLayout * PyVaTableView has been rewritten using the same base class / renderer
__pkginfo__.py
debian/control
doc/3.14.rst
doc/book/en/devweb/views/table.rst
web/test/test_views.py
web/test/unittest_urlrewrite.py
web/test/unittest_views_baseviews.py
web/test/unittest_views_pyviews.py
web/test/unittest_viewselector.py
web/views/cwsources.py
web/views/pyviews.py
web/views/tableview.py
web/views/workflow.py
--- a/__pkginfo__.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/__pkginfo__.py	Fri Oct 21 14:32:37 2011 +0200
@@ -40,7 +40,7 @@
 ]
 
 __depends__ = {
-    'logilab-common': '>= 0.56.3',
+    'logilab-common': '>= 0.57.0',
     'logilab-mtconverter': '>= 0.8.0',
     'rql': '>= 0.28.0',
     'yams': '>= 0.34.0',
--- a/debian/control	Fri Oct 21 14:32:37 2011 +0200
+++ b/debian/control	Fri Oct 21 14:32:37 2011 +0200
@@ -99,7 +99,7 @@
 Package: cubicweb-common
 Architecture: all
 XB-Python-Version: ${python:Versions}
-Depends: ${misc:Depends}, ${python:Depends}, graphviz, gettext, python-logilab-mtconverter (>= 0.8.0), python-logilab-common (>= 0.56.3), python-yams (>= 0.34.0), python-rql (>= 0.28.0), python-lxml
+Depends: ${misc:Depends}, ${python:Depends}, graphviz, gettext, python-logilab-mtconverter (>= 0.8.0), python-logilab-common (>= 0.57.0), python-yams (>= 0.34.0), python-rql (>= 0.28.0), python-lxml
 Recommends: python-simpletal (>= 4.0), python-crypto
 Conflicts: cubicweb-core
 Replaces: cubicweb-core
--- a/doc/3.14.rst	Fri Oct 21 14:32:37 2011 +0200
+++ b/doc/3.14.rst	Fri Oct 21 14:32:37 2011 +0200
@@ -67,6 +67,15 @@
 
 * jQuery has been updated to 1.6.4. No backward compat issue known (yet...)
 
+* Table views refactoring : new RsetTableView and EntityTableView, as well as
+  rewritten an enhanced version of PyValTableView on the same bases.  Those
+  deprecates former `TableView`, `EntityAttributesTableView` and `CellView`,
+  which are however kept for backward compat, with some warnings that may not be
+  very clear unfortunatly (you may see your own table view subclass name here,
+  which doesn't make the problem that clear). Notice that `_cw.view('table',
+  rset, *kwargs)` will be routed to the new `RsetTableView` or to the old
+  `TableView` depending on given extra arguments. See #1986413.
+
 
 Unintrusive API changes
 -----------------------
--- a/doc/book/en/devweb/views/table.rst	Fri Oct 21 14:32:37 2011 +0200
+++ b/doc/book/en/devweb/views/table.rst	Fri Oct 21 14:32:37 2011 +0200
@@ -1,26 +1,7 @@
-Table view
-----------
-
-(:mod:`cubicweb.web.views.tableview`)
-
-*table*
-    Creates a HTML table (`<table>`) and call the view `cell` for each cell of
-    the result set. Applicable on any result set.
+Table views
+-----------
 
-*editable-table*
-    Creates an **editable** HTML table (`<table>`) and call the view `cell` for each cell of
-    the result set. Applicable on any result set.
-
-*cell*
-    By default redirects to the `final` view if this is a final entity or
-    `outofcontext` view otherwise
-
-
-API
-```
-
-.. autoclass:: cubicweb.web.views.tableview.TableView
-   :members:
+.. automodule:: cubicweb.web.views.tableview
 
 Example
 ```````
@@ -29,50 +10,135 @@
 
 .. sourcecode:: python
 
-    class ActivityTable(EntityView):
-        __regid__ = 'activitytable'
+    class ActivityResourcesTable(EntityView):
+        __regid__ = 'activity.resources.table'
         __select__ = is_instance('Activity')
-        title = _('activitytable')
 
         def call(self, showresource=True):
-            _ = self._cw._
-            headers  = [_("diem"), _("duration"), _("workpackage"), _("description"), _("state"), u""]
             eids = ','.join(str(row[0]) for row in self.cw_rset)
-            rql = ('Any R, D, DUR, WO, DESCR, S, A, SN, RT, WT ORDERBY D DESC '
+            rql = ('Any R,D,DUR,WO,DESCR,S,A, SN,RT,WT ORDERBY D DESC '
                    'WHERE '
                    '   A is Activity, A done_by R, R title RT, '
                    '   A diem D, A duration DUR, '
                    '   A done_for WO, WO title WT, '
-                   '   A description DESCR, A in_state S, S name SN, A eid IN (%s)' % eids)
-            if showresource:
-                displaycols = range(7)
-                headers.insert(0, display_name(self._cw, 'Resource'))
-            else: # skip resource column if asked to
-                displaycols = range(1, 7)
+                   '   A description DESCR, A in_state S, S name SN, '
+                   '   A eid IN (%s)' % eids)
             rset = self._cw.execute(rql)
-            self.wview('editable-table', rset, 'null',
-                       displayfilter=True, displayactions=False,
-                       headers=headers, displaycols=displaycols,
-                       cellvids={3: 'editable-final'})
+            self.wview('resource.table', rset, 'null')
 
-To obtain an editable table, specify 'edtitable-table' as vid. You
-have to select the entity in the rql request too (in order to kwnow
-which entity must be edited). You can specify an optional
-`displaycols` argument which defines column's indexes that will be
-displayed. In the above example, setting `showresource` to `False`
-will only render columns from index 1 to 7.
+    class ResourcesTable(RsetTableView):
+        __regid__ = 'resource.table'
+        # notice you may wish a stricter selector to check rql's shape
+        __select__ = is_instance('Resource')
+        # my table headers
+        headers  = ['Resource', 'diem', 'duration', 'workpackage', 'description', 'state']
+        # I want a table where attributes are editable (reledit inside)
+        finalvid = 'editable-final'
+
+        cellvids = {3: 'editable-final'}
+        # display facets and actions with a menu
+        layout_args = {'display_filter': 'top',
+                       'add_view_actions': None}
+
+To obtain an editable table, you may specify the 'editable-table' view identifier
+using some of `cellvids`, `finalvid` or `nonfinalvid`.
 
 The previous example results in:
 
 .. image:: ../../images/views-table-shadow.png
 
-
-In order to activate table filter mechanism, set the `displayfilter`
-argument to True. A small arrow will be displayed at the table's top
-right corner. Clicking on `show filter form` action, will display the
-filter form as below:
+In order to activate table filter mechanism, the `display_filter` option is given
+as a layout argument. A small arrow will be displayed at the table's top right
+corner. Clicking on `show filter form` action, will display the filter form as
+below:
 
 .. image:: ../../images/views-table-filter-shadow.png
 
-By the same way, you can display all registered actions for the
-selected entity, setting `displayactions` argument to True.
+By the same way, you can display additional actions for the selected entities
+by setting `add_view_actions` layout option to `True`. This will add actions
+returned by the view's :meth:`~cubicweb.web.views.TableMixIn.table_actions`.
+
+You can notice that all columns of the result set are not displayed. This is
+because of given `headers`, implying to display only columns from 0 to
+len(headers).
+
+Also Notice that the `ResourcesTable` view relies on a particular rql shape
+(which is not ensured by the way, the only checked thing is that the result set
+contains instance of the `Resource` type). That usually implies that you can't
+use this view for user specific queries (e.g. generated by facets or typed
+manually).
+
+
+So another option would be to write this view using
+:class:`~cubicweb.web.views.tableview.EntityTableView`, as below.
+
+
+    class ResourcesTable(EntityTableView):
+        __regid__ = 'resource.table'
+        __select__ = is_instance('Resource')
+        # table columns definition
+        columns  = ['resource', 'diem', 'duration', 'workpackage', 'description', 'in_state']
+        # I want a table where attributes are editable (reledit inside)
+        finalvid = 'editable-final'
+        # display facets and actions with a menu
+        layout_args = {'display_filter': 'top',
+                       'add_view_actions': None}
+
+        def workpackage_cell(entity):
+            activity = entity.reverse_done_in[0]
+            activity.view('reledit', rtype='done_for', role='subject', w=w)
+        def workpackage_sortvalue(entity):
+            activity = entity.reverse_done_in[0]
+            return activity.done_for[0].sortvalue()
+
+        column_renderers = {
+            'resource': MainEntityColRenderer(),
+            'workpackage': EntityTableColRenderer(
+               header='Workpackage',
+               renderfunc=worpackage_cell,
+               sortfunc=worpackage_sortvalue,),
+            'in_state': EntityTableColRenderer(
+               renderfunc=lambda w,x: w(x.cw_adapt_to('IWorkflowable').printable_state),
+               sortfunc=lambda x: x.cw_adapt_to('IWorkflowable').printable_state),
+         }
+
+Notice the following point:
+
+* `cell_<column>(w, entity)` will be searched for rendering the content of a
+  cell. If not found, `column` is expected to be an attribute of `entity`.
+
+* `cell_sortvalue_<column>(entity)` should return a typed value to use for
+  javascript sorting or None for not sortable columns (the default).
+
+* The :func:`etable_entity_sortvalue` decorator will set a 'sortvalue' function
+  for the column containing the main entity (the one given as argument to all
+  methods), which will call `entity.sortvalue()`.
+
+* You can set a column header using the :func:`etable_header_title` decorator.
+  This header will be translated. If it's not an already existing msgid, think
+  to mark it using `_()` (the example supposes headers are schema defined msgid).
+
+
+Pro/cons of each approach
+`````````````````````````
+:class:`EntityTableView` and :class:`RsetableView` provides basically the same
+set of features, though they don't share the same properties. Let's try to sum
+up pro and cons of each class.
+
+* `EntityTableView` view is:
+
+  - more verbose, but usually easier to understand
+
+  - easily extended (easy to add/remove columns for instance)
+
+  - doesn't rely on a particular rset shape. Simply give it a title and will be
+    listed in the 'possible views' box if any.
+
+* `RsetTableView` view is:
+
+  - hard to beat to display barely a result set, or for cases where some of
+    `headers`, `displaycols` or `cellvids` could be defined to enhance the table
+    while you don't care about e.g. pagination or facets.
+
+  - hardly extensible, as you usually have to change places where the view is
+    called to modify the RQL (hence the view's result set shape).
--- a/web/test/test_views.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/test/test_views.py	Fri Oct 21 14:32:37 2011 +0200
@@ -1,4 +1,4 @@
-# copyright 2003-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -45,10 +45,6 @@
         rset = self.execute('CWUser X WHERE X login "admin"')
         self.view('copy', rset)
 
-    def test_manual_tests(self):
-        rset = self.execute('Any P,F,S WHERE P is CWUser, P firstname F, P surname S')
-        self.view('table', rset, template=None, displayfilter=True, displaycols=[0,2])
-
     def test_sortable_js_added(self):
         rset = self.execute('CWUser X')
         # sortable.js should not be included by default
--- a/web/test/unittest_urlrewrite.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/test/unittest_urlrewrite.py	Fri Oct 21 14:32:37 2011 +0200
@@ -54,7 +54,7 @@
                           'tab': 'cw_users_management'}),
             ('/cwgroup$', {'vid': 'cw.users-and-groups-management',
                            'tab': 'cw_groups_management'}),
-            ('/cwsource$', {'vid': 'cw.source-management'}),
+            ('/cwsource$', {'vid': 'cw.sources-management'}),
             ('/schema/([^/]+?)/?$', {'rql': r'Any X WHERE X is CWEType, X name "\1"', 'vid': 'primary'}),
             ('/add/([^/]+?)/?$' , dict(vid='creation', etype=r'\1')),
             ('/doc/images/(.+?)/?$', dict(fid='\\1', vid='wdocimages')),
--- a/web/test/unittest_views_baseviews.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/test/unittest_views_baseviews.py	Fri Oct 21 14:32:37 2011 +0200
@@ -1,4 +1,4 @@
-# copyright 2003-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -111,22 +111,13 @@
 
     def test_sortvalue(self):
         e, _, view = self._prepare_entity()
-        expected = ['<toto>', 'loo"ong blabla'[:10], e.creation_date.strftime('%Y/%m/%d %H:%M:%S')]
-        got = [loadjson(view.sortvalue(0, i)) for i in xrange(3)]
-        self.assertListEqual(got, expected)
+        colrenderers = view.build_column_renderers()[:3]
+        self.assertListEqual([renderer.sortvalue(0) for renderer in colrenderers],
+                             [u'<toto>', u'loo"ong blabla'[:10], e.creation_date])
         # XXX sqlite does not handle Interval correctly
         # value = loadjson(view.sortvalue(0, 3))
         # self.assertAlmostEquals(value, rset.rows[0][3].seconds)
 
-    def test_sortvalue_with_display_col(self):
-        e, rset, view = self._prepare_entity()
-        labels = view.columns_labels()
-        table = TableWidget(view)
-        table.columns = view.get_columns(labels, [1, 2], None, None, None, None, 0)
-        expected = ['loo"ong blabla'[:10], e.creation_date.strftime('%Y/%m/%d %H:%M:%S')]
-        got = [loadjson(value) for _, value in table.itercols(0)]
-        self.assertListEqual(got, expected)
-
 
 class HTMLStreamTests(CubicWebTC):
 
--- a/web/test/unittest_views_pyviews.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/test/unittest_views_pyviews.py	Fri Oct 21 14:32:37 2011 +0200
@@ -1,4 +1,4 @@
-# copyright 2003-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -25,10 +25,9 @@
                                          pyvalue=[[1, 'a'], [2, 'b']])
         content = view.render(pyvalue=[[1, 'a'], [2, 'b']],
                               headers=['num', 'char'])
-        self.assertEqual(content.strip(), '''<table class="listing">
-<thead><tr><th>num</th><th>char</th></tr>
-</thead><tbody><tr><td>1</td><td>a</td></tr>
-<tr><td>2</td><td>b</td></tr>
+        self.assertEqual(content.strip(), '''<table class="listing"><tbody>\
+<tr class="even" onmouseout="$(this).removeClass(&quot;highlighted&quot;)" onmouseover="$(this).addClass(&quot;highlighted&quot;);"><td >1</td><td >a</td></tr>
+<tr class="odd" onmouseout="$(this).removeClass(&quot;highlighted&quot;)" onmouseover="$(this).addClass(&quot;highlighted&quot;);"><td >2</td><td >b</td></tr>
 </tbody></table>''')
 
     def test_pyvallist(self):
--- a/web/test/unittest_viewselector.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/test/unittest_viewselector.py	Fri Oct 21 14:32:37 2011 +0200
@@ -31,7 +31,7 @@
     primary, baseviews, tableview, editforms, calendar, management, embedding,
     actions, startup, cwuser, schema, xbel, vcard, owl, treeview, idownloadable,
     wdoc, debug, cwuser, cwproperties, cwsources, workflow, xmlrss, rdf,
-    csvexport)
+    csvexport, json)
 
 from cubes.folder import views as folderviews
 
@@ -117,8 +117,9 @@
         self.assertListEqual(self.pviews(req, rset),
                              [('csvexport', csvexport.CSVRsetView),
                               ('ecsvexport', csvexport.CSVEntityView),
-                              ('editable-table', tableview.EditableTableView),
+                              ('ejsonexport', json.JsonEntityView),
                               ('filetree', treeview.FileTreeView),
+                              ('jsonexport', json.JsonRsetView),
                               ('list', baseviews.ListView),
                               ('oneline', baseviews.OneLineView),
                               ('owlabox', owl.OWLABOXView),
@@ -127,7 +128,7 @@
                               ('rss', xmlrss.RSSView),
                               ('sameetypelist', baseviews.SameETypeListView),
                               ('security', management.SecurityManagementView),
-                              ('table', tableview.TableView),
+                              ('table', tableview.RsetTableView),
                               ('text', baseviews.TextView),
                               ('treeview', treeview.TreeView),
                               ('xbel', xbel.XbelView),
@@ -140,8 +141,9 @@
         self.assertListEqual(self.pviews(req, rset),
                              [('csvexport', csvexport.CSVRsetView),
                               ('ecsvexport', csvexport.CSVEntityView),
-                              ('editable-table', tableview.EditableTableView),
+                              ('ejsonexport', json.JsonEntityView),
                               ('filetree', treeview.FileTreeView),
+                              ('jsonexport', json.JsonRsetView),
                               ('list', baseviews.ListView),
                               ('oneline', baseviews.OneLineView),
                               ('owlabox', owl.OWLABOXView),
@@ -150,7 +152,7 @@
                               ('rss', xmlrss.RSSView),
                               ('sameetypelist', baseviews.SameETypeListView),
                               ('security', management.SecurityManagementView),
-                              ('table', tableview.TableView),
+                              ('table', tableview.RsetTableView),
                               ('text', baseviews.TextView),
                               ('treeview', treeview.TreeView),
                               ('xbel', xbel.XbelView),
@@ -194,8 +196,9 @@
         self.assertListEqual(self.pviews(req, rset),
                              [('csvexport', csvexport.CSVRsetView),
                               ('ecsvexport', csvexport.CSVEntityView),
-                              ('editable-table', tableview.EditableTableView),
+                              ('ejsonexport', json.JsonEntityView),
                               ('filetree', treeview.FileTreeView),
+                              ('jsonexport', json.JsonRsetView),
                               ('list', baseviews.ListView),
                               ('oneline', baseviews.OneLineView),
                               ('owlabox', owl.OWLABOXView),
@@ -203,7 +206,7 @@
                               ('rsetxml', xmlrss.XMLRsetView),
                               ('rss', xmlrss.RSSView),
                               ('security', management.SecurityManagementView),
-                              ('table', tableview.TableView),
+                              ('table', tableview.RsetTableView),
                               ('text', baseviews.TextView),
                               ('treeview', treeview.TreeView),
                               ('xbel', xbel.XbelView),
@@ -215,9 +218,9 @@
         rset = req.execute('Any N, X WHERE X in_group Y, Y name N')
         self.assertListEqual(self.pviews(req, rset),
                              [('csvexport', csvexport.CSVRsetView),
-                              ('editable-table', tableview.EditableTableView),
+                              ('jsonexport', json.JsonRsetView),
                               ('rsetxml', xmlrss.XMLRsetView),
-                              ('table', tableview.TableView),
+                              ('table', tableview.RsetTableView),
                               ])
 
     def test_possible_views_multiple_eusers(self):
@@ -225,11 +228,11 @@
         rset = req.execute('CWUser X')
         self.assertListEqual(self.pviews(req, rset),
                              [('csvexport', csvexport.CSVRsetView),
-                              ('cw.users-table', cwuser.CWUsersTable),
                               ('ecsvexport', csvexport.CSVEntityView),
-                              ('editable-table', tableview.EditableTableView),
+                              ('ejsonexport', json.JsonEntityView),
                               ('filetree', treeview.FileTreeView),
                               ('foaf', cwuser.FoafView),
+                              ('jsonexport', json.JsonRsetView),
                               ('list', baseviews.ListView),
                               ('oneline', baseviews.OneLineView),
                               ('owlabox', owl.OWLABOXView),
@@ -238,7 +241,7 @@
                               ('rss', xmlrss.RSSView),
                               ('sameetypelist', baseviews.SameETypeListView),
                               ('security', management.SecurityManagementView),
-                              ('table', tableview.TableView),
+                              ('table', tableview.RsetTableView),
                               ('text', baseviews.TextView),
                               ('treeview', treeview.TreeView),
                               ('vcard', vcard.VCardCWUserView),
@@ -366,7 +369,7 @@
         self.assertIsInstance(self.vreg['views'].select('edition', req, rset=rset),
                              editforms.EditionFormView)
         self.assertIsInstance(self.vreg['views'].select('table', req, rset=rset),
-                             tableview.TableView)
+                             tableview.RsetTableView)
         self.assertRaises(NoSelectableObject,
                               self.vreg['views'].select, 'creation', req, rset=rset)
         self.assertRaises(NoSelectableObject,
@@ -379,7 +382,7 @@
         self.assertIsInstance(self.vreg['views'].select('list', req, rset=rset),
                              baseviews.ListView)
         self.assertIsInstance(self.vreg['views'].select('table', req, rset=rset),
-                             tableview.TableView)
+                             tableview.RsetTableView)
         self.assertRaises(NoSelectableObject,
                               self.vreg['views'].select, 'creation', req, rset=rset)
         # list of entities of different types
@@ -390,7 +393,7 @@
         self.assertIsInstance(self.vreg['views'].select('list', req, rset=rset),
                                   baseviews.ListView)
         self.assertIsInstance(self.vreg['views'].select('table', req, rset=rset),
-                                  tableview.TableView)
+                                  tableview.RsetTableView)
         self.assertRaises(NoSelectableObject,
                              self.vreg['views'].select, 'creation', req, rset=rset)
         self.assertRaises(NoSelectableObject,
@@ -399,7 +402,7 @@
         req = self.request()
         rset = req.execute('Any N, X WHERE X in_group Y, Y name N')
         self.assertIsInstance(self.vreg['views'].select('table', req, rset=rset),
-                                  tableview.TableView)
+                                  tableview.RsetTableView)
         self.assertRaises(NoSelectableObject,
                               self.vreg['views'].select, 'index', req, rset=rset)
         self.assertRaises(NoSelectableObject,
@@ -418,7 +421,7 @@
         self.assertRaises(NoSelectableObject,
                               self.vreg['views'].select, 'creation', req, rset=rset)
         self.assertIsInstance(self.vreg['views'].select('table', req, rset=rset),
-                              tableview.TableView)
+                              tableview.RsetTableView)
 
     def test_interface_selector(self):
         image = self.request().create_entity('File', data_name=u'bim.png', data=Binary('bim'))
--- a/web/views/cwsources.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/views/cwsources.py	Fri Oct 21 14:32:37 2011 +0200
@@ -84,8 +84,9 @@
         else:
             if hostconfig:
                 self.w(u'<h3>%s</h3>' % self._cw._('CWSourceHostConfig_plural'))
-                self._cw.view('editable-table', hostconfig,
-                              displaycols=range(2), w=self.w)
+                self._cw.view('table', hostconfig, w=self.w,
+                              displaycols=range(2),
+                              cellvids={1: 'editable-final'})
 
 
 MAPPED_SOURCE_TYPES = set( ('pyrorql', 'datafeed') )
--- a/web/views/pyviews.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/views/pyviews.py	Fri Oct 21 14:32:37 2011 +0200
@@ -21,18 +21,38 @@
 
 from cubicweb.view import View
 from cubicweb.selectors import match_kwargs
+from cubicweb.web.views import tableview
 
 
-class PyValTableView(View):
-    """display a list of list of values into an HTML table.
+class PyValTableColRenderer(tableview.AbstractColumnRenderer):
+    """Default column renderer for :class:`PyValTableView`."""
+    def bind(self, view, colid):
+        super(PyValTableColRenderer, self).bind(view, colid)
+        self.header = view.headers[colid] if view.headers else None
+        self.data = view.pyvalue
+
+    def render_header(self, w):
+        if self.header:
+            w(self._cw._(self.header))
+        else:
+            w(self.empty_cell_content)
 
-    Take care, content is NOT xml-escaped.
+    def render_cell(self, w, rownum):
+        w(unicode(self.data[rownum][self.colid]))
+
 
-    If `headers` is specfied, it is expected to be a list of headers to be
+class PyValTableView(tableview.TableMixIn, View):
+    """This table view is designed to be used a list of list of unicode values
+    given as a mandatory `pyvalue` argument. Take care, content is NOT
+    xml-escaped.
+
+    It's configured through the following selection arguments.
+
+    If `headers` is specified, it is expected to be a list of headers to be
     inserted as first row (in <thead>).
 
-    If `colheaders` is True, the first column will be considered as an headers
-    column an its values will be inserted inside <th> instead of <td>.
+    `header_column_idx` may be used to specify a column index or a set of column
+    indiced where values should be inserted inside <th> tag instead of <td>.
 
     `cssclass` is the CSS class used on the <table> tag, and default to
     'listing' (so that the table will look similar to those generated by the
@@ -40,31 +60,53 @@
     """
     __regid__ = 'pyvaltable'
     __select__ = match_kwargs('pyvalue')
+    default_column_renderer_class = PyValTableColRenderer
+    paginable = False # not supported
+    headers = None
+    cssclass = None
+    domid = None
 
-    def call(self, pyvalue, headers=None, colheaders=False,
-             cssclass='listing'):
-        if headers is None:
-            headers = self._cw.form.get('headers')
-        w = self.w
-        w(u'<table class="%s">\n' % cssclass)
-        if headers:
-            w(u'<thead>')
-            w(u'<tr>')
-            for header in headers:
-                w(u'<th>%s</th>' % header)
-            w(u'</tr>\n')
-            w(u'</thead>')
-        w(u'<tbody>')
-        for row in pyvalue:
-            w(u'<tr>')
-            if colheaders:
-                w(u'<th>%s</th>' % row[0])
-                row = row[1:]
-            for cell in row:
-                w(u'<td>%s</td>' % cell)
-            w(u'</tr>\n')
-        w(u'</tbody>')
-        w(u'</table>\n')
+    def __init__(self, req, pyvalue, headers=None, cssclass=None,
+                 header_column_idx=None, **kwargs):
+        super(PyValTableView, self).__init__(req, **kwargs)
+        self.pyvalue = pyvalue
+        if headers is not None:
+            self.headers = headers
+        elif self.headers: # headers set on a class attribute, translate
+            self.headers = [self._cw._(header) for header in self.headers]
+        if cssclass is not None:
+            self.cssclass = cssclass
+        self.header_column_idx = header_column_idx
+
+    @property
+    def layout_args(self):
+        args = {}
+        if self.cssclass:
+            args['cssclass'] = self.cssclass
+        if self.header_column_idx is not None:
+            args['header_column_idx'] = self.header_column_idx
+        return args
+
+    # layout callbacks #########################################################
+
+    @property
+    def table_size(self):
+        """return the number of rows (header excluded) to be displayed"""
+        return len(self.pyvalue)
+
+    @property
+    def has_headers(self):
+        return self.headers
+
+    def build_column_renderers(self):
+        return [self.column_renderer(colid)
+                for colid in xrange(len(self.pyvalue[0]))]
+
+    def facets_form(self, mainvar=None):
+        return None # not supported
+
+    def table_actions(self):
+        return [] # not supported
 
 
 class PyValListView(View):
--- a/web/views/tableview.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/views/tableview.py	Fri Oct 21 14:32:37 2011 +0200
@@ -15,29 +15,783 @@
 #
 # You should have received a copy of the GNU Lesser General Public License along
 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
-"""generic table view, including filtering abilities using facets"""
+"""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.pyview.PyValTableView
+   :members:
+
+All those classes are rendered using a *layout*:
+
+.. autoclass:: cubicweb.web.views.pyview.TableLayout
+   :members:
+
+There is by default only on 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 can still 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 implements you own class.
+
+.. autoclass:: cubicweb.web.views.pyview.TableMixIn
+   :members:
+"""
 
 __docformat__ = "restructuredtext en"
 _ = unicode
 
+from warnings import warn
+
 from logilab.mtconverter import xml_escape
+from logilab.common.decorators import cachedproperty
+from logilab.common.deprecation import class_deprecated
 
 from cubicweb import NoSelectableObject, tags
-from cubicweb.selectors import nonempty_rset
+from cubicweb.selectors import yes, nonempty_rset, match_kwargs, objectify_selector
 from cubicweb.utils import make_uid, js_dumps, JSString
 from cubicweb.view import EntityView, AnyRsetView
-from cubicweb.uilib import toggle_action, limitsize, htmlescape
-from cubicweb.web import jsonize, component, facet
+from cubicweb.uilib import toggle_action, limitsize, htmlescape, sgml_attributes, domid
+from cubicweb.web import jsonize, component
 from cubicweb.web.htmlwidgets import (TableWidget, TableColumn, MenuWidget,
                                       PopupBoxMenu)
 
 
+@objectify_selector
+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.
+
+    * `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>
+    """
+    __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
+    add_view_actions = False
+    header_column_idx = None
+    enable_sorting = True
+    tablesorter_settings = {
+        'textExtraction': JSString('cw.sortValueExtraction'),
+        'selectorHeaders': "thead tr:first th", # 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 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 of
+        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 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.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(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)
+        if paginate:
+            view.paginate(w=w, show_all_option=False)
+        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 view.has_headers:
+            w(u'<thead><tr>')
+            for colrenderer in colrenderers:
+                w(u'<th>')
+                colrenderer.render_header(w)
+                w(u'</th>')
+            w(u'</tr></thead>\n')
+        w(u'<tbody>')
+        for rownum in xrange(view.table_size):
+            self.render_row(w, rownum, colrenderers)
+        w(u'</tbody>')
+        w(u'</table>')
+        if actions and self.display_actions == 'bottom':
+            self.render_actions(w, actions)
+        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, basestring):
+                sortvalue = sortvalue[:10]
+            if sortvalue is not None:
+                attrs[u'cubicweb:sortvalue'] = js_dumps(sortvalue)
+        return attrs
+
+    def render_actions(self, w, 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' % 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
+        showlabel = self._cw._('show filter form')
+        hidelabel = self._cw._('hide filter form')
+        if currentlydisplayed:
+            c1, d1 = 'hidden', '%sShow' % divid
+            c2, d2 = None, '%sHide' % divid
+        else:
+            c1, d1 = None, '%sShow' % divid
+            c2, d2 = 'hidden', '%sHide' % divid
+        return [component.Link(showhide, showlabel, klass=c1, ident=d1),
+                component.Link(showhide, hidelabel, klass=c2, ident=d2)]
+
+
+class AbstractColumnRenderer(object):
+    """Abstract base class for column renderer. Interface of a column renderer follows:
+
+    .. automethod:: bind
+    .. automethod:: render_header
+    .. automethod:: render_cell
+    .. automethod:: 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_escape'd
+    :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
+    """
+    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)>' % (self.view.__class__.__name__,
+                                        self.__class__.__name__,
+                                        self.colid)
+
+    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 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:
+
+    .. automethod:: 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
+
+    def call(self, **kwargs):
+        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]
+        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
+        try:
+            return self._cw.vreg['views'].select(
+                'facet.filtertable', self._cw, rset=self.cw_rset, view=self,
+                **kwargs)
+        except NoSelectableObject:
+            return None
+
+    @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:: 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):
+        # we don't need 'divid' once assumed a view can compute its domid
+        params['divid'] = self.domid
+        params['vid'] = self.__regid__
+        return navcomp.ajax_page_url(**params)
+
+
+class RsetTableColRenderer(AbstractColumnRenderer):
+    """Default renderer for :class:`RsetTableView`."""
+    default_cellvid = 'incontext'
+
+    def __init__(self, cellvid=None, **kwargs):
+        super(RsetTableColRenderer, self).__init__(**kwargs)
+        self.cellvid = cellvid or self.default_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
+    """
+    __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, **kwargs):
+        if self.headers:
+            self.headers = [h and self._cw._(h) for h in self.headers]
+        if (headers or displaycols or cellvids):
+            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 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)
+        self.layout_render(self.w)
+
+    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 = range(len(headers))
+            else:
+                rqlst = self.cw_rset.syntax_tree()
+                displaycols = 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:: entity
+    """
+    def __init__(self, renderfunc=None, sortfunc=None, **kwargs):
+        if renderfunc is None:
+            renderfunc = lambda w,x: w(x.printable_value(self.colid))
+            if sortfunc is None:
+                sortfunc = lambda x: x.sortvalue(self.colid)
+        kwargs.setdefault('sortable', sortfunc is not None)
+        super(EntityTableColRenderer, self).__init__(**kwargs)
+        self.renderfunc = renderfunc
+        self.sortfunc = sortfunc
+
+    def render_cell(self, w, rownum):
+        entity = self.entity(rownum)
+        if entity:
+            self.renderfunc(w, entity)
+        else:
+            w(self.empty_cell_content)
+
+    def sortvalue(self, rownum):
+        entity = self.entity(rownum)
+        if entity:
+            return self.sortfunc(self.entity(rownum))
+        return None
+
+    def entity(self, rownum):
+        """Return the table's main entity"""
+        return self.view.cw_rset.get_entity(rownum, self.view.cw_col or 0)
+
+
+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', **kwargs):
+        kwargs.setdefault('renderfunc', lambda w, x: x.view(vid, w=w))
+        kwargs.setdefault('sortfunc', lambda x: x.sortvalue())
+        super(MainEntityColRenderer, self).__init__(**kwargs)
+
+    def default_header(self):
+        view = self.view
+        return u', '.join(self._cw.__(et+'_plural')
+                          for et in view.cw_rset.column_types(view.cw_col or 0))
+
+
+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 using entity types in the main
+    column.
+    """
+    def __init__(self, getrelated, **kwargs):
+        super(RelatedEntityColRenderer, self).__init__(**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 EntityTableView(TableMixIn, EntityView):
+    """This abstract table view is designed to be used with an
+    :class:`is_instance()` or :class:`adaptable` selector, hence doesn't depend
+    the result set shape as the :class:`TableView` 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:: EntityTableColRenderer
+    .. autoclass:: MainEntityColRenderer
+    .. autoclass:: RelatedEntityColRenderer
+    """
+    __abstract__ = True
+    default_column_renderer_class = EntityTableColRenderer
+    columns = None # to be defined in concret class
+
+    def call(self, columns=None):
+        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]
+
+
+class EmptyCellView(AnyRsetView):
+    __regid__ = 'empty-cell'
+    __select__ = yes()
+    def call(self, **kwargs):
+        self.w(u'&#160;')
+    cell_call = call
+
+
+################################################################################
+# DEPRECATED tables ############################################################
+################################################################################
+
+
 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.
     """
+    __metaclass__ = class_deprecated
+    __deprecation_warning__ = '[3.14] %(cls)s is deprecated'
     __regid__ = 'table'
     title = _('table')
     finalview = 'final'
@@ -97,6 +851,13 @@
 });''' % (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 of
+        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,
@@ -119,7 +880,6 @@
         if mainindex is None:
             mainindex = self.main_var_index()
         computed_labels = self.columns_labels(mainindex)
-        hidden = True
         if not subvid and 'subvid' in req.form:
             subvid = req.form.pop('subvid')
         actions = list(actions)
@@ -128,16 +888,10 @@
         else:
             if displayfilter is None and req.form.get('displayfilter'):
                 displayfilter = True
-                if req.form['displayfilter'] == 'shown':
-                    hidden = False
             if displayactions is None and req.form.get('displayactions'):
                 displayactions = True
         displaycols = self.displaycols(displaycols, headers)
-        fromformfilter = 'fromformfilter' in req.form
-        # if fromformfilter is true, this is an ajax call and we only want to
-        # replace the inner div, so don't regenerate everything under the if
-        # below
-        if not fromformfilter:
+        if self.initial_load:
             self.w(u'<div class="section">')
             if not title and 'title' in req.form:
                 title = req.form['title']
@@ -168,7 +922,7 @@
             table.append_column(column)
         table.render(self.w)
         self.w(u'</div>\n')
-        if not fromformfilter:
+        if not self.initial_load:
             self.w(u'</div>\n')
 
     def page_navigation_url(self, navcomp, path, params):
@@ -199,7 +953,7 @@
         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"/>')
+        self.w(u'<div class="clear"></div>')
 
     def get_columns(self, computed_labels, displaycols, headers, subvid,
                     cellvids, cellattrs, mainindex):
@@ -268,6 +1022,8 @@
 
 
 class CellView(EntityView):
+    __metaclass__ = class_deprecated
+    __deprecation_warning__ = '[3.14] %(cls)s is deprecated'
     __regid__ = 'cell'
     __select__ = nonempty_rset()
 
@@ -363,6 +1119,8 @@
     Table will render column header using the method header_for_COLNAME if
     defined otherwise COLNAME will be used.
     """
+    __metaclass__ = class_deprecated
+    __deprecation_warning__ = '[3.14] %(cls)s is deprecated'
     __abstract__ = True
     columns = ()
     table_css = "listing"
@@ -413,4 +1171,3 @@
             self.w(u'<th>%s</th>' % xml_escape(colname))
         self.w(u'</tr></thead>\n')
 
-
--- a/web/views/workflow.py	Fri Oct 21 14:32:37 2011 +0200
+++ b/web/views/workflow.py	Fri Oct 21 14:32:37 2011 +0200
@@ -143,12 +143,10 @@
         if self._cw.vreg.schema.eschema('CWUser').has_perm(self._cw, 'read'):
             sel += ',U,C'
             rql += ', WF owned_by U?'
-            displaycols = range(5)
             headers = (_('from_state'), _('to_state'), _('comment'), _('date'),
                        _('CWUser'))
         else:
             sel += ',C'
-            displaycols = range(4)
             headers = (_('from_state'), _('to_state'), _('comment'), _('date'))
         rql = '%s %s, X eid %%(x)s' % (sel, rql)
         try:
@@ -157,9 +155,8 @@
             return
         if rset:
             if title:
-                title = _(title)
-            self.wview('table', rset, title=title, displayactions=False,
-                       displaycols=displaycols, headers=headers)
+                self.w(u'<h2>%s</h2>\n' % _(title))
+            self.wview('table', rset, headers=headers)
 
 
 class WFHistoryVComponent(component.EntityCtxComponent):
@@ -284,7 +281,7 @@
         rset = self._cw.execute(
             'Any T,T,DS,T,TT ORDERBY TN WHERE T transition_of WF, WF eid %(x)s,'
             'T type TT, T name TN, T destination_state DS?', {'x': entity.eid})
-        self.wview('editable-table', rset, 'null',
+        self.wview('table', rset, 'null',
                    cellvids={ 1: 'trfromstates', 2: 'outofcontext', 3:'trsecurity',},
                    headers = (_('Transition'),  _('from_state'),
                               _('to_state'), _('permissions'), _('type') ),