[controllers] deprecate JSonController and implement AjaxController / ajax-func registry (closes #2110265)
authorAdrien Di Mascio <Adrien.DiMascio@logilab.fr>
Fri, 16 Dec 2011 12:30:12 +0100
changeset 8128 0a927fe4541b
parent 8125 7070250bf50d
child 8129 2dedcc15208d
[controllers] deprecate JSonController and implement AjaxController / ajax-func registry (closes #2110265)
devtools/testlib.py
doc/book/en/devweb/ajax.rst
doc/book/en/devweb/controllers.rst
doc/book/en/devweb/index.rst
doc/book/en/devweb/js.rst
web/application.py
web/request.py
web/test/unittest_views_basecontrollers.py
web/views/actions.py
web/views/ajaxcontroller.py
web/views/autoform.py
web/views/basecontrollers.py
web/views/bookmark.py
web/views/cwproperties.py
web/views/editcontroller.py
web/views/facets.py
web/views/forms.py
web/views/treeview.py
--- a/devtools/testlib.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/devtools/testlib.py	Fri Dec 16 12:30:12 2011 +0100
@@ -605,7 +605,7 @@
         dump = json.dumps
         args = [dump(arg) for arg in args]
         req = self.request(fname=fname, pageid='123', arg=args)
-        ctrl = self.vreg['controllers'].select('json', req)
+        ctrl = self.vreg['controllers'].select('ajax', req)
         return ctrl.publish(), req
 
     def app_publish(self, req, path='view'):
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/book/en/devweb/ajax.rst	Fri Dec 16 12:30:12 2011 +0100
@@ -0,0 +1,12 @@
+.. _ajax:
+
+Ajax
+----
+
+CubicWeb provides a few helpers to facilitate *javascript <-> python* communications.
+
+You can, for instance, register some python functions that will become
+callable from javascript through ajax calls. All the ajax URLs are handled
+by the ``AjaxController`` controller.
+
+.. automodule:: cubicweb.web.views.ajaxcontroller
--- a/doc/book/en/devweb/controllers.rst	Fri Dec 09 12:14:11 2011 +0100
+++ b/doc/book/en/devweb/controllers.rst	Fri Dec 16 12:30:12 2011 +0100
@@ -22,10 +22,6 @@
   :exc:`NoSelectableObject` errors that may bubble up to its entry point, in an
   end-user-friendly way (but other programming errors will slip through)
 
-* the JSon controller (same module) provides services for Ajax calls,
-  typically using JSON as a serialization format for input, and
-  sometimes using either JSON or XML for output;
-
 * the JSonpController is a wrapper around the ``ViewController`` that
   provides jsonp_ services. Padding can be specified with the
   ``callback`` request parameter. Only *jsonexport* / *ejsonexport*
@@ -36,10 +32,6 @@
 * the Login/Logout controllers make effective user login or logout
   requests
 
-.. warning::
-
-  JsonController will probably be renamed into AjaxController soon since
-  it has nothing to do with json per se.
 
 .. _jsonp: http://en.wikipedia.org/wiki/JSONP
 
@@ -64,6 +56,13 @@
 * the MailBugReport controller (web/views/basecontrollers.py) allows
   to quickly have a `reportbug` feature in one's application
 
+* the :class:`cubicweb.web.views.ajaxcontroller.AjaxController`
+  (:mod:`cubicweb.web.views.ajaxcontroller`) provides
+  services for Ajax calls, typically using JSON as a serialization format
+  for input, and sometimes using either JSON or XML for output. See
+  :ref:`ajax` chapter for more information.
+
+
 Registration
 ++++++++++++
 
--- a/doc/book/en/devweb/index.rst	Fri Dec 09 12:14:11 2011 +0100
+++ b/doc/book/en/devweb/index.rst	Fri Dec 16 12:30:12 2011 +0100
@@ -12,6 +12,7 @@
    request
    views/index
    rtags
+   ajax
    js
    css
    edition/index
--- a/doc/book/en/devweb/js.rst	Fri Dec 09 12:14:11 2011 +0100
+++ b/doc/book/en/devweb/js.rst	Fri Dec 16 12:30:12 2011 +0100
@@ -72,21 +72,22 @@
 A simple example with asyncRemoteExec
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-In the python side, we have to extend the ``BaseController``
-class. The ``@jsonize`` decorator ensures that the return value of the
-method is encoded as JSON data. By construction, the JSonController
-inputs everything in JSON format.
+On the python side, we have to define an
+:class:`cubicweb.web.views.ajaxcontroller.AjaxFunction` object. The
+simplest way to do that is to use the
+:func:`cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator (for more
+details on this, refer to :ref:`ajax`).
 
 .. sourcecode: python
 
-    from cubicweb.web.views.basecontrollers import JSonController, jsonize
+    from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
-    @monkeypatch(JSonController)
-    @jsonize
+    # serialize output to json to get it back easily on the javascript side
+    @ajaxfunc(output_type='json')
     def js_say_hello(self, name):
         return u'hello %s' % name
 
-In the javascript side, we do the asynchronous call. Notice how it
+On the javascript side, we do the asynchronous call. Notice how it
 creates a `deferred` object. Proper treatment of the return value or
 error handling has to be done through the addCallback and addErrback
 methods.
@@ -131,7 +132,7 @@
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 The server side implementation of `reloadComponent` is the
-js_component method of the JSonController.
+:func:`cubicweb.web.views.ajaxcontroller.component` *AjaxFunction* appobject.
 
 The following function implements a two-steps method to delete a
 standard bookmark and refresh the UI, while keeping the UI responsive.
@@ -166,7 +167,8 @@
 
 
 * `url` (mandatory) should be a complete url (typically referencing
-  the JSonController, but this is not strictly mandatory)
+  the :class:`cubicweb.web.views.ajaxcontroller.AjaxController`,
+  but this is not strictly mandatory)
 
 * `data` (optional) is a dictionary of values given to the
   controller specified through an `url` argument; some keys may have a
@@ -204,25 +206,23 @@
 
 .. sourcecode:: python
 
-    from cubicweb import typed_eid
-    from cubicweb.web.views.basecontrollers import JSonController, xhtmlize
+    from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
-    @monkeypatch(JSonController)
-    @xhtmlize
+    @ajaxfunc(output_type='xhtml')
     def js_frob_status(self, eid, frobname):
-        entity = self._cw.entity_from_eid(typed_eid(eid))
+        entity = self._cw.entity_from_eid(eid)
         return entity.view('frob', name=frobname)
 
 .. sourcecode:: javascript
 
-    function update_some_div(divid, eid, frobname) {
+    function updateSomeDiv(divid, eid, frobname) {
         var params = {fname:'frob_status', eid: eid, frobname:frobname};
         jQuery('#'+divid).loadxhtml(JSON_BASE_URL, params, 'post');
      }
 
 In this example, the url argument is the base json url of a cube
 instance (it should contain something like
-`http://myinstance/json?`). The actual JSonController method name is
+`http://myinstance/ajax?`). The actual AjaxController method name is
 encoded in the `params` dictionary using the `fname` key.
 
 A more real-life example
@@ -250,7 +250,7 @@
         w(u'</div>')
         self._cw.add_onload(u"""
             jQuery('#lazy-%(vid)s').bind('%(event)s', function() {
-                   load_now('#lazy-%(vid)s');});"""
+                   loadNow('#lazy-%(vid)s');});"""
             % {'event': 'load_%s' % vid, 'vid': vid})
 
 This creates a `div` with a specific event associated to it.
@@ -271,7 +271,7 @@
 
 .. sourcecode:: javascript
 
-    function load_now(eltsel) {
+    function loadNow(eltsel) {
         var lazydiv = jQuery(eltsel);
         lazydiv.loadxhtml(lazydiv.attr('cubicweb:loadurl'));
     }
@@ -306,18 +306,77 @@
         """trigger an event that will force immediate loading of the view
         on dom readyness
         """
-        self._cw.add_onload("trigger_load('%s');" % vid)
+        self._cw.add_onload("triggerLoad('%s');" % vid)
 
 The browser-side definition follows.
 
 .. sourcecode:: javascript
 
-    function trigger_load(divid) {
+    function triggerLoad(divid) {
         jQuery('#lazy-' + divd).trigger('load_' + divid);
     }
 
 
-.. XXX userCallback / user_callback
+python/ajax dynamic callbacks
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+CubicWeb provides a way to dynamically register a function and make it
+callable from the javascript side. The typical use case for this is a
+situation where you have everything at hand to implement an action
+(whether it be performing a RQL query or executing a few python
+statements) that you'd like to defer to a user click in the web
+interface.  In other words, generate an HTML ``<a href=...`` link that
+would execute your few lines of code.
+
+The trick is to create a python function and store this function in
+the user's session data. You will then be able to access it later.
+While this might sound hard to implement, it's actually quite easy
+thanks to the ``_cw.user_callback()``. This method takes a function,
+registers it and returns a javascript instruction suitable for
+``href`` or ``onclick`` usage. The call is then performed
+asynchronously.
+
+Here's a simplified example taken from the vcreview_ cube that will
+generate a link to change an entity state directly without the
+standard intermediate *comment / validate* step:
+
+.. sourcecode:: python
+
+    def entity_call(self, entity):
+        # [...]
+        def change_state(req, eid):
+            entity = req.entity_from_eid(eid)
+            entity.cw_adapt_to('IWorkflowable').fire_transition('done')
+        url = self._cw.user_callback(change_state, (entity.eid,))
+        self.w(tags.input(type='button', onclick=url, value=self._cw._('mark as done')))
+
+
+The ``change_state`` callback function is registered with
+``self._cw.user_callback()`` which returns the ``url`` value directly
+used for the ``onclick`` attribute of the button. On the javascript
+side, the ``userCallback()`` function is used but you most probably
+won't have to bother with it.
+
+Of course, when dealing with session data, the question of session
+cleaning pops up immediately. If you use ``user_callback()``, the
+registered function will be deleted automatically at some point
+as any other session data. If you want your function to be deleted once
+the web page is unloaded or when the user has clicked once on your link, then
+``_cw.register_onetime_callback()`` is what you need. It behaves as
+``_cw.user_callback()`` but stores the function in page data instead
+of global session data.
+
+
+.. Warning::
+
+  Be careful when registering functions with closures, keep in mind that
+  enclosed data will be kept in memory until the session gets cleared. Also,
+  if you keep entities or any object referecing the current ``req`` object, you
+  might have problems reusing them later because the underlying session
+  might have been closed at the time the callback gets executed.
+
+
+.. _vcreview: http://www.cubicweb.org/project/cubicweb-vcreview
 
 Javascript library: overview
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -356,12 +415,12 @@
 
 .. toctree::
     :maxdepth: 1
-    
+
     js_api/index
 
 
 Testing javascript
-~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~
 
 You with the ``cubicweb.qunit.QUnitTestCase`` can include standard Qunit tests
 inside the python unittest run . You simply have to define a new class that
--- a/web/application.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/application.py	Fri Dec 16 12:30:12 2011 +0100
@@ -450,7 +450,7 @@
         req.remove_header('Etag')
         req.reset_message()
         req.reset_headers()
-        if req.json_request:
+        if req.ajax_request:
             raise RemoteCallFailed(unicode(ex))
         try:
             req.data['ex'] = ex
--- a/web/request.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/request.py	Fri Dec 16 12:30:12 2011 +0100
@@ -82,7 +82,7 @@
 
 class CubicWebRequestBase(DBAPIRequest):
     """abstract HTTP request, should be extended according to the HTTP backend"""
-    json_request = False # to be set to True by json controllers
+    ajax_request = False # to be set to True by ajax controllers
 
     def __init__(self, vreg, https, form=None):
         super(CubicWebRequestBase, self).__init__(vreg)
@@ -122,6 +122,12 @@
         self.pageid = pid
 
     @property
+    def json_request(self):
+        warn('[3.15] self._cw.json_request is deprecated, use self._cw.ajax_request instead',
+             DeprecationWarning, stacklevel=2)
+        return self.ajax_request
+
+    @property
     def authmode(self):
         return self.vreg.config['auth-mode']
 
--- a/web/test/unittest_views_basecontrollers.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/test/unittest_views_basecontrollers.py	Fri Dec 16 12:30:12 2011 +0100
@@ -20,15 +20,19 @@
 from __future__ import with_statement
 
 from logilab.common.testlib import unittest_main, mock_object
+from logilab.common.decorators import monkeypatch
 
 from cubicweb import Binary, NoSelectableObject, ValidationError
 from cubicweb.view import STRICT_DOCTYPE
 from cubicweb.devtools.testlib import CubicWebTC
 from cubicweb.utils import json_dumps
 from cubicweb.uilib import rql_for_eid
-from cubicweb.web import INTERNAL_FIELD_VALUE, Redirect, RequestError
+from cubicweb.web import INTERNAL_FIELD_VALUE, Redirect, RequestError, RemoteCallFailed
 from cubicweb.entities.authobjs import CWUser
 from cubicweb.web.views.autoform import get_pending_inserts, get_pending_deletes
+from cubicweb.web.views.basecontrollers import JSonController, xhtmlize, jsonize
+from cubicweb.web.views.ajaxcontroller import ajaxfunc, AjaxFunction
+
 u = unicode
 
 def req_form(user):
@@ -557,11 +561,12 @@
 
 
 
-class JSONControllerTC(CubicWebTC):
+class AjaxControllerTC(CubicWebTC):
+    tested_controller = 'ajax'
 
     def ctrl(self, req=None):
         req = req or self.request(url='http://whatever.fr/')
-        return self.vreg['controllers'].select('json', req)
+        return self.vreg['controllers'].select(self.tested_controller, req)
 
     def setup_database(self):
         req = self.request()
@@ -679,8 +684,89 @@
         self.assertEqual(self.remote_call('format_date', '2007-01-01 12:00:00')[0],
                           json_dumps('2007/01/01'))
 
+    def test_ajaxfunc_noparameter(self):
+        @ajaxfunc
+        def foo(self, x, y):
+            return 'hello'
+        self.assertTrue(issubclass(foo, AjaxFunction))
+        self.assertEqual(foo.__regid__, 'foo')
+        self.assertEqual(foo.check_pageid, False)
+        self.assertEqual(foo.output_type, None)
+        req = self.request()
+        f = foo(req)
+        self.assertEqual(f(12, 13), 'hello')
+
+    def test_ajaxfunc_checkpageid(self):
+        @ajaxfunc( check_pageid=True)
+        def foo(self, x, y):
+            pass
+        self.assertTrue(issubclass(foo, AjaxFunction))
+        self.assertEqual(foo.__regid__, 'foo')
+        self.assertEqual(foo.check_pageid, True)
+        self.assertEqual(foo.output_type, None)
+        # no pageid
+        req = self.request()
+        f = foo(req)
+        self.assertRaises(RemoteCallFailed, f, 12, 13)
+
+    def test_ajaxfunc_json(self):
+        @ajaxfunc(output_type='json')
+        def foo(self, x, y):
+            return x + y
+        self.assertTrue(issubclass(foo, AjaxFunction))
+        self.assertEqual(foo.__regid__, 'foo')
+        self.assertEqual(foo.check_pageid, False)
+        self.assertEqual(foo.output_type, 'json')
+        # no pageid
+        req = self.request()
+        f = foo(req)
+        self.assertEqual(f(12, 13), '25')
 
 
+class JSonControllerTC(AjaxControllerTC):
+    # NOTE: this class performs the same tests as AjaxController but with
+    #       deprecated 'json' controller (i.e. check backward compatibility)
+    tested_controller = 'json'
+
+    def setUp(self):
+        super(JSonControllerTC, self).setUp()
+        self.exposed_remote_funcs = [fname for fname in dir(JSonController)
+                                     if fname.startswith('js_')]
+
+    def tearDown(self):
+        super(JSonControllerTC, self).tearDown()
+        for funcname in dir(JSonController):
+            # remove functions added dynamically during tests
+            if funcname.startswith('js_') and funcname not in self.exposed_remote_funcs:
+                delattr(JSonController, funcname)
+
+    def test_monkeypatch_jsoncontroller(self):
+        self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
+        @monkeypatch(JSonController)
+        def js_foo(self):
+            return u'hello'
+        res, req = self.remote_call('foo')
+        self.assertEqual(res, u'hello')
+
+    def test_monkeypatch_jsoncontroller_xhtmlize(self):
+        self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
+        @monkeypatch(JSonController)
+        @xhtmlize
+        def js_foo(self):
+            return u'hello'
+        res, req = self.remote_call('foo')
+        self.assertEqual(res,
+                         '<?xml version="1.0"?>\n' + STRICT_DOCTYPE +
+                         u'<div xmlns="http://www.w3.org/1999/xhtml" xmlns:cubicweb="http://www.logilab.org/2008/cubicweb">hello</div>')
+
+    def test_monkeypatch_jsoncontroller_jsonize(self):
+        self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
+        @monkeypatch(JSonController)
+        @jsonize
+        def js_foo(self):
+            return 12
+        res, req = self.remote_call('foo')
+        self.assertEqual(res, '12')
 
 if __name__ == '__main__':
     unittest_main()
--- a/web/views/actions.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/actions.py	Fri Dec 16 12:30:12 2011 +0100
@@ -130,7 +130,7 @@
         params = self._cw.form.copy()
         for param in ('vid', '__message') + controller.NAV_FORM_PARAMETERS:
             params.pop(param, None)
-        if self._cw.json_request:
+        if self._cw.ajax_request:
             path = 'view'
             if self.cw_rset is not None:
                 params = {'rql': self.cw_rset.printable_rql()}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/views/ajaxcontroller.py	Fri Dec 16 12:30:12 2011 +0100
@@ -0,0 +1,452 @@
+# 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.
+#
+# 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/>.
+#
+# (disable pylint msg for client obj access to protected member as in obj._cw)
+# pylint: disable=W0212
+"""The ``ajaxcontroller`` module defines the :class:`AjaxController`
+controller and the ``ajax-funcs`` cubicweb registry.
+
+.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxController
+   :members:
+
+``ajax-funcs`` registry hosts exposed remote functions, that is
+functions that can be called from the javascript world.
+
+To register a new remote function, either decorate your function
+with the :ref:`cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator:
+
+.. sourcecode:: python
+
+    from cubicweb.selectors import mactch_user_groups
+    from cubicweb.web.views.ajaxcontroller import ajaxfunc
+
+    @ajaxfunc(output_type='json', selector=match_user_groups('managers'))
+    def list_users(self):
+        return [u for (u,) in self._cw.execute('Any L WHERE U login L')]
+
+or inherit from :class:`cubicwbe.web.views.ajaxcontroller.AjaxFunction` and
+implement the ``__call__`` method:
+
+.. sourcecode:: python
+
+    from cubicweb.web.views.ajaxcontroller import AjaxFunction
+    class ListUser(AjaxFunction):
+        __regid__ = 'list_users' # __regid__ is the name of the exposed function
+        __select__ = match_user_groups('managers')
+        output_type = 'json'
+
+        def __call__(self):
+            return [u for (u, ) in self._cw.execute('Any L WHERE U login L')]
+
+
+.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxFunction
+   :members:
+
+.. autofunction:: cubicweb.web.views.ajaxcontroller.ajaxfunc
+
+"""
+
+__docformat__ = "restructuredtext en"
+
+from functools import partial
+
+from logilab.common.date import strptime
+from logilab.common.deprecation import deprecated
+
+from cubicweb import ObjectNotFound, NoSelectableObject
+from cubicweb.appobject import AppObject
+from cubicweb.selectors import yes
+from cubicweb.utils import json, json_dumps, UStringIO
+from cubicweb.uilib import exc_message
+from cubicweb.web import RemoteCallFailed, DirectResponse
+from cubicweb.web.controller import Controller
+from cubicweb.web.views import vid_from_rset
+from cubicweb.web.views import basecontrollers
+
+
+def optional_kwargs(extraargs):
+    if extraargs is None:
+        return {}
+    # we receive unicode keys which is not supported by the **syntax
+    return dict((str(key), value) for key, value in extraargs.iteritems())
+
+
+class AjaxController(Controller):
+    """AjaxController handles ajax remote calls from javascript
+
+    The following javascript function call:
+
+    .. sourcecode:: javascript
+
+      var d = asyncRemoteExec('foo', 12, "hello");
+      d.addCallback(function(result) {
+          alert('server response is: ' + result);
+      });
+
+    will generate an ajax HTTP GET on the following url::
+
+        BASE_URL/ajax?fname=foo&arg=12&arg="hello"
+
+    The AjaxController controller will therefore be selected to handle those URLs
+    and will itself select the :class:`cubicweb.web.views.ajaxcontroller.AjaxFunction`
+    matching the *fname* parameter.
+    """
+    __regid__ = 'ajax'
+
+    def publish(self, rset=None):
+        self._cw.ajax_request = True
+        try:
+            fname = self._cw.form['fname']
+        except KeyError:
+            raise RemoteCallFailed('no method specified')
+        try:
+            func = self._cw.vreg['ajax-func'].select(fname, self._cw)
+        except ObjectNotFound:
+            # function not found in the registry, inspect JSonController for
+            # backward compatibility
+            try:
+                func = getattr(basecontrollers.JSonController, 'js_%s' % fname).im_func
+                func = partial(func, self)
+            except AttributeError:
+                raise RemoteCallFailed('no %s method' % fname)
+            else:
+                self.warning('remote function %s found on JSonController, '
+                             'use AjaxFunction / @ajaxfunc instead', fname)
+        except NoSelectableObject:
+            raise RemoteCallFailed('method %s not available in this context'
+                                   % fname)
+        # no <arg> attribute means the callback takes no argument
+        args = self._cw.form.get('arg', ())
+        if not isinstance(args, (list, tuple)):
+            args = (args,)
+        try:
+            args = [json.loads(arg) for arg in args]
+        except ValueError, exc:
+            self.exception('error while decoding json arguments for '
+                           'js_%s: %s (err: %s)', fname, args, exc)
+            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
+        try:
+            result = func(*args)
+        except (RemoteCallFailed, DirectResponse):
+            raise
+        except Exception, exc:
+            self.exception('an exception occurred while calling js_%s(%s): %s',
+                           fname, args, exc)
+            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
+        if result is None:
+            return ''
+        # get unicode on @htmlize methods, encoded string on @jsonize methods
+        elif isinstance(result, unicode):
+            return result.encode(self._cw.encoding)
+        return result
+
+class AjaxFunction(AppObject):
+    """
+    Attributes on this base class are:
+
+    :attr: `check_pageid`: make sure the pageid received is valid before proceeding
+    :attr: `output_type`:
+
+           - *None*: no processing, no change on content-type
+
+           - *json*: serialize with `json_dumps` and set *application/json*
+                     content-type
+
+           - *xhtml*: wrap result in an XML node and forces HTML / XHTML
+                      content-type (use ``_cw.html_content_type()``)
+
+    """
+    __registry__ = 'ajax-func'
+    __select__ = yes()
+    __abstract__ = True
+
+    check_pageid = False
+    output_type = None
+
+    @staticmethod
+    def _rebuild_posted_form(names, values, action=None):
+        form = {}
+        for name, value in zip(names, values):
+            # remove possible __action_xxx inputs
+            if name.startswith('__action'):
+                if action is None:
+                    # strip '__action_' to get the actual action name
+                    action = name[9:]
+                continue
+            # form.setdefault(name, []).append(value)
+            if name in form:
+                curvalue = form[name]
+                if isinstance(curvalue, list):
+                    curvalue.append(value)
+                else:
+                    form[name] = [curvalue, value]
+            else:
+                form[name] = value
+        # simulate click on __action_%s button to help the controller
+        if action:
+            form['__action_%s' % action] = u'whatever'
+        return form
+
+    def validate_form(self, action, names, values):
+        self._cw.form = self._rebuild_posted_form(names, values, action)
+        return basecontrollers._validate_form(self._cw, self._cw.vreg)
+
+    def _exec(self, rql, args=None, rocheck=True):
+        """json mode: execute RQL and return resultset as json"""
+        rql = rql.strip()
+        if rql.startswith('rql:'):
+            rql = rql[4:]
+        if rocheck:
+            self._cw.ensure_ro_rql(rql)
+        try:
+            return self._cw.execute(rql, args)
+        except Exception, ex:
+            self.exception("error in _exec(rql=%s): %s", rql, ex)
+            return None
+        return None
+
+    def _call_view(self, view, paginate=False, **kwargs):
+        divid = self._cw.form.get('divid')
+        # we need to call pagination before with the stream set
+        try:
+            stream = view.set_stream()
+        except AttributeError:
+            stream = UStringIO()
+            kwargs['w'] = stream.write
+            assert not paginate
+        if divid == 'pageContent':
+            # ensure divid isn't reused by the view (e.g. table view)
+            del self._cw.form['divid']
+            # mimick main template behaviour
+            stream.write(u'<div id="pageContent">')
+            vtitle = self._cw.form.get('vtitle')
+            if vtitle:
+                stream.write(u'<h1 class="vtitle">%s</h1>\n' % vtitle)
+            paginate = True
+        nav_html = UStringIO()
+        if paginate and not view.handle_pagination:
+            view.paginate(w=nav_html.write)
+        stream.write(nav_html.getvalue())
+        if divid == 'pageContent':
+            stream.write(u'<div id="contentmain">')
+        view.render(**kwargs)
+        extresources = self._cw.html_headers.getvalue(skiphead=True)
+        if extresources:
+            stream.write(u'<div class="ajaxHtmlHead">\n') # XXX use a widget ?
+            stream.write(extresources)
+            stream.write(u'</div>\n')
+        if divid == 'pageContent':
+            stream.write(u'</div>%s</div>' % nav_html.getvalue())
+        return stream.getvalue()
+
+
+def _ajaxfunc_factory(implementation, selector=yes(), _output_type=None,
+                      _check_pageid=False, regid=None):
+    """converts a standard python function into an AjaxFunction appobject"""
+    class AnAjaxFunc(AjaxFunction):
+        __regid__ = regid or implementation.__name__
+        __select__ = selector
+        output_type = _output_type
+        check_pageid = _check_pageid
+
+        def serialize(self, content):
+            if self.output_type is None:
+                return content
+            elif self.output_type == 'xhtml':
+                self._cw.set_content_type(self._cw.html_content_type())
+                return ''.join((self._cw.document_surrounding_div(),
+                                content.strip(), u'</div>'))
+            elif self.output_type == 'json':
+                self._cw.set_content_type('application/json')
+                return json_dumps(content)
+            raise RemoteCallFailed('no serializer found for output type %s'
+                                   % self.output_type)
+
+        def __call__(self, *args, **kwargs):
+            if self.check_pageid:
+                data = self._cw.session.data.get(self._cw.pageid)
+                if data is None:
+                    raise RemoteCallFailed(self._cw._('pageid-not-found'))
+            return self.serialize(implementation(self, *args, **kwargs))
+    AnAjaxFunc.__name__ = implementation.__name__
+    # make sure __module__ refers to the original module otherwise
+    # vreg.register(obj) will ignore ``obj``.
+    AnAjaxFunc.__module__ = implementation.__module__
+    return AnAjaxFunc
+
+
+def ajaxfunc(implementation=None, selector=yes(), output_type=None,
+             check_pageid=False, regid=None):
+    """promote a standard function to an ``AjaxFunction`` appobject.
+
+    All parameters are optional:
+
+    :param selector: a custom selector object if needed, default is ``yes()``
+
+    :param output_type: either None, 'json' or 'xhtml' to customize output
+                        content-type. Default is None
+
+    :param check_pageid: whether the function requires a valid `pageid` or not
+                         to proceed. Default is False.
+
+    :param regid: a custom __regid__ for the created ``AjaxFunction`` object. Default
+                  is to keep the wrapped function name.
+
+    ``ajaxfunc`` can be used both as a standalone decorator:
+
+    .. sourcecode:: python
+
+        @ajaxfunc
+        def my_function(self):
+            return 42
+
+    or as a parametrizable decorator:
+
+    .. sourcecode:: python
+
+        @ajaxfunc(output_type='json')
+        def my_function(self):
+            return 42
+
+    """
+    # if used as a parametrized decorator (e.g. @ajaxfunc(output_type='json'))
+    if implementation is None:
+        def _decorator(func):
+            return _ajaxfunc_factory(func, selector=selector,
+                                     _output_type=output_type,
+                                     _check_pageid=check_pageid,
+                                     regid=regid)
+        return _decorator
+    # else, used as a standalone decorator (i.e. @ajaxfunc)
+    return _ajaxfunc_factory(implementation, selector=selector,
+                             _output_type=output_type,
+                             _check_pageid=check_pageid, regid=regid)
+
+
+
+###############################################################################
+#  Cubicweb remote functions for :                                            #
+#  - appobject rendering                                                      #
+#  - user / page session data management                                      #
+###############################################################################
+@ajaxfunc(output_type='xhtml')
+def view(self):
+    # XXX try to use the page-content template
+    req = self._cw
+    rql = req.form.get('rql')
+    if rql:
+        rset = self._exec(rql)
+    elif 'eid' in req.form:
+        rset = self._cw.eid_rset(req.form['eid'])
+    else:
+        rset = None
+    vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
+    try:
+        viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
+    except NoSelectableObject:
+        vid = req.form.get('fallbackvid', 'noresult')
+        viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
+    viewobj.set_http_cache_headers()
+    req.validate_cache()
+    return self._call_view(viewobj, paginate=req.form.pop('paginate', False))
+
+
+@ajaxfunc(output_type='xhtml')
+def component(self, compid, rql, registry='components', extraargs=None):
+    if rql:
+        rset = self._exec(rql)
+    else:
+        rset = None
+    # XXX while it sounds good, addition of the try/except below cause pb:
+    # when filtering using facets return an empty rset, the edition box
+    # isn't anymore selectable, as expected. The pb is that with the
+    # try/except below, we see a "an error occurred" message in the ui, while
+    # we don't see it without it. Proper fix would probably be to deal with
+    # this by allowing facet handling code to tell to js_component that such
+    # error is expected and should'nt be reported.
+    #try:
+    comp = self._cw.vreg[registry].select(compid, self._cw, rset=rset,
+                                          **optional_kwargs(extraargs))
+    #except NoSelectableObject:
+    #    raise RemoteCallFailed('unselectable')
+    return self._call_view(comp, **optional_kwargs(extraargs))
+
+@ajaxfunc(output_type='xhtml')
+def render(self, registry, oid, eid=None,
+              selectargs=None, renderargs=None):
+    if eid is not None:
+        rset = self._cw.eid_rset(eid)
+        # XXX set row=0
+    elif self._cw.form.get('rql'):
+        rset = self._cw.execute(self._cw.form['rql'])
+    else:
+        rset = None
+    viewobj = self._cw.vreg[registry].select(oid, self._cw, rset=rset,
+                                             **optional_kwargs(selectargs))
+    return self._call_view(viewobj, **optional_kwargs(renderargs))
+
+
+@ajaxfunc(output_type='json')
+def i18n(self, msgids):
+    """returns the translation of `msgid`"""
+    return [self._cw._(msgid) for msgid in msgids]
+
+@ajaxfunc(output_type='json')
+def format_date(self, strdate):
+    """returns the formatted date for `msgid`"""
+    date = strptime(strdate, '%Y-%m-%d %H:%M:%S')
+    return self._cw.format_date(date)
+
+@ajaxfunc(output_type='json')
+def external_resource(self, resource):
+    """returns the URL of the external resource named `resource`"""
+    return self._cw.uiprops[resource]
+
+@ajaxfunc(output_type='json', check_pageid=True)
+def user_callback(self, cbname):
+    """execute the previously registered user callback `cbname`.
+
+    If matching callback is not found, return None
+    """
+    page_data = self._cw.session.data.get(self._cw.pageid, {})
+    try:
+        cb = page_data[cbname]
+    except KeyError:
+        self.warning('unable to find user callback %s', cbname)
+        return None
+    return cb(self._cw)
+
+
+@ajaxfunc
+def unregister_user_callback(self, cbname):
+    """unregister user callback `cbname`"""
+    self._cw.unregister_callback(self._cw.pageid, cbname)
+
+@ajaxfunc
+def unload_page_data(self):
+    """remove user's session data associated to current pageid"""
+    self._cw.session.data.pop(self._cw.pageid, None)
+
+@ajaxfunc(output_type='json')
+@deprecated("[3.13] use jQuery.cookie(cookiename, cookievalue, {path: '/'}) in js land instead")
+def set_cookie(self, cookiename, cookievalue):
+    """generates the Set-Cookie HTTP reponse header corresponding
+    to `cookiename` / `cookievalue`.
+    """
+    cookiename, cookievalue = str(cookiename), str(cookievalue)
+    self._cw.set_cookie(cookiename, cookievalue)
--- a/web/views/autoform.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/autoform.py	Fri Dec 16 12:30:12 2011 +0100
@@ -134,10 +134,11 @@
 from cubicweb.selectors import (
     match_kwargs, match_form_params, non_final_entity,
     specified_etype_implements)
-from cubicweb.utils import json_dumps
+from cubicweb.utils import json, json_dumps
 from cubicweb.web import (stdmsgs, uicfg, eid_param,
                           form as f, formwidgets as fw, formfields as ff)
 from cubicweb.web.views import forms
+from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
 _AFS = uicfg.autoform_section
 _AFFK = uicfg.autoform_field_kwargs
@@ -437,6 +438,70 @@
         execute(rql, {'x': subj, 'y': obj})
 
 
+# ajax edition helpers ########################################################
+@ajaxfunc(output_type='xhtml', check_pageid=True)
+def inline_creation_form(self, peid, petype, ttype, rtype, role, i18nctx):
+    view = self._cw.vreg['views'].select('inline-creation', self._cw,
+                                         etype=ttype, rtype=rtype, role=role,
+                                         peid=peid, petype=petype)
+    return self._call_view(view, i18nctx=i18nctx)
+
+@ajaxfunc(output_type='json')
+def validate_form(self, action, names, values):
+    return self.validate_form(action, names, values)
+
+@ajaxfunc
+def cancel_edition(self, errorurl):
+    """cancelling edition from javascript
+
+    We need to clear associated req's data :
+      - errorurl
+      - pending insertions / deletions
+    """
+    self._cw.cancel_edition(errorurl)
+
+@ajaxfunc(output_type='xhtml')
+def reledit_form(self):
+    req = self._cw
+    args = dict((x, req.form[x])
+                for x in ('formid', 'rtype', 'role', 'reload', 'action'))
+    rset = req.eid_rset(typed_eid(self._cw.form['eid']))
+    try:
+        args['reload'] = json.loads(args['reload'])
+    except ValueError: # not true/false, an absolute url
+        assert args['reload'].startswith('http')
+    view = req.vreg['views'].select('reledit', req, rset=rset, rtype=args['rtype'])
+    return self._call_view(view, **args)
+
+
+def _add_pending(req, eidfrom, rel, eidto, kind):
+    key = 'pending_%s' % kind
+    pendings = req.session.data.setdefault(key, set())
+    pendings.add( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
+
+def _remove_pending(req, eidfrom, rel, eidto, kind):
+    key = 'pending_%s' % kind
+    pendings = req.session.data[key]
+    pendings.remove( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
+
+@ajaxfunc(output_type='json')
+def remove_pending_insert(self, (eidfrom, rel, eidto)):
+    _remove_pending(self._cw, eidfrom, rel, eidto, 'insert')
+
+@ajaxfunc(output_type='json')
+def add_pending_inserts(self, tripletlist):
+    for eidfrom, rel, eidto in tripletlist:
+        _add_pending(self._cw, eidfrom, rel, eidto, 'insert')
+
+@ajaxfunc(output_type='json')
+def remove_pending_delete(self, (eidfrom, rel, eidto)):
+    _remove_pending(self._cw, eidfrom, rel, eidto, 'delete')
+
+@ajaxfunc(output_type='json')
+def add_pending_delete(self, (eidfrom, rel, eidto)):
+    _add_pending(self._cw, eidfrom, rel, eidto, 'delete')
+
+
 class GenericRelationsWidget(fw.FieldWidget):
 
     def render(self, form, field, renderer):
--- a/web/views/basecontrollers.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/basecontrollers.py	Fri Dec 16 12:30:12 2011 +0100
@@ -22,20 +22,21 @@
 __docformat__ = "restructuredtext en"
 _ = unicode
 
-from logilab.common.date import strptime
+from warnings import warn
+
 from logilab.common.deprecation import deprecated
 
 from cubicweb import (NoSelectableObject, ObjectNotFound, ValidationError,
                       AuthenticationError, typed_eid)
-from cubicweb.utils import UStringIO, json, json_dumps
-from cubicweb.uilib import exc_message
-from cubicweb.selectors import authenticated_user, anonymous_user, match_form_params
-from cubicweb.mail import format_mail
-from cubicweb.web import Redirect, RemoteCallFailed, DirectResponse, facet
+from cubicweb.utils import json_dumps
+from cubicweb.selectors import (authenticated_user, anonymous_user,
+                                match_form_params)
+from cubicweb.web import Redirect, RemoteCallFailed
 from cubicweb.web.controller import Controller
-from cubicweb.web.views import vid_from_rset, formrenderers
+from cubicweb.web.views import vid_from_rset
 
 
+@deprecated('jsonize is deprecated, use AjaxFunction appobjects instead')
 def jsonize(func):
     """decorator to sets correct content_type and calls `json_dumps` on
     results
@@ -46,6 +47,7 @@
     wrapper.__name__ = func.__name__
     return wrapper
 
+@deprecated('xhtmlize is deprecated, use AjaxFunction appobjects instead')
 def xhtmlize(func):
     """decorator to sets correct content_type and calls `xmlize` on results"""
     def wrapper(self, *args, **kwargs):
@@ -56,6 +58,7 @@
     wrapper.__name__ = func.__name__
     return wrapper
 
+@deprecated('check_pageid is deprecated, use AjaxFunction appobjects instead')
 def check_pageid(func):
     """decorator which checks the given pageid is found in the
     user's session data
@@ -234,7 +237,7 @@
 </script>""" %  (domid, callback, errback, jsargs, cbargs)
 
     def publish(self, rset=None):
-        self._cw.json_request = True
+        self._cw.ajax_request = True
         # XXX unclear why we have a separated controller here vs
         # js_validate_form on the json controller
         status, args, entity = _validate_form(self._cw, self._cw.vreg)
@@ -242,339 +245,18 @@
             self._cw.encoding)
         return self.response(domid, status, args, entity)
 
-def optional_kwargs(extraargs):
-    if extraargs is None:
-        return {}
-    # we receive unicode keys which is not supported by the **syntax
-    return dict((str(key), value) for key, value in extraargs.iteritems())
-
 
 class JSonController(Controller):
     __regid__ = 'json'
 
     def publish(self, rset=None):
-        """call js_* methods. Expected form keys:
-
-        :fname: the method name without the js_ prefix
-        :args: arguments list (json)
-
-        note: it's the responsability of js_* methods to set the correct
-        response content type
-        """
-        self._cw.json_request = True
-        try:
-            fname = self._cw.form['fname']
-            func = getattr(self, 'js_%s' % fname)
-        except KeyError:
-            raise RemoteCallFailed('no method specified')
-        except AttributeError:
-            raise RemoteCallFailed('no %s method' % fname)
-        # no <arg> attribute means the callback takes no argument
-        args = self._cw.form.get('arg', ())
-        if not isinstance(args, (list, tuple)):
-            args = (args,)
-        try:
-            args = [json.loads(arg) for arg in args]
-        except ValueError, exc:
-            self.exception('error while decoding json arguments for js_%s: %s (err: %s)',
-                           fname, args, exc)
-            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
-        try:
-            result = func(*args)
-        except (RemoteCallFailed, DirectResponse):
-            raise
-        except Exception, exc:
-            self.exception('an exception occurred while calling js_%s(%s): %s',
-                           fname, args, exc)
-            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
-        if result is None:
-            return ''
-        # get unicode on @htmlize methods, encoded string on @jsonize methods
-        elif isinstance(result, unicode):
-            return result.encode(self._cw.encoding)
-        return result
-
-    def _rebuild_posted_form(self, names, values, action=None):
-        form = {}
-        for name, value in zip(names, values):
-            # remove possible __action_xxx inputs
-            if name.startswith('__action'):
-                if action is None:
-                    # strip '__action_' to get the actual action name
-                    action = name[9:]
-                continue
-            # form.setdefault(name, []).append(value)
-            if name in form:
-                curvalue = form[name]
-                if isinstance(curvalue, list):
-                    curvalue.append(value)
-                else:
-                    form[name] = [curvalue, value]
-            else:
-                form[name] = value
-        # simulate click on __action_%s button to help the controller
-        if action:
-            form['__action_%s' % action] = u'whatever'
-        return form
-
-    def _exec(self, rql, args=None, rocheck=True):
-        """json mode: execute RQL and return resultset as json"""
-        rql = rql.strip()
-        if rql.startswith('rql:'):
-            rql = rql[4:]
-        if rocheck:
-            self._cw.ensure_ro_rql(rql)
-        try:
-            return self._cw.execute(rql, args)
-        except Exception, ex:
-            self.exception("error in _exec(rql=%s): %s", rql, ex)
-            return None
-        return None
-
-    def _call_view(self, view, paginate=False, **kwargs):
-        divid = self._cw.form.get('divid')
-        # we need to call pagination before with the stream set
-        try:
-            stream = view.set_stream()
-        except AttributeError:
-            stream = UStringIO()
-            kwargs['w'] = stream.write
-            assert not paginate
-        if divid == 'pageContent':
-            # ensure divid isn't reused by the view (e.g. table view)
-            del self._cw.form['divid']
-            # mimick main template behaviour
-            stream.write(u'<div id="pageContent">')
-            vtitle = self._cw.form.get('vtitle')
-            if vtitle:
-                stream.write(u'<h1 class="vtitle">%s</h1>\n' % vtitle)
-            paginate = True
-        nav_html = UStringIO()
-        if paginate and not view.handle_pagination:
-            view.paginate(w=nav_html.write)
-        stream.write(nav_html.getvalue())
-        if divid == 'pageContent':
-            stream.write(u'<div id="contentmain">')
-        view.render(**kwargs)
-        extresources = self._cw.html_headers.getvalue(skiphead=True)
-        if extresources:
-            stream.write(u'<div class="ajaxHtmlHead">\n') # XXX use a widget ?
-            stream.write(extresources)
-            stream.write(u'</div>\n')
-        if divid == 'pageContent':
-            stream.write(u'</div>%s</div>' % nav_html.getvalue())
-        return stream.getvalue()
-
-    @xhtmlize
-    def js_view(self):
-        # XXX try to use the page-content template
-        req = self._cw
-        rql = req.form.get('rql')
-        if rql:
-            rset = self._exec(rql)
-        elif 'eid' in req.form:
-            rset = self._cw.eid_rset(req.form['eid'])
-        else:
-            rset = None
-        vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
-        try:
-            view = self._cw.vreg['views'].select(vid, req, rset=rset)
-        except NoSelectableObject:
-            vid = req.form.get('fallbackvid', 'noresult')
-            view = self._cw.vreg['views'].select(vid, req, rset=rset)
-        self.validate_cache(view)
-        return self._call_view(view, paginate=req.form.pop('paginate', False))
-
-    @xhtmlize
-    def js_prop_widget(self, propkey, varname, tabindex=None):
-        """specific method for CWProperty handling"""
-        entity = self._cw.vreg['etypes'].etype_class('CWProperty')(self._cw)
-        entity.eid = varname
-        entity['pkey'] = propkey
-        form = self._cw.vreg['forms'].select('edition', self._cw, entity=entity)
-        form.build_context()
-        vfield = form.field_by_name('value')
-        renderer = formrenderers.FormRenderer(self._cw)
-        return vfield.render(form, renderer, tabindex=tabindex) \
-               + renderer.render_help(form, vfield)
-
-    @xhtmlize
-    def js_component(self, compid, rql, registry='components', extraargs=None):
-        if rql:
-            rset = self._exec(rql)
-        else:
-            rset = None
-        # XXX while it sounds good, addition of the try/except below cause pb:
-        # when filtering using facets return an empty rset, the edition box
-        # isn't anymore selectable, as expected. The pb is that with the
-        # try/except below, we see a "an error occurred" message in the ui, while
-        # we don't see it without it. Proper fix would probably be to deal with
-        # this by allowing facet handling code to tell to js_component that such
-        # error is expected and should'nt be reported.
-        #try:
-        comp = self._cw.vreg[registry].select(compid, self._cw, rset=rset,
-                                              **optional_kwargs(extraargs))
-        #except NoSelectableObject:
-        #    raise RemoteCallFailed('unselectable')
-        return self._call_view(comp, **optional_kwargs(extraargs))
-
-    @xhtmlize
-    def js_render(self, registry, oid, eid=None,
-                  selectargs=None, renderargs=None):
-        if eid is not None:
-            rset = self._cw.eid_rset(eid)
-            # XXX set row=0
-        elif self._cw.form.get('rql'):
-            rset = self._cw.execute(self._cw.form['rql'])
-        else:
-            rset = None
-        view = self._cw.vreg[registry].select(oid, self._cw, rset=rset,
-                                              **optional_kwargs(selectargs))
-        return self._call_view(view, **optional_kwargs(renderargs))
-
-    @check_pageid
-    @xhtmlize
-    def js_inline_creation_form(self, peid, petype, ttype, rtype, role, i18nctx):
-        view = self._cw.vreg['views'].select('inline-creation', self._cw,
-                                             etype=ttype, rtype=rtype, role=role,
-                                             peid=peid, petype=petype)
-        return self._call_view(view, i18nctx=i18nctx)
-
-    @jsonize
-    def js_validate_form(self, action, names, values):
-        return self.validate_form(action, names, values)
-
-    def validate_form(self, action, names, values):
-        self._cw.form = self._rebuild_posted_form(names, values, action)
-        return _validate_form(self._cw, self._cw.vreg)
-
-    @xhtmlize
-    def js_reledit_form(self):
-        req = self._cw
-        args = dict((x, req.form[x])
-                    for x in ('formid', 'rtype', 'role', 'reload', 'action'))
-        rset = req.eid_rset(typed_eid(self._cw.form['eid']))
-        try:
-            args['reload'] = json.loads(args['reload'])
-        except ValueError: # not true/false, an absolute url
-            assert args['reload'].startswith('http')
-        view = req.vreg['views'].select('reledit', req, rset=rset, rtype=args['rtype'])
-        return self._call_view(view, **args)
-
-    @jsonize
-    def js_i18n(self, msgids):
-        """returns the translation of `msgid`"""
-        return [self._cw._(msgid) for msgid in msgids]
-
-    @jsonize
-    def js_format_date(self, strdate):
-        """returns the formatted date for `msgid`"""
-        date = strptime(strdate, '%Y-%m-%d %H:%M:%S')
-        return self._cw.format_date(date)
-
-    @jsonize
-    def js_external_resource(self, resource):
-        """returns the URL of the external resource named `resource`"""
-        return self._cw.uiprops[resource]
-
-    @check_pageid
-    @jsonize
-    def js_user_callback(self, cbname):
-        page_data = self._cw.session.data.get(self._cw.pageid, {})
-        try:
-            cb = page_data[cbname]
-        except KeyError:
-            return None
-        return cb(self._cw)
-
-    @jsonize
-    def js_filter_build_rql(self, names, values):
-        form = self._rebuild_posted_form(names, values)
-        self._cw.form = form
-        builder = facet.FilterRQLBuilder(self._cw)
-        return builder.build_rql()
-
-    @jsonize
-    def js_filter_select_content(self, facetids, rql, mainvar):
-        # Union unsupported yet
-        select = self._cw.vreg.parse(self._cw, rql).children[0]
-        filtered_variable = facet.get_filtered_variable(select, mainvar)
-        facet.prepare_select(select, filtered_variable)
-        update_map = {}
-        for fid in facetids:
-            fobj = facet.get_facet(self._cw, fid, select, filtered_variable)
-            update_map[fid] = fobj.possible_values()
-        return update_map
-
-    def js_unregister_user_callback(self, cbname):
-        self._cw.unregister_callback(self._cw.pageid, cbname)
-
-    def js_unload_page_data(self):
-        self._cw.session.data.pop(self._cw.pageid, None)
-
-    def js_cancel_edition(self, errorurl):
-        """cancelling edition from javascript
-
-        We need to clear associated req's data :
-          - errorurl
-          - pending insertions / deletions
-        """
-        self._cw.cancel_edition(errorurl)
-
-    def js_delete_bookmark(self, beid):
-        rql = 'DELETE B bookmarked_by U WHERE B eid %(b)s, U eid %(u)s'
-        self._cw.execute(rql, {'b': typed_eid(beid), 'u' : self._cw.user.eid})
-
-    def js_node_clicked(self, treeid, nodeeid):
-        """add/remove eid in treestate cookie"""
-        from cubicweb.web.views.treeview import treecookiename
-        cookies = self._cw.get_cookie()
-        statename = treecookiename(treeid)
-        treestate = cookies.get(statename)
-        if treestate is None:
-            self._cw.set_cookie(statename, nodeeid)
-        else:
-            marked = set(filter(None, treestate.value.split(':')))
-            if nodeeid in marked:
-                marked.remove(nodeeid)
-            else:
-                marked.add(nodeeid)
-            self._cw.set_cookie(statename, ':'.join(marked))
-
-    @jsonize
-    @deprecated("[3.13] use jQuery.cookie(cookiename, cookievalue, {path: '/'}) in js land instead")
-    def js_set_cookie(self, cookiename, cookievalue):
-        cookiename, cookievalue = str(cookiename), str(cookievalue)
-        self._cw.set_cookie(cookiename, cookievalue)
-
-    # relations edition stuff ##################################################
-
-    def _add_pending(self, eidfrom, rel, eidto, kind):
-        key = 'pending_%s' % kind
-        pendings = self._cw.session.data.setdefault(key, set())
-        pendings.add( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
-
-    def _remove_pending(self, eidfrom, rel, eidto, kind):
-        key = 'pending_%s' % kind
-        pendings = self._cw.session.data[key]
-        pendings.remove( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
-
-    def js_remove_pending_insert(self, (eidfrom, rel, eidto)):
-        self._remove_pending(eidfrom, rel, eidto, 'insert')
-
-    def js_add_pending_inserts(self, tripletlist):
-        for eidfrom, rel, eidto in tripletlist:
-            self._add_pending(eidfrom, rel, eidto, 'insert')
-
-    def js_remove_pending_delete(self, (eidfrom, rel, eidto)):
-        self._remove_pending(eidfrom, rel, eidto, 'delete')
-
-    def js_add_pending_delete(self, (eidfrom, rel, eidto)):
-        self._add_pending(eidfrom, rel, eidto, 'delete')
+        warn('[3.15] JSONController is deprecated, use AjaxController instead',
+             DeprecationWarning)
+        ajax_controller = self._cw.vreg['controllers'].select('ajax', self._cw, appli=self.appli)
+        return ajax_controller.publish(rset)
 
 
 # XXX move to massmailing
-
 class MailBugReportController(Controller):
     __regid__ = 'reportbug'
     __select__ = match_form_params('description')
--- a/web/views/bookmark.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/bookmark.py	Fri Dec 16 12:30:12 2011 +0100
@@ -22,11 +22,12 @@
 
 from logilab.mtconverter import xml_escape
 
-from cubicweb import Unauthorized
+from cubicweb import Unauthorized, typed_eid
 from cubicweb.selectors import is_instance, one_line_rset
 from cubicweb.web import (action, component, uicfg, htmlwidgets,
                           formwidgets as fw)
 from cubicweb.web.views import primary
+from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
 _abaa = uicfg.actionbox_appearsin_addmenu
 _abaa.tag_subject_of(('*', 'bookmarked_by', '*'), False)
@@ -133,3 +134,8 @@
             menu.append(self.link(req._('pick existing bookmarks'), url))
             self.append(menu)
         self.render_items(w)
+
+@ajaxfunc
+def delete_bookmark(self, beid):
+    rql = 'DELETE B bookmarked_by U WHERE B eid %(b)s, U eid %(u)s'
+    self._cw.execute(rql, {'b': typed_eid(beid), 'u' : self._cw.user.eid})
--- a/web/views/cwproperties.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/cwproperties.py	Fri Dec 16 12:30:12 2011 +0100
@@ -35,6 +35,7 @@
 from cubicweb.web.formwidgets import (Select, TextInput, Button, SubmitButton,
                                       FieldWidget)
 from cubicweb.web.views import primary, formrenderers, editcontroller
+from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
 uicfg.primaryview_section.tag_object_of(('*', 'for_user', '*'), 'hidden')
 
@@ -419,6 +420,20 @@
         """
         return 'view', {}
 
+
+@ajaxfunc(output_type='xhtml')
+def prop_widget(self, propkey, varname, tabindex=None):
+    """specific method for CWProperty handling"""
+    entity = self._cw.vreg['etypes'].etype_class('CWProperty')(self._cw)
+    entity.eid = varname
+    entity['pkey'] = propkey
+    form = self._cw.vreg['forms'].select('edition', self._cw, entity=entity)
+    form.build_context()
+    vfield = form.field_by_name('value')
+    renderer = formrenderers.FormRenderer(self._cw)
+    return vfield.render(form, renderer, tabindex=tabindex) \
+           + renderer.render_help(form, vfield)
+
 _afs = uicfg.autoform_section
 _afs.tag_subject_of(('*', 'for_user', '*'), 'main', 'hidden')
 _afs.tag_object_of(('*', 'for_user', '*'), 'main', 'hidden')
--- a/web/views/editcontroller.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/editcontroller.py	Fri Dec 16 12:30:12 2011 +0100
@@ -161,7 +161,7 @@
             neweid = entity.eid
         except ValidationError, ex:
             self._to_create[eid] = ex.entity
-            if self._cw.json_request: # XXX (syt) why?
+            if self._cw.ajax_request: # XXX (syt) why?
                 ex.entity = eid
             raise
         self._to_create[eid] = neweid
--- a/web/views/facets.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/facets.py	Fri Dec 16 12:30:12 2011 +0100
@@ -32,6 +32,7 @@
 from cubicweb.uilib import css_em_num_value
 from cubicweb.view import AnyRsetView
 from cubicweb.web import component, facet as facetbase
+from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
 def facets(req, rset, context, mainvar=None, **kwargs):
     """return the base rql and a list of widgets for facets applying to the
@@ -313,6 +314,28 @@
             w(u'</div>')
         w(u'</div>\n')
 
+# python-ajax remote functions used by facet widgets #########################
+
+@ajaxfunc(output_type='json')
+def filter_build_rql(self, names, values):
+    form = self._rebuild_posted_form(names, values)
+    self._cw.form = form
+    builder = facetbase.FilterRQLBuilder(self._cw)
+    return builder.build_rql()
+
+@ajaxfunc(output_type='json')
+def filter_select_content(self, facetids, rql, mainvar):
+    # Union unsupported yet
+    select = self._cw.vreg.parse(self._cw, rql).children[0]
+    filtered_variable = facetbase.get_filtered_variable(select, mainvar)
+    facetbase.prepare_select(select, filtered_variable)
+    update_map = {}
+    for fid in facetids:
+        fobj = facetbase.get_facet(self._cw, fid, select, filtered_variable)
+        update_map[fid] = fobj.possible_values()
+    return update_map
+
+
 
 # facets ######################################################################
 
--- a/web/views/forms.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/forms.py	Fri Dec 16 12:30:12 2011 +0100
@@ -406,7 +406,7 @@
             return self.force_session_key
         # XXX if this is a json request, suppose we should redirect to the
         # entity primary view
-        if self._cw.json_request and self.edited_entity.has_eid():
+        if self._cw.ajax_request and self.edited_entity.has_eid():
             return '%s#%s' % (self.edited_entity.absolute_url(), self.domid)
         # XXX we should not consider some url parameters that may lead to
         # different url after a validation error
--- a/web/views/treeview.py	Fri Dec 09 12:14:11 2011 +0100
+++ b/web/views/treeview.py	Fri Dec 16 12:30:12 2011 +0100
@@ -31,6 +31,7 @@
 from cubicweb.view import EntityView
 from cubicweb.mixins import _done_init
 from cubicweb.web.views import baseviews
+from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
 def treecookiename(treeid):
     return str('%s-treestate' % treeid)
@@ -280,3 +281,20 @@
                        treeid=treeid, initial_load=False, **morekwargs)
         w(u'</li>')
 
+
+
+@ajaxfunc
+def node_clicked(self, treeid, nodeeid):
+    """add/remove eid in treestate cookie"""
+    cookies = self._cw.get_cookie()
+    statename = treecookiename(treeid)
+    treestate = cookies.get(statename)
+    if treestate is None:
+        self._cw.set_cookie(statename, nodeeid)
+    else:
+        marked = set(filter(None, treestate.value.split(':')))
+        if nodeeid in marked:
+            marked.remove(nodeeid)
+        else:
+            marked.add(nodeeid)
+        self._cw.set_cookie(statename, ':'.join(marked))