[controllers] deprecate JSonController and implement AjaxController / ajax-func registry (closes #2110265)

authorAdrien Di Mascio <Adrien.DiMascio@logilab.fr>
changeset0a927fe4541b
branchdefault
phasepublic
hiddenno
parent revision#7070250bf50d [schema] React to yams improvement of metadata attribute handling.
child revision#2dedcc15208d [table] use cell text content when cubicweb:sortvalue is not defined (closes #2093183)
files modified by this revision
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
# HG changeset patch
# User Adrien Di Mascio <Adrien.DiMascio@logilab.fr>
# Date 1324035012 -3600
# Fri Dec 16 12:30:12 2011 +0100
# Node ID 0a927fe4541b8ce0b611b939bf425ed59ed86dc6
# Parent 7070250bf50de6ddf153ad991c3f5b6fb53d8cbf
[controllers] deprecate JSonController and implement AjaxController / ajax-func registry (closes #2110265)

diff --git a/devtools/testlib.py b/devtools/testlib.py
@@ -603,11 +603,11 @@
1      def remote_call(self, fname, *args):
2          """remote json call simulation"""
3          dump = json.dumps
4          args = [dump(arg) for arg in args]
5          req = self.request(fname=fname, pageid='123', arg=args)
6 -        ctrl = self.vreg['controllers'].select('json', req)
7 +        ctrl = self.vreg['controllers'].select('ajax', req)
8          return ctrl.publish(), req
9 
10      def app_publish(self, req, path='view'):
11          return self.app.publish(path, req)
12 
diff --git a/doc/book/en/devweb/ajax.rst b/doc/book/en/devweb/ajax.rst
@@ -0,0 +1,12 @@
13 +.. _ajax:
14 +
15 +Ajax
16 +----
17 +
18 +CubicWeb provides a few helpers to facilitate *javascript <-> python* communications.
19 +
20 +You can, for instance, register some python functions that will become
21 +callable from javascript through ajax calls. All the ajax URLs are handled
22 +by the ``AjaxController`` controller.
23 +
24 +.. automodule:: cubicweb.web.views.ajaxcontroller
diff --git a/doc/book/en/devweb/controllers.rst b/doc/book/en/devweb/controllers.rst
@@ -20,28 +20,20 @@
25    :ref:`the_main_template_layout` and lets the ResultSet/Views dispatch system
26    build up the whole content; it handles :exc:`ObjectNotFound` and
27    :exc:`NoSelectableObject` errors that may bubble up to its entry point, in an
28    end-user-friendly way (but other programming errors will slip through)
29 
30 -* the JSon controller (same module) provides services for Ajax calls,
31 -  typically using JSON as a serialization format for input, and
32 -  sometimes using either JSON or XML for output;
33 -
34  * the JSonpController is a wrapper around the ``ViewController`` that
35    provides jsonp_ services. Padding can be specified with the
36    ``callback`` request parameter. Only *jsonexport* / *ejsonexport*
37    views can be used. If another ``vid`` is specified, it will be
38    ignored and replaced by *jsonexport*. Request is anonymized
39    to avoid returning sensitive data and reduce the risks of CSRF attacks;
40 
41  * the Login/Logout controllers make effective user login or logout
42    requests
43 
44 -.. warning::
45 -
46 -  JsonController will probably be renamed into AjaxController soon since
47 -  it has nothing to do with json per se.
48 
49  .. _jsonp: http://en.wikipedia.org/wiki/JSONP
50 
51  `Edition`:
52 
@@ -62,10 +54,17 @@
53    for outgoing email notifications
54 
55  * the MailBugReport controller (web/views/basecontrollers.py) allows
56    to quickly have a `reportbug` feature in one's application
57 
58 +* the :class:`cubicweb.web.views.ajaxcontroller.AjaxController`
59 +  (:mod:`cubicweb.web.views.ajaxcontroller`) provides
60 +  services for Ajax calls, typically using JSON as a serialization format
61 +  for input, and sometimes using either JSON or XML for output. See
62 +  :ref:`ajax` chapter for more information.
63 +
64 +
65  Registration
66  ++++++++++++
67 
68  All controllers (should) live in the 'controllers' namespace within
69  the global registry.
diff --git a/doc/book/en/devweb/index.rst b/doc/book/en/devweb/index.rst
@@ -10,10 +10,11 @@
70     publisher
71     controllers
72     request
73     views/index
74     rtags
75 +   ajax
76     js
77     css
78     edition/index
79     facets
80     internationalization
diff --git a/doc/book/en/devweb/js.rst b/doc/book/en/devweb/js.rst
@@ -70,25 +70,26 @@
81 
82 
83  A simple example with asyncRemoteExec
84  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
85 
86 -In the python side, we have to extend the ``BaseController``
87 -class. The ``@jsonize`` decorator ensures that the return value of the
88 -method is encoded as JSON data. By construction, the JSonController
89 -inputs everything in JSON format.
90 +On the python side, we have to define an
91 +:class:`cubicweb.web.views.ajaxcontroller.AjaxFunction` object. The
92 +simplest way to do that is to use the
93 +:func:`cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator (for more
94 +details on this, refer to :ref:`ajax`).
95 
96  .. sourcecode: python
97 
98 -    from cubicweb.web.views.basecontrollers import JSonController, jsonize
99 +    from cubicweb.web.views.ajaxcontroller import ajaxfunc
100 
101 -    @monkeypatch(JSonController)
102 -    @jsonize
103 +    # serialize output to json to get it back easily on the javascript side
104 +    @ajaxfunc(output_type='json')
105      def js_say_hello(self, name):
106          return u'hello %s' % name
107 
108 -In the javascript side, we do the asynchronous call. Notice how it
109 +On the javascript side, we do the asynchronous call. Notice how it
110  creates a `deferred` object. Proper treatment of the return value or
111  error handling has to be done through the addCallback and addErrback
112  methods.
113 
114  .. sourcecode: javascript
@@ -129,11 +130,11 @@
115 
116  A simple reloadComponent example
117  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
118 
119  The server side implementation of `reloadComponent` is the
120 -js_component method of the JSonController.
121 +:func:`cubicweb.web.views.ajaxcontroller.component` *AjaxFunction* appobject.
122 
123  The following function implements a two-steps method to delete a
124  standard bookmark and refresh the UI, while keeping the UI responsive.
125 
126  .. sourcecode:: javascript
@@ -164,11 +165,12 @@
127 
128  .. _`jQuery.load`: http://api.jquery.com/load/
129 
130 
131  * `url` (mandatory) should be a complete url (typically referencing
132 -  the JSonController, but this is not strictly mandatory)
133 +  the :class:`cubicweb.web.views.ajaxcontroller.AjaxController`,
134 +  but this is not strictly mandatory)
135 
136  * `data` (optional) is a dictionary of values given to the
137    controller specified through an `url` argument; some keys may have a
138    special meaning depending on the choosen controller (such as `fname`
139    for the JSonController); the `callback` key, if present, must refer
@@ -202,29 +204,27 @@
140  injected in the live DOM. The view will be of course selected
141  server-side using an entity eid provided by the client side.
142 
143  .. sourcecode:: python
144 
145 -    from cubicweb import typed_eid
146 -    from cubicweb.web.views.basecontrollers import JSonController, xhtmlize
147 +    from cubicweb.web.views.ajaxcontroller import ajaxfunc
148 
149 -    @monkeypatch(JSonController)
150 -    @xhtmlize
151 +    @ajaxfunc(output_type='xhtml')
152      def js_frob_status(self, eid, frobname):
153 -        entity = self._cw.entity_from_eid(typed_eid(eid))
154 +        entity = self._cw.entity_from_eid(eid)
155          return entity.view('frob', name=frobname)
156 
157  .. sourcecode:: javascript
158 
159 -    function update_some_div(divid, eid, frobname) {
160 +    function updateSomeDiv(divid, eid, frobname) {
161          var params = {fname:'frob_status', eid: eid, frobname:frobname};
162          jQuery('#'+divid).loadxhtml(JSON_BASE_URL, params, 'post');
163       }
164 
165  In this example, the url argument is the base json url of a cube
166  instance (it should contain something like
167 -`http://myinstance/json?`). The actual JSonController method name is
168 +`http://myinstance/ajax?`). The actual AjaxController method name is
169  encoded in the `params` dictionary using the `fname` key.
170 
171  A more real-life example
172  ~~~~~~~~~~~~~~~~~~~~~~~~
173 
@@ -248,11 +248,11 @@
174          w(u'<div id="lazy-%s" cubicweb:loadurl="%s">' % (
175              vid, xml_escape(self._cw.build_url('json', **urlparams))))
176          w(u'</div>')
177          self._cw.add_onload(u"""
178              jQuery('#lazy-%(vid)s').bind('%(event)s', function() {
179 -                   load_now('#lazy-%(vid)s');});"""
180 +                   loadNow('#lazy-%(vid)s');});"""
181              % {'event': 'load_%s' % vid, 'vid': vid})
182 
183  This creates a `div` with a specific event associated to it.
184 
185  The full version deals with:
@@ -269,11 +269,11 @@
186 
187  The javascript side is quite simple, due to loadxhtml awesomeness.
188 
189  .. sourcecode:: javascript
190 
191 -    function load_now(eltsel) {
192 +    function loadNow(eltsel) {
193          var lazydiv = jQuery(eltsel);
194          lazydiv.loadxhtml(lazydiv.attr('cubicweb:loadurl'));
195      }
196 
197  This is all significantly different of the previous `simple example`
@@ -304,22 +304,81 @@
198 
199      def forceview(self, vid):
200          """trigger an event that will force immediate loading of the view
201          on dom readyness
202          """
203 -        self._cw.add_onload("trigger_load('%s');" % vid)
204 +        self._cw.add_onload("triggerLoad('%s');" % vid)
205 
206  The browser-side definition follows.
207 
208  .. sourcecode:: javascript
209 
210 -    function trigger_load(divid) {
211 +    function triggerLoad(divid) {
212          jQuery('#lazy-' + divd).trigger('load_' + divid);
213      }
214 
215 
216 -.. XXX userCallback / user_callback
217 +python/ajax dynamic callbacks
218 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
219 +
220 +CubicWeb provides a way to dynamically register a function and make it
221 +callable from the javascript side. The typical use case for this is a
222 +situation where you have everything at hand to implement an action
223 +(whether it be performing a RQL query or executing a few python
224 +statements) that you'd like to defer to a user click in the web
225 +interface.  In other words, generate an HTML ``<a href=...`` link that
226 +would execute your few lines of code.
227 +
228 +The trick is to create a python function and store this function in
229 +the user's session data. You will then be able to access it later.
230 +While this might sound hard to implement, it's actually quite easy
231 +thanks to the ``_cw.user_callback()``. This method takes a function,
232 +registers it and returns a javascript instruction suitable for
233 +``href`` or ``onclick`` usage. The call is then performed
234 +asynchronously.
235 +
236 +Here's a simplified example taken from the vcreview_ cube that will
237 +generate a link to change an entity state directly without the
238 +standard intermediate *comment / validate* step:
239 +
240 +.. sourcecode:: python
241 +
242 +    def entity_call(self, entity):
243 +        # [...]
244 +        def change_state(req, eid):
245 +            entity = req.entity_from_eid(eid)
246 +            entity.cw_adapt_to('IWorkflowable').fire_transition('done')
247 +        url = self._cw.user_callback(change_state, (entity.eid,))
248 +        self.w(tags.input(type='button', onclick=url, value=self._cw._('mark as done')))
249 +
250 +
251 +The ``change_state`` callback function is registered with
252 +``self._cw.user_callback()`` which returns the ``url`` value directly
253 +used for the ``onclick`` attribute of the button. On the javascript
254 +side, the ``userCallback()`` function is used but you most probably
255 +won't have to bother with it.
256 +
257 +Of course, when dealing with session data, the question of session
258 +cleaning pops up immediately. If you use ``user_callback()``, the
259 +registered function will be deleted automatically at some point
260 +as any other session data. If you want your function to be deleted once
261 +the web page is unloaded or when the user has clicked once on your link, then
262 +``_cw.register_onetime_callback()`` is what you need. It behaves as
263 +``_cw.user_callback()`` but stores the function in page data instead
264 +of global session data.
265 +
266 +
267 +.. Warning::
268 +
269 +  Be careful when registering functions with closures, keep in mind that
270 +  enclosed data will be kept in memory until the session gets cleared. Also,
271 +  if you keep entities or any object referecing the current ``req`` object, you
272 +  might have problems reusing them later because the underlying session
273 +  might have been closed at the time the callback gets executed.
274 +
275 +
276 +.. _vcreview: http://www.cubicweb.org/project/cubicweb-vcreview
277 
278  Javascript library: overview
279  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
280 
281  * jquery.* : jquery and jquery UI library
@@ -354,16 +413,16 @@
282  API
283  ~~~
284 
285  .. toctree::
286      :maxdepth: 1
287 -    
288 +
289      js_api/index
290 
291 
292  Testing javascript
293 -~~~~~~~~~~~~~~~~~~~~~~
294 +~~~~~~~~~~~~~~~~~~
295 
296  You with the ``cubicweb.qunit.QUnitTestCase`` can include standard Qunit tests
297  inside the python unittest run . You simply have to define a new class that
298  inherit from ``QUnitTestCase`` and register your javascript test file in the
299  ``all_js_tests`` lclass attribut. This  ``all_js_tests`` is a sequence a
diff --git a/web/application.py b/web/application.py
@@ -448,11 +448,11 @@
300          self.exception(repr(ex))
301          req.set_header('Cache-Control', 'no-cache')
302          req.remove_header('Etag')
303          req.reset_message()
304          req.reset_headers()
305 -        if req.json_request:
306 +        if req.ajax_request:
307              raise RemoteCallFailed(unicode(ex))
308          try:
309              req.data['ex'] = ex
310              if tb:
311                  req.data['excinfo'] = excinfo
diff --git a/web/request.py b/web/request.py
@@ -80,11 +80,11 @@
312 
313 
314 
315  class CubicWebRequestBase(DBAPIRequest):
316      """abstract HTTP request, should be extended according to the HTTP backend"""
317 -    json_request = False # to be set to True by json controllers
318 +    ajax_request = False # to be set to True by ajax controllers
319 
320      def __init__(self, vreg, https, form=None):
321          super(CubicWebRequestBase, self).__init__(vreg)
322          self.https = https
323          if https:
@@ -120,10 +120,16 @@
324              pid = make_uid(id(self))
325              self.html_headers.define_var('pageid', pid, override=False)
326          self.pageid = pid
327 
328      @property
329 +    def json_request(self):
330 +        warn('[3.15] self._cw.json_request is deprecated, use self._cw.ajax_request instead',
331 +             DeprecationWarning, stacklevel=2)
332 +        return self.ajax_request
333 +
334 +    @property
335      def authmode(self):
336          return self.vreg.config['auth-mode']
337 
338      @property
339      def varmaker(self):
diff --git a/web/test/unittest_views_basecontrollers.py b/web/test/unittest_views_basecontrollers.py
@@ -18,19 +18,23 @@
340  """cubicweb.web.views.basecontrollers unit tests"""
341 
342  from __future__ import with_statement
343 
344  from logilab.common.testlib import unittest_main, mock_object
345 +from logilab.common.decorators import monkeypatch
346 
347  from cubicweb import Binary, NoSelectableObject, ValidationError
348  from cubicweb.view import STRICT_DOCTYPE
349  from cubicweb.devtools.testlib import CubicWebTC
350  from cubicweb.utils import json_dumps
351  from cubicweb.uilib import rql_for_eid
352 -from cubicweb.web import INTERNAL_FIELD_VALUE, Redirect, RequestError
353 +from cubicweb.web import INTERNAL_FIELD_VALUE, Redirect, RequestError, RemoteCallFailed
354  from cubicweb.entities.authobjs import CWUser
355  from cubicweb.web.views.autoform import get_pending_inserts, get_pending_deletes
356 +from cubicweb.web.views.basecontrollers import JSonController, xhtmlize, jsonize
357 +from cubicweb.web.views.ajaxcontroller import ajaxfunc, AjaxFunction
358 +
359  u = unicode
360 
361  def req_form(user):
362      return {'eid': [str(user.eid)],
363              '_cw_entity_fields:%s' % user.eid: '_cw_generic_field',
@@ -555,15 +559,16 @@
364          self.assertRaises(NoSelectableObject,
365                            self.vreg['controllers'].select, 'sendmail', self.request())
366 
367 
368 
369 -class JSONControllerTC(CubicWebTC):
370 +class AjaxControllerTC(CubicWebTC):
371 +    tested_controller = 'ajax'
372 
373      def ctrl(self, req=None):
374          req = req or self.request(url='http://whatever.fr/')
375 -        return self.vreg['controllers'].select('json', req)
376 +        return self.vreg['controllers'].select(self.tested_controller, req)
377 
378      def setup_database(self):
379          req = self.request()
380          self.pytag = req.create_entity('Tag', name=u'python')
381          self.cubicwebtag = req.create_entity('Tag', name=u'cubicweb')
@@ -677,10 +682,91 @@
382 
383      def test_format_date(self):
384          self.assertEqual(self.remote_call('format_date', '2007-01-01 12:00:00')[0],
385                            json_dumps('2007/01/01'))
386 
387 +    def test_ajaxfunc_noparameter(self):
388 +        @ajaxfunc
389 +        def foo(self, x, y):
390 +            return 'hello'
391 +        self.assertTrue(issubclass(foo, AjaxFunction))
392 +        self.assertEqual(foo.__regid__, 'foo')
393 +        self.assertEqual(foo.check_pageid, False)
394 +        self.assertEqual(foo.output_type, None)
395 +        req = self.request()
396 +        f = foo(req)
397 +        self.assertEqual(f(12, 13), 'hello')
398 +
399 +    def test_ajaxfunc_checkpageid(self):
400 +        @ajaxfunc( check_pageid=True)
401 +        def foo(self, x, y):
402 +            pass
403 +        self.assertTrue(issubclass(foo, AjaxFunction))
404 +        self.assertEqual(foo.__regid__, 'foo')
405 +        self.assertEqual(foo.check_pageid, True)
406 +        self.assertEqual(foo.output_type, None)
407 +        # no pageid
408 +        req = self.request()
409 +        f = foo(req)
410 +        self.assertRaises(RemoteCallFailed, f, 12, 13)
411 +
412 +    def test_ajaxfunc_json(self):
413 +        @ajaxfunc(output_type='json')
414 +        def foo(self, x, y):
415 +            return x + y
416 +        self.assertTrue(issubclass(foo, AjaxFunction))
417 +        self.assertEqual(foo.__regid__, 'foo')
418 +        self.assertEqual(foo.check_pageid, False)
419 +        self.assertEqual(foo.output_type, 'json')
420 +        # no pageid
421 +        req = self.request()
422 +        f = foo(req)
423 +        self.assertEqual(f(12, 13), '25')
424 
425 
426 +class JSonControllerTC(AjaxControllerTC):
427 +    # NOTE: this class performs the same tests as AjaxController but with
428 +    #       deprecated 'json' controller (i.e. check backward compatibility)
429 +    tested_controller = 'json'
430 +
431 +    def setUp(self):
432 +        super(JSonControllerTC, self).setUp()
433 +        self.exposed_remote_funcs = [fname for fname in dir(JSonController)
434 +                                     if fname.startswith('js_')]
435 +
436 +    def tearDown(self):
437 +        super(JSonControllerTC, self).tearDown()
438 +        for funcname in dir(JSonController):
439 +            # remove functions added dynamically during tests
440 +            if funcname.startswith('js_') and funcname not in self.exposed_remote_funcs:
441 +                delattr(JSonController, funcname)
442 +
443 +    def test_monkeypatch_jsoncontroller(self):
444 +        self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
445 +        @monkeypatch(JSonController)
446 +        def js_foo(self):
447 +            return u'hello'
448 +        res, req = self.remote_call('foo')
449 +        self.assertEqual(res, u'hello')
450 +
451 +    def test_monkeypatch_jsoncontroller_xhtmlize(self):
452 +        self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
453 +        @monkeypatch(JSonController)
454 +        @xhtmlize
455 +        def js_foo(self):
456 +            return u'hello'
457 +        res, req = self.remote_call('foo')
458 +        self.assertEqual(res,
459 +                         '<?xml version="1.0"?>\n' + STRICT_DOCTYPE +
460 +                         u'<div xmlns="http://www.w3.org/1999/xhtml" xmlns:cubicweb="http://www.logilab.org/2008/cubicweb">hello</div>')
461 +
462 +    def test_monkeypatch_jsoncontroller_jsonize(self):
463 +        self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
464 +        @monkeypatch(JSonController)
465 +        @jsonize
466 +        def js_foo(self):
467 +            return 12
468 +        res, req = self.remote_call('foo')
469 +        self.assertEqual(res, '12')
470 
471  if __name__ == '__main__':
472      unittest_main()
diff --git a/web/views/actions.py b/web/views/actions.py
@@ -128,11 +128,11 @@
473 
474      def url(self):
475          params = self._cw.form.copy()
476          for param in ('vid', '__message') + controller.NAV_FORM_PARAMETERS:
477              params.pop(param, None)
478 -        if self._cw.json_request:
479 +        if self._cw.ajax_request:
480              path = 'view'
481              if self.cw_rset is not None:
482                  params = {'rql': self.cw_rset.printable_rql()}
483          else:
484              path = self._cw.relative_path(includeparams=False)
diff --git a/web/views/ajaxcontroller.py b/web/views/ajaxcontroller.py
@@ -0,0 +1,452 @@
485 +# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
486 +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
487 +#
488 +# This file is part of CubicWeb.
489 +#
490 +# CubicWeb is free software: you can redistribute it and/or modify it under the
491 +# terms of the GNU Lesser General Public License as published by the Free
492 +# Software Foundation, either version 2.1 of the License, or (at your option)
493 +# any later version.
494 +#
495 +# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
496 +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
497 +# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
498 +# details.
499 +#
500 +# You should have received a copy of the GNU Lesser General Public License along
501 +# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
502 +#
503 +# (disable pylint msg for client obj access to protected member as in obj._cw)
504 +# pylint: disable=W0212
505 +"""The ``ajaxcontroller`` module defines the :class:`AjaxController`
506 +controller and the ``ajax-funcs`` cubicweb registry.
507 +
508 +.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxController
509 +   :members:
510 +
511 +``ajax-funcs`` registry hosts exposed remote functions, that is
512 +functions that can be called from the javascript world.
513 +
514 +To register a new remote function, either decorate your function
515 +with the :ref:`cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator:
516 +
517 +.. sourcecode:: python
518 +
519 +    from cubicweb.selectors import mactch_user_groups
520 +    from cubicweb.web.views.ajaxcontroller import ajaxfunc
521 +
522 +    @ajaxfunc(output_type='json', selector=match_user_groups('managers'))
523 +    def list_users(self):
524 +        return [u for (u,) in self._cw.execute('Any L WHERE U login L')]
525 +
526 +or inherit from :class:`cubicwbe.web.views.ajaxcontroller.AjaxFunction` and
527 +implement the ``__call__`` method:
528 +
529 +.. sourcecode:: python
530 +
531 +    from cubicweb.web.views.ajaxcontroller import AjaxFunction
532 +    class ListUser(AjaxFunction):
533 +        __regid__ = 'list_users' # __regid__ is the name of the exposed function
534 +        __select__ = match_user_groups('managers')
535 +        output_type = 'json'
536 +
537 +        def __call__(self):
538 +            return [u for (u, ) in self._cw.execute('Any L WHERE U login L')]
539 +
540 +
541 +.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxFunction
542 +   :members:
543 +
544 +.. autofunction:: cubicweb.web.views.ajaxcontroller.ajaxfunc
545 +
546 +"""
547 +
548 +__docformat__ = "restructuredtext en"
549 +
550 +from functools import partial
551 +
552 +from logilab.common.date import strptime
553 +from logilab.common.deprecation import deprecated
554 +
555 +from cubicweb import ObjectNotFound, NoSelectableObject
556 +from cubicweb.appobject import AppObject
557 +from cubicweb.selectors import yes
558 +from cubicweb.utils import json, json_dumps, UStringIO
559 +from cubicweb.uilib import exc_message
560 +from cubicweb.web import RemoteCallFailed, DirectResponse
561 +from cubicweb.web.controller import Controller
562 +from cubicweb.web.views import vid_from_rset
563 +from cubicweb.web.views import basecontrollers
564 +
565 +
566 +def optional_kwargs(extraargs):
567 +    if extraargs is None:
568 +        return {}
569 +    # we receive unicode keys which is not supported by the **syntax
570 +    return dict((str(key), value) for key, value in extraargs.iteritems())
571 +
572 +
573 +class AjaxController(Controller):
574 +    """AjaxController handles ajax remote calls from javascript
575 +
576 +    The following javascript function call:
577 +
578 +    .. sourcecode:: javascript
579 +
580 +      var d = asyncRemoteExec('foo', 12, "hello");
581 +      d.addCallback(function(result) {
582 +          alert('server response is: ' + result);
583 +      });
584 +
585 +    will generate an ajax HTTP GET on the following url::
586 +
587 +        BASE_URL/ajax?fname=foo&arg=12&arg="hello"
588 +
589 +    The AjaxController controller will therefore be selected to handle those URLs
590 +    and will itself select the :class:`cubicweb.web.views.ajaxcontroller.AjaxFunction`
591 +    matching the *fname* parameter.
592 +    """
593 +    __regid__ = 'ajax'
594 +
595 +    def publish(self, rset=None):
596 +        self._cw.ajax_request = True
597 +        try:
598 +            fname = self._cw.form['fname']
599 +        except KeyError:
600 +            raise RemoteCallFailed('no method specified')
601 +        try:
602 +            func = self._cw.vreg['ajax-func'].select(fname, self._cw)
603 +        except ObjectNotFound:
604 +            # function not found in the registry, inspect JSonController for
605 +            # backward compatibility
606 +            try:
607 +                func = getattr(basecontrollers.JSonController, 'js_%s' % fname).im_func
608 +                func = partial(func, self)
609 +            except AttributeError:
610 +                raise RemoteCallFailed('no %s method' % fname)
611 +            else:
612 +                self.warning('remote function %s found on JSonController, '
613 +                             'use AjaxFunction / @ajaxfunc instead', fname)
614 +        except NoSelectableObject:
615 +            raise RemoteCallFailed('method %s not available in this context'
616 +                                   % fname)
617 +        # no <arg> attribute means the callback takes no argument
618 +        args = self._cw.form.get('arg', ())
619 +        if not isinstance(args, (list, tuple)):
620 +            args = (args,)
621 +        try:
622 +            args = [json.loads(arg) for arg in args]
623 +        except ValueError, exc:
624 +            self.exception('error while decoding json arguments for '
625 +                           'js_%s: %s (err: %s)', fname, args, exc)
626 +            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
627 +        try:
628 +            result = func(*args)
629 +        except (RemoteCallFailed, DirectResponse):
630 +            raise
631 +        except Exception, exc:
632 +            self.exception('an exception occurred while calling js_%s(%s): %s',
633 +                           fname, args, exc)
634 +            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
635 +        if result is None:
636 +            return ''
637 +        # get unicode on @htmlize methods, encoded string on @jsonize methods
638 +        elif isinstance(result, unicode):
639 +            return result.encode(self._cw.encoding)
640 +        return result
641 +
642 +class AjaxFunction(AppObject):
643 +    """
644 +    Attributes on this base class are:
645 +
646 +    :attr: `check_pageid`: make sure the pageid received is valid before proceeding
647 +    :attr: `output_type`:
648 +
649 +           - *None*: no processing, no change on content-type
650 +
651 +           - *json*: serialize with `json_dumps` and set *application/json*
652 +                     content-type
653 +
654 +           - *xhtml*: wrap result in an XML node and forces HTML / XHTML
655 +                      content-type (use ``_cw.html_content_type()``)
656 +
657 +    """
658 +    __registry__ = 'ajax-func'
659 +    __select__ = yes()
660 +    __abstract__ = True
661 +
662 +    check_pageid = False
663 +    output_type = None
664 +
665 +    @staticmethod
666 +    def _rebuild_posted_form(names, values, action=None):
667 +        form = {}
668 +        for name, value in zip(names, values):
669 +            # remove possible __action_xxx inputs
670 +            if name.startswith('__action'):
671 +                if action is None:
672 +                    # strip '__action_' to get the actual action name
673 +                    action = name[9:]
674 +                continue
675 +            # form.setdefault(name, []).append(value)
676 +            if name in form:
677 +                curvalue = form[name]
678 +                if isinstance(curvalue, list):
679 +                    curvalue.append(value)
680 +                else:
681 +                    form[name] = [curvalue, value]
682 +            else:
683 +                form[name] = value
684 +        # simulate click on __action_%s button to help the controller
685 +        if action:
686 +            form['__action_%s' % action] = u'whatever'
687 +        return form
688 +
689 +    def validate_form(self, action, names, values):
690 +        self._cw.form = self._rebuild_posted_form(names, values, action)
691 +        return basecontrollers._validate_form(self._cw, self._cw.vreg)
692 +
693 +    def _exec(self, rql, args=None, rocheck=True):
694 +        """json mode: execute RQL and return resultset as json"""
695 +        rql = rql.strip()
696 +        if rql.startswith('rql:'):
697 +            rql = rql[4:]
698 +        if rocheck:
699 +            self._cw.ensure_ro_rql(rql)
700 +        try:
701 +            return self._cw.execute(rql, args)
702 +        except Exception, ex:
703 +            self.exception("error in _exec(rql=%s): %s", rql, ex)
704 +            return None
705 +        return None
706 +
707 +    def _call_view(self, view, paginate=False, **kwargs):
708 +        divid = self._cw.form.get('divid')
709 +        # we need to call pagination before with the stream set
710 +        try:
711 +            stream = view.set_stream()
712 +        except AttributeError:
713 +            stream = UStringIO()
714 +            kwargs['w'] = stream.write
715 +            assert not paginate
716 +        if divid == 'pageContent':
717 +            # ensure divid isn't reused by the view (e.g. table view)
718 +            del self._cw.form['divid']
719 +            # mimick main template behaviour
720 +            stream.write(u'<div id="pageContent">')
721 +            vtitle = self._cw.form.get('vtitle')
722 +            if vtitle:
723 +                stream.write(u'<h1 class="vtitle">%s</h1>\n' % vtitle)
724 +            paginate = True
725 +        nav_html = UStringIO()
726 +        if paginate and not view.handle_pagination:
727 +            view.paginate(w=nav_html.write)
728 +        stream.write(nav_html.getvalue())
729 +        if divid == 'pageContent':
730 +            stream.write(u'<div id="contentmain">')
731 +        view.render(**kwargs)
732 +        extresources = self._cw.html_headers.getvalue(skiphead=True)
733 +        if extresources:
734 +            stream.write(u'<div class="ajaxHtmlHead">\n') # XXX use a widget ?
735 +            stream.write(extresources)
736 +            stream.write(u'</div>\n')
737 +        if divid == 'pageContent':
738 +            stream.write(u'</div>%s</div>' % nav_html.getvalue())
739 +        return stream.getvalue()
740 +
741 +
742 +def _ajaxfunc_factory(implementation, selector=yes(), _output_type=None,
743 +                      _check_pageid=False, regid=None):
744 +    """converts a standard python function into an AjaxFunction appobject"""
745 +    class AnAjaxFunc(AjaxFunction):
746 +        __regid__ = regid or implementation.__name__
747 +        __select__ = selector
748 +        output_type = _output_type
749 +        check_pageid = _check_pageid
750 +
751 +        def serialize(self, content):
752 +            if self.output_type is None:
753 +                return content
754 +            elif self.output_type == 'xhtml':
755 +                self._cw.set_content_type(self._cw.html_content_type())
756 +                return ''.join((self._cw.document_surrounding_div(),
757 +                                content.strip(), u'</div>'))
758 +            elif self.output_type == 'json':
759 +                self._cw.set_content_type('application/json')
760 +                return json_dumps(content)
761 +            raise RemoteCallFailed('no serializer found for output type %s'
762 +                                   % self.output_type)
763 +
764 +        def __call__(self, *args, **kwargs):
765 +            if self.check_pageid:
766 +                data = self._cw.session.data.get(self._cw.pageid)
767 +                if data is None:
768 +                    raise RemoteCallFailed(self._cw._('pageid-not-found'))
769 +            return self.serialize(implementation(self, *args, **kwargs))
770 +    AnAjaxFunc.__name__ = implementation.__name__
771 +    # make sure __module__ refers to the original module otherwise
772 +    # vreg.register(obj) will ignore ``obj``.
773 +    AnAjaxFunc.__module__ = implementation.__module__
774 +    return AnAjaxFunc
775 +
776 +
777 +def ajaxfunc(implementation=None, selector=yes(), output_type=None,
778 +             check_pageid=False, regid=None):
779 +    """promote a standard function to an ``AjaxFunction`` appobject.
780 +
781 +    All parameters are optional:
782 +
783 +    :param selector: a custom selector object if needed, default is ``yes()``
784 +
785 +    :param output_type: either None, 'json' or 'xhtml' to customize output
786 +                        content-type. Default is None
787 +
788 +    :param check_pageid: whether the function requires a valid `pageid` or not
789 +                         to proceed. Default is False.
790 +
791 +    :param regid: a custom __regid__ for the created ``AjaxFunction`` object. Default
792 +                  is to keep the wrapped function name.
793 +
794 +    ``ajaxfunc`` can be used both as a standalone decorator:
795 +
796 +    .. sourcecode:: python
797 +
798 +        @ajaxfunc
799 +        def my_function(self):
800 +            return 42
801 +
802 +    or as a parametrizable decorator:
803 +
804 +    .. sourcecode:: python
805 +
806 +        @ajaxfunc(output_type='json')
807 +        def my_function(self):
808 +            return 42
809 +
810 +    """
811 +    # if used as a parametrized decorator (e.g. @ajaxfunc(output_type='json'))
812 +    if implementation is None:
813 +        def _decorator(func):
814 +            return _ajaxfunc_factory(func, selector=selector,
815 +                                     _output_type=output_type,
816 +                                     _check_pageid=check_pageid,
817 +                                     regid=regid)
818 +        return _decorator
819 +    # else, used as a standalone decorator (i.e. @ajaxfunc)
820 +    return _ajaxfunc_factory(implementation, selector=selector,
821 +                             _output_type=output_type,
822 +                             _check_pageid=check_pageid, regid=regid)
823 +
824 +
825 +
826 +###############################################################################
827 +#  Cubicweb remote functions for :                                            #
828 +#  - appobject rendering                                                      #
829 +#  - user / page session data management                                      #
830 +###############################################################################
831 +@ajaxfunc(output_type='xhtml')
832 +def view(self):
833 +    # XXX try to use the page-content template
834 +    req = self._cw
835 +    rql = req.form.get('rql')
836 +    if rql:
837 +        rset = self._exec(rql)
838 +    elif 'eid' in req.form:
839 +        rset = self._cw.eid_rset(req.form['eid'])
840 +    else:
841 +        rset = None
842 +    vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
843 +    try:
844 +        viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
845 +    except NoSelectableObject:
846 +        vid = req.form.get('fallbackvid', 'noresult')
847 +        viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
848 +    viewobj.set_http_cache_headers()
849 +    req.validate_cache()
850 +    return self._call_view(viewobj, paginate=req.form.pop('paginate', False))
851 +
852 +
853 +@ajaxfunc(output_type='xhtml')
854 +def component(self, compid, rql, registry='components', extraargs=None):
855 +    if rql:
856 +        rset = self._exec(rql)
857 +    else:
858 +        rset = None
859 +    # XXX while it sounds good, addition of the try/except below cause pb:
860 +    # when filtering using facets return an empty rset, the edition box
861 +    # isn't anymore selectable, as expected. The pb is that with the
862 +    # try/except below, we see a "an error occurred" message in the ui, while
863 +    # we don't see it without it. Proper fix would probably be to deal with
864 +    # this by allowing facet handling code to tell to js_component that such
865 +    # error is expected and should'nt be reported.
866 +    #try:
867 +    comp = self._cw.vreg[registry].select(compid, self._cw, rset=rset,
868 +                                          **optional_kwargs(extraargs))
869 +    #except NoSelectableObject:
870 +    #    raise RemoteCallFailed('unselectable')
871 +    return self._call_view(comp, **optional_kwargs(extraargs))
872 +
873 +@ajaxfunc(output_type='xhtml')
874 +def render(self, registry, oid, eid=None,
875 +              selectargs=None, renderargs=None):
876 +    if eid is not None:
877 +        rset = self._cw.eid_rset(eid)
878 +        # XXX set row=0
879 +    elif self._cw.form.get('rql'):
880 +        rset = self._cw.execute(self._cw.form['rql'])
881 +    else:
882 +        rset = None
883 +    viewobj = self._cw.vreg[registry].select(oid, self._cw, rset=rset,
884 +                                             **optional_kwargs(selectargs))
885 +    return self._call_view(viewobj, **optional_kwargs(renderargs))
886 +
887 +
888 +@ajaxfunc(output_type='json')
889 +def i18n(self, msgids):
890 +    """returns the translation of `msgid`"""
891 +    return [self._cw._(msgid) for msgid in msgids]
892 +
893 +@ajaxfunc(output_type='json')
894 +def format_date(self, strdate):
895 +    """returns the formatted date for `msgid`"""
896 +    date = strptime(strdate, '%Y-%m-%d %H:%M:%S')
897 +    return self._cw.format_date(date)
898 +
899 +@ajaxfunc(output_type='json')
900 +def external_resource(self, resource):
901 +    """returns the URL of the external resource named `resource`"""
902 +    return self._cw.uiprops[resource]
903 +
904 +@ajaxfunc(output_type='json', check_pageid=True)
905 +def user_callback(self, cbname):
906 +    """execute the previously registered user callback `cbname`.
907 +
908 +    If matching callback is not found, return None
909 +    """
910 +    page_data = self._cw.session.data.get(self._cw.pageid, {})
911 +    try:
912 +        cb = page_data[cbname]
913 +    except KeyError:
914 +        self.warning('unable to find user callback %s', cbname)
915 +        return None
916 +    return cb(self._cw)
917 +
918 +
919 +@ajaxfunc
920 +def unregister_user_callback(self, cbname):
921 +    """unregister user callback `cbname`"""
922 +    self._cw.unregister_callback(self._cw.pageid, cbname)
923 +
924 +@ajaxfunc
925 +def unload_page_data(self):
926 +    """remove user's session data associated to current pageid"""
927 +    self._cw.session.data.pop(self._cw.pageid, None)
928 +
929 +@ajaxfunc(output_type='json')
930 +@deprecated("[3.13] use jQuery.cookie(cookiename, cookievalue, {path: '/'}) in js land instead")
931 +def set_cookie(self, cookiename, cookievalue):
932 +    """generates the Set-Cookie HTTP reponse header corresponding
933 +    to `cookiename` / `cookievalue`.
934 +    """
935 +    cookiename, cookievalue = str(cookiename), str(cookievalue)
936 +    self._cw.set_cookie(cookiename, cookievalue)
diff --git a/web/views/autoform.py b/web/views/autoform.py
@@ -132,14 +132,15 @@
937  from cubicweb.schema import display_name
938  from cubicweb.view import EntityView
939  from cubicweb.selectors import (
940      match_kwargs, match_form_params, non_final_entity,
941      specified_etype_implements)
942 -from cubicweb.utils import json_dumps
943 +from cubicweb.utils import json, json_dumps
944  from cubicweb.web import (stdmsgs, uicfg, eid_param,
945                            form as f, formwidgets as fw, formfields as ff)
946  from cubicweb.web.views import forms
947 +from cubicweb.web.views.ajaxcontroller import ajaxfunc
948 
949  _AFS = uicfg.autoform_section
950  _AFFK = uicfg.autoform_field_kwargs
951 
952 
@@ -435,10 +436,74 @@
953      for subj, rtype, obj in parse_relations_descr(rdefs):
954          rql = 'SET X %s Y where X eid %%(x)s, Y eid %%(y)s' % rtype
955          execute(rql, {'x': subj, 'y': obj})
956 
957 
958 +# ajax edition helpers ########################################################
959 +@ajaxfunc(output_type='xhtml', check_pageid=True)
960 +def inline_creation_form(self, peid, petype, ttype, rtype, role, i18nctx):
961 +    view = self._cw.vreg['views'].select('inline-creation', self._cw,
962 +                                         etype=ttype, rtype=rtype, role=role,
963 +                                         peid=peid, petype=petype)
964 +    return self._call_view(view, i18nctx=i18nctx)
965 +
966 +@ajaxfunc(output_type='json')
967 +def validate_form(self, action, names, values):
968 +    return self.validate_form(action, names, values)
969 +
970 +@ajaxfunc
971 +def cancel_edition(self, errorurl):
972 +    """cancelling edition from javascript
973 +
974 +    We need to clear associated req's data :
975 +      - errorurl
976 +      - pending insertions / deletions
977 +    """
978 +    self._cw.cancel_edition(errorurl)
979 +
980 +@ajaxfunc(output_type='xhtml')
981 +def reledit_form(self):
982 +    req = self._cw
983 +    args = dict((x, req.form[x])
984 +                for x in ('formid', 'rtype', 'role', 'reload', 'action'))
985 +    rset = req.eid_rset(typed_eid(self._cw.form['eid']))
986 +    try:
987 +        args['reload'] = json.loads(args['reload'])
988 +    except ValueError: # not true/false, an absolute url
989 +        assert args['reload'].startswith('http')
990 +    view = req.vreg['views'].select('reledit', req, rset=rset, rtype=args['rtype'])
991 +    return self._call_view(view, **args)
992 +
993 +
994 +def _add_pending(req, eidfrom, rel, eidto, kind):
995 +    key = 'pending_%s' % kind
996 +    pendings = req.session.data.setdefault(key, set())
997 +    pendings.add( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
998 +
999 +def _remove_pending(req, eidfrom, rel, eidto, kind):
1000 +    key = 'pending_%s' % kind
1001 +    pendings = req.session.data[key]
1002 +    pendings.remove( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
1003 +
1004 +@ajaxfunc(output_type='json')
1005 +def remove_pending_insert(self, (eidfrom, rel, eidto)):
1006 +    _remove_pending(self._cw, eidfrom, rel, eidto, 'insert')
1007 +
1008 +@ajaxfunc(output_type='json')
1009 +def add_pending_inserts(self, tripletlist):
1010 +    for eidfrom, rel, eidto in tripletlist:
1011 +        _add_pending(self._cw, eidfrom, rel, eidto, 'insert')
1012 +
1013 +@ajaxfunc(output_type='json')
1014 +def remove_pending_delete(self, (eidfrom, rel, eidto)):
1015 +    _remove_pending(self._cw, eidfrom, rel, eidto, 'delete')
1016 +
1017 +@ajaxfunc(output_type='json')
1018 +def add_pending_delete(self, (eidfrom, rel, eidto)):
1019 +    _add_pending(self._cw, eidfrom, rel, eidto, 'delete')
1020 +
1021 +
1022  class GenericRelationsWidget(fw.FieldWidget):
1023 
1024      def render(self, form, field, renderer):
1025          stream = []
1026          w = stream.append
diff --git a/web/views/basecontrollers.py b/web/views/basecontrollers.py
@@ -20,44 +20,47 @@
1027  """
1028 
1029  __docformat__ = "restructuredtext en"
1030  _ = unicode
1031 
1032 -from logilab.common.date import strptime
1033 +from warnings import warn
1034 +
1035  from logilab.common.deprecation import deprecated
1036 
1037  from cubicweb import (NoSelectableObject, ObjectNotFound, ValidationError,
1038                        AuthenticationError, typed_eid)
1039 -from cubicweb.utils import UStringIO, json, json_dumps
1040 -from cubicweb.uilib import exc_message
1041 -from cubicweb.selectors import authenticated_user, anonymous_user, match_form_params
1042 -from cubicweb.mail import format_mail
1043 -from cubicweb.web import Redirect, RemoteCallFailed, DirectResponse, facet
1044 +from cubicweb.utils import json_dumps
1045 +from cubicweb.selectors import (authenticated_user, anonymous_user,
1046 +                                match_form_params)
1047 +from cubicweb.web import Redirect, RemoteCallFailed
1048  from cubicweb.web.controller import Controller
1049 -from cubicweb.web.views import vid_from_rset, formrenderers
1050 +from cubicweb.web.views import vid_from_rset
1051 
1052 
1053 +@deprecated('jsonize is deprecated, use AjaxFunction appobjects instead')
1054  def jsonize(func):
1055      """decorator to sets correct content_type and calls `json_dumps` on
1056      results
1057      """
1058      def wrapper(self, *args, **kwargs):
1059          self._cw.set_content_type('application/json')
1060          return json_dumps(func(self, *args, **kwargs))
1061      wrapper.__name__ = func.__name__
1062      return wrapper
1063 
1064 +@deprecated('xhtmlize is deprecated, use AjaxFunction appobjects instead')
1065  def xhtmlize(func):
1066      """decorator to sets correct content_type and calls `xmlize` on results"""
1067      def wrapper(self, *args, **kwargs):
1068          self._cw.set_content_type(self._cw.html_content_type())
1069          result = func(self, *args, **kwargs)
1070          return ''.join((self._cw.document_surrounding_div(), result.strip(),
1071                          u'</div>'))
1072      wrapper.__name__ = func.__name__
1073      return wrapper
1074 
1075 +@deprecated('check_pageid is deprecated, use AjaxFunction appobjects instead')
1076  def check_pageid(func):
1077      """decorator which checks the given pageid is found in the
1078      user's session data
1079      """
1080      def wrapper(self, *args, **kwargs):
@@ -232,351 +235,30 @@
1081          return """<script type="text/javascript">
1082   window.parent.handleFormValidationResponse('%s', %s, %s, %s, %s);
1083  </script>""" %  (domid, callback, errback, jsargs, cbargs)
1084 
1085      def publish(self, rset=None):
1086 -        self._cw.json_request = True
1087 +        self._cw.ajax_request = True
1088          # XXX unclear why we have a separated controller here vs
1089          # js_validate_form on the json controller
1090          status, args, entity = _validate_form(self._cw, self._cw.vreg)
1091          domid = self._cw.form.get('__domid', 'entityForm').encode(
1092              self._cw.encoding)
1093          return self.response(domid, status, args, entity)
1094 
1095 -def optional_kwargs(extraargs):
1096 -    if extraargs is None:
1097 -        return {}
1098 -    # we receive unicode keys which is not supported by the **syntax
1099 -    return dict((str(key), value) for key, value in extraargs.iteritems())
1100 -
1101 
1102  class JSonController(Controller):
1103      __regid__ = 'json'
1104 
1105      def publish(self, rset=None):
1106 -        """call js_* methods. Expected form keys:
1107 -
1108 -        :fname: the method name without the js_ prefix
1109 -        :args: arguments list (json)
1110 -
1111 -        note: it's the responsability of js_* methods to set the correct
1112 -        response content type
1113 -        """
1114 -        self._cw.json_request = True
1115 -        try:
1116 -            fname = self._cw.form['fname']
1117 -            func = getattr(self, 'js_%s' % fname)
1118 -        except KeyError:
1119 -            raise RemoteCallFailed('no method specified')
1120 -        except AttributeError:
1121 -            raise RemoteCallFailed('no %s method' % fname)
1122 -        # no <arg> attribute means the callback takes no argument
1123 -        args = self._cw.form.get('arg', ())
1124 -        if not isinstance(args, (list, tuple)):
1125 -            args = (args,)
1126 -        try:
1127 -            args = [json.loads(arg) for arg in args]
1128 -        except ValueError, exc:
1129 -            self.exception('error while decoding json arguments for js_%s: %s (err: %s)',
1130 -                           fname, args, exc)
1131 -            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
1132 -        try:
1133 -            result = func(*args)
1134 -        except (RemoteCallFailed, DirectResponse):
1135 -            raise
1136 -        except Exception, exc:
1137 -            self.exception('an exception occurred while calling js_%s(%s): %s',
1138 -                           fname, args, exc)
1139 -            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
1140 -        if result is None:
1141 -            return ''
1142 -        # get unicode on @htmlize methods, encoded string on @jsonize methods
1143 -        elif isinstance(result, unicode):
1144 -            return result.encode(self._cw.encoding)
1145 -        return result
1146 -
1147 -    def _rebuild_posted_form(self, names, values, action=None):
1148 -        form = {}
1149 -        for name, value in zip(names, values):
1150 -            # remove possible __action_xxx inputs
1151 -            if name.startswith('__action'):
1152 -                if action is None:
1153 -                    # strip '__action_' to get the actual action name
1154 -                    action = name[9:]
1155 -                continue
1156 -            # form.setdefault(name, []).append(value)
1157 -            if name in form:
1158 -                curvalue = form[name]
1159 -                if isinstance(curvalue, list):
1160 -                    curvalue.append(value)
1161 -                else:
1162 -                    form[name] = [curvalue, value]
1163 -            else:
1164 -                form[name] = value
1165 -        # simulate click on __action_%s button to help the controller
1166 -        if action:
1167 -            form['__action_%s' % action] = u'whatever'
1168 -        return form
1169 -
1170 -    def _exec(self, rql, args=None, rocheck=True):
1171 -        """json mode: execute RQL and return resultset as json"""
1172 -        rql = rql.strip()
1173 -        if rql.startswith('rql:'):
1174 -            rql = rql[4:]
1175 -        if rocheck:
1176 -            self._cw.ensure_ro_rql(rql)
1177 -        try:
1178 -            return self._cw.execute(rql, args)
1179 -        except Exception, ex:
1180 -            self.exception("error in _exec(rql=%s): %s", rql, ex)
1181 -            return None
1182 -        return None
1183 -
1184 -    def _call_view(self, view, paginate=False, **kwargs):
1185 -        divid = self._cw.form.get('divid')
1186 -        # we need to call pagination before with the stream set
1187 -        try:
1188 -            stream = view.set_stream()
1189 -        except AttributeError:
1190 -            stream = UStringIO()
1191 -            kwargs['w'] = stream.write
1192 -            assert not paginate
1193 -        if divid == 'pageContent':
1194 -            # ensure divid isn't reused by the view (e.g. table view)
1195 -            del self._cw.form['divid']
1196 -            # mimick main template behaviour
1197 -            stream.write(u'<div id="pageContent">')
1198 -            vtitle = self._cw.form.get('vtitle')
1199 -            if vtitle:
1200 -                stream.write(u'<h1 class="vtitle">%s</h1>\n' % vtitle)
1201 -            paginate = True
1202 -        nav_html = UStringIO()
1203 -        if paginate and not view.handle_pagination:
1204 -            view.paginate(w=nav_html.write)
1205 -        stream.write(nav_html.getvalue())
1206 -        if divid == 'pageContent':
1207 -            stream.write(u'<div id="contentmain">')
1208 -        view.render(**kwargs)
1209 -        extresources = self._cw.html_headers.getvalue(skiphead=True)
1210 -        if extresources:
1211 -            stream.write(u'<div class="ajaxHtmlHead">\n') # XXX use a widget ?
1212 -            stream.write(extresources)
1213 -            stream.write(u'</div>\n')
1214 -        if divid == 'pageContent':
1215 -            stream.write(u'</div>%s</div>' % nav_html.getvalue())
1216 -        return stream.getvalue()
1217 -
1218 -    @xhtmlize
1219 -    def js_view(self):
1220 -        # XXX try to use the page-content template
1221 -        req = self._cw
1222 -        rql = req.form.get('rql')
1223 -        if rql:
1224 -            rset = self._exec(rql)
1225 -        elif 'eid' in req.form:
1226 -            rset = self._cw.eid_rset(req.form['eid'])
1227 -        else:
1228 -            rset = None
1229 -        vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
1230 -        try:
1231 -            view = self._cw.vreg['views'].select(vid, req, rset=rset)
1232 -        except NoSelectableObject:
1233 -            vid = req.form.get('fallbackvid', 'noresult')
1234 -            view = self._cw.vreg['views'].select(vid, req, rset=rset)
1235 -        self.validate_cache(view)
1236 -        return self._call_view(view, paginate=req.form.pop('paginate', False))
1237 -
1238 -    @xhtmlize
1239 -    def js_prop_widget(self, propkey, varname, tabindex=None):
1240 -        """specific method for CWProperty handling"""
1241 -        entity = self._cw.vreg['etypes'].etype_class('CWProperty')(self._cw)
1242 -        entity.eid = varname
1243 -        entity['pkey'] = propkey
1244 -        form = self._cw.vreg['forms'].select('edition', self._cw, entity=entity)
1245 -        form.build_context()
1246 -        vfield = form.field_by_name('value')
1247 -        renderer = formrenderers.FormRenderer(self._cw)
1248 -        return vfield.render(form, renderer, tabindex=tabindex) \
1249 -               + renderer.render_help(form, vfield)
1250 -
1251 -    @xhtmlize
1252 -    def js_component(self, compid, rql, registry='components', extraargs=None):
1253 -        if rql:
1254 -            rset = self._exec(rql)
1255 -        else:
1256 -            rset = None
1257 -        # XXX while it sounds good, addition of the try/except below cause pb:
1258 -        # when filtering using facets return an empty rset, the edition box
1259 -        # isn't anymore selectable, as expected. The pb is that with the
1260 -        # try/except below, we see a "an error occurred" message in the ui, while
1261 -        # we don't see it without it. Proper fix would probably be to deal with
1262 -        # this by allowing facet handling code to tell to js_component that such
1263 -        # error is expected and should'nt be reported.
1264 -        #try:
1265 -        comp = self._cw.vreg[registry].select(compid, self._cw, rset=rset,
1266 -                                              **optional_kwargs(extraargs))
1267 -        #except NoSelectableObject:
1268 -        #    raise RemoteCallFailed('unselectable')
1269 -        return self._call_view(comp, **optional_kwargs(extraargs))
1270 -
1271 -    @xhtmlize
1272 -    def js_render(self, registry, oid, eid=None,
1273 -                  selectargs=None, renderargs=None):
1274 -        if eid is not None:
1275 -            rset = self._cw.eid_rset(eid)
1276 -            # XXX set row=0
1277 -        elif self._cw.form.get('rql'):
1278 -            rset = self._cw.execute(self._cw.form['rql'])
1279 -        else:
1280 -            rset = None
1281 -        view = self._cw.vreg[registry].select(oid, self._cw, rset=rset,
1282 -                                              **optional_kwargs(selectargs))
1283 -        return self._call_view(view, **optional_kwargs(renderargs))
1284 -
1285 -    @check_pageid
1286 -    @xhtmlize
1287 -    def js_inline_creation_form(self, peid, petype, ttype, rtype, role, i18nctx):
1288 -        view = self._cw.vreg['views'].select('inline-creation', self._cw,
1289 -                                             etype=ttype, rtype=rtype, role=role,
1290 -                                             peid=peid, petype=petype)
1291 -        return self._call_view(view, i18nctx=i18nctx)
1292 -
1293 -    @jsonize
1294 -    def js_validate_form(self, action, names, values):
1295 -        return self.validate_form(action, names, values)
1296 -
1297 -    def validate_form(self, action, names, values):
1298 -        self._cw.form = self._rebuild_posted_form(names, values, action)
1299 -        return _validate_form(self._cw, self._cw.vreg)
1300 -
1301 -    @xhtmlize
1302 -    def js_reledit_form(self):
1303 -        req = self._cw
1304 -        args = dict((x, req.form[x])
1305 -                    for x in ('formid', 'rtype', 'role', 'reload', 'action'))
1306 -        rset = req.eid_rset(typed_eid(self._cw.form['eid']))
1307 -        try:
1308 -            args['reload'] = json.loads(args['reload'])
1309 -        except ValueError: # not true/false, an absolute url
1310 -            assert args['reload'].startswith('http')
1311 -        view = req.vreg['views'].select('reledit', req, rset=rset, rtype=args['rtype'])
1312 -        return self._call_view(view, **args)
1313 -
1314 -    @jsonize
1315 -    def js_i18n(self, msgids):
1316 -        """returns the translation of `msgid`"""
1317 -        return [self._cw._(msgid) for msgid in msgids]
1318 -
1319 -    @jsonize
1320 -    def js_format_date(self, strdate):
1321 -        """returns the formatted date for `msgid`"""
1322 -        date = strptime(strdate, '%Y-%m-%d %H:%M:%S')
1323 -        return self._cw.format_date(date)
1324 -
1325 -    @jsonize
1326 -    def js_external_resource(self, resource):
1327 -        """returns the URL of the external resource named `resource`"""
1328 -        return self._cw.uiprops[resource]
1329 -
1330 -    @check_pageid
1331 -    @jsonize
1332 -    def js_user_callback(self, cbname):
1333 -        page_data = self._cw.session.data.get(self._cw.pageid, {})
1334 -        try:
1335 -            cb = page_data[cbname]
1336 -        except KeyError:
1337 -            return None
1338 -        return cb(self._cw)
1339 -
1340 -    @jsonize
1341 -    def js_filter_build_rql(self, names, values):
1342 -        form = self._rebuild_posted_form(names, values)
1343 -        self._cw.form = form
1344 -        builder = facet.FilterRQLBuilder(self._cw)
1345 -        return builder.build_rql()
1346 -
1347 -    @jsonize
1348 -    def js_filter_select_content(self, facetids, rql, mainvar):
1349 -        # Union unsupported yet
1350 -        select = self._cw.vreg.parse(self._cw, rql).children[0]
1351 -        filtered_variable = facet.get_filtered_variable(select, mainvar)
1352 -        facet.prepare_select(select, filtered_variable)
1353 -        update_map = {}
1354 -        for fid in facetids:
1355 -            fobj = facet.get_facet(self._cw, fid, select, filtered_variable)
1356 -            update_map[fid] = fobj.possible_values()
1357 -        return update_map
1358 -
1359 -    def js_unregister_user_callback(self, cbname):
1360 -        self._cw.unregister_callback(self._cw.pageid, cbname)
1361 -
1362 -    def js_unload_page_data(self):
1363 -        self._cw.session.data.pop(self._cw.pageid, None)
1364 -
1365 -    def js_cancel_edition(self, errorurl):
1366 -        """cancelling edition from javascript
1367 -
1368 -        We need to clear associated req's data :
1369 -          - errorurl
1370 -          - pending insertions / deletions
1371 -        """
1372 -        self._cw.cancel_edition(errorurl)
1373 -
1374 -    def js_delete_bookmark(self, beid):
1375 -        rql = 'DELETE B bookmarked_by U WHERE B eid %(b)s, U eid %(u)s'
1376 -        self._cw.execute(rql, {'b': typed_eid(beid), 'u' : self._cw.user.eid})
1377 -
1378 -    def js_node_clicked(self, treeid, nodeeid):
1379 -        """add/remove eid in treestate cookie"""
1380 -        from cubicweb.web.views.treeview import treecookiename
1381 -        cookies = self._cw.get_cookie()
1382 -        statename = treecookiename(treeid)
1383 -        treestate = cookies.get(statename)
1384 -        if treestate is None:
1385 -            self._cw.set_cookie(statename, nodeeid)
1386 -        else:
1387 -            marked = set(filter(None, treestate.value.split(':')))
1388 -            if nodeeid in marked:
1389 -                marked.remove(nodeeid)
1390 -            else:
1391 -                marked.add(nodeeid)
1392 -            self._cw.set_cookie(statename, ':'.join(marked))
1393 -
1394 -    @jsonize
1395 -    @deprecated("[3.13] use jQuery.cookie(cookiename, cookievalue, {path: '/'}) in js land instead")
1396 -    def js_set_cookie(self, cookiename, cookievalue):
1397 -        cookiename, cookievalue = str(cookiename), str(cookievalue)
1398 -        self._cw.set_cookie(cookiename, cookievalue)
1399 -
1400 -    # relations edition stuff ##################################################
1401 -
1402 -    def _add_pending(self, eidfrom, rel, eidto, kind):
1403 -        key = 'pending_%s' % kind
1404 -        pendings = self._cw.session.data.setdefault(key, set())
1405 -        pendings.add( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
1406 -
1407 -    def _remove_pending(self, eidfrom, rel, eidto, kind):
1408 -        key = 'pending_%s' % kind
1409 -        pendings = self._cw.session.data[key]
1410 -        pendings.remove( (typed_eid(eidfrom), rel, typed_eid(eidto)) )
1411 -
1412 -    def js_remove_pending_insert(self, (eidfrom, rel, eidto)):
1413 -        self._remove_pending(eidfrom, rel, eidto, 'insert')
1414 -
1415 -    def js_add_pending_inserts(self, tripletlist):
1416 -        for eidfrom, rel, eidto in tripletlist:
1417 -            self._add_pending(eidfrom, rel, eidto, 'insert')
1418 -
1419 -    def js_remove_pending_delete(self, (eidfrom, rel, eidto)):
1420 -        self._remove_pending(eidfrom, rel, eidto, 'delete')
1421 -
1422 -    def js_add_pending_delete(self, (eidfrom, rel, eidto)):
1423 -        self._add_pending(eidfrom, rel, eidto, 'delete')
1424 +        warn('[3.15] JSONController is deprecated, use AjaxController instead',
1425 +             DeprecationWarning)
1426 +        ajax_controller = self._cw.vreg['controllers'].select('ajax', self._cw, appli=self.appli)
1427 +        return ajax_controller.publish(rset)
1428 
1429 
1430  # XXX move to massmailing
1431 -
1432  class MailBugReportController(Controller):
1433      __regid__ = 'reportbug'
1434      __select__ = match_form_params('description')
1435 
1436      def publish(self, rset=None):
diff --git a/web/views/bookmark.py b/web/views/bookmark.py
@@ -20,15 +20,16 @@
1437  __docformat__ = "restructuredtext en"
1438  _ = unicode
1439 
1440  from logilab.mtconverter import xml_escape
1441 
1442 -from cubicweb import Unauthorized
1443 +from cubicweb import Unauthorized, typed_eid
1444  from cubicweb.selectors import is_instance, one_line_rset
1445  from cubicweb.web import (action, component, uicfg, htmlwidgets,
1446                            formwidgets as fw)
1447  from cubicweb.web.views import primary
1448 +from cubicweb.web.views.ajaxcontroller import ajaxfunc
1449 
1450  _abaa = uicfg.actionbox_appearsin_addmenu
1451  _abaa.tag_subject_of(('*', 'bookmarked_by', '*'), False)
1452  _abaa.tag_object_of(('*', 'bookmarked_by', '*'), False)
1453 
@@ -131,5 +132,10 @@
1454              url = req.user.absolute_url(vid='xaddrelation', rtype='bookmarked_by',
1455                                          target='subject')
1456              menu.append(self.link(req._('pick existing bookmarks'), url))
1457              self.append(menu)
1458          self.render_items(w)
1459 +
1460 +@ajaxfunc
1461 +def delete_bookmark(self, beid):
1462 +    rql = 'DELETE B bookmarked_by U WHERE B eid %(b)s, U eid %(u)s'
1463 +    self._cw.execute(rql, {'b': typed_eid(beid), 'u' : self._cw.user.eid})
diff --git a/web/views/cwproperties.py b/web/views/cwproperties.py
@@ -33,10 +33,11 @@
1464  from cubicweb.web.form import FormViewMixIn
1465  from cubicweb.web.formfields import FIELDS, StringField
1466  from cubicweb.web.formwidgets import (Select, TextInput, Button, SubmitButton,
1467                                        FieldWidget)
1468  from cubicweb.web.views import primary, formrenderers, editcontroller
1469 +from cubicweb.web.views.ajaxcontroller import ajaxfunc
1470 
1471  uicfg.primaryview_section.tag_object_of(('*', 'for_user', '*'), 'hidden')
1472 
1473  # some string we want to be internationalizable for nicer display of property
1474  # groups
@@ -417,10 +418,24 @@
1475          """return (path, parameters) which should be used as redirect
1476          information when this entity is being deleted
1477          """
1478          return 'view', {}
1479 
1480 +
1481 +@ajaxfunc(output_type='xhtml')
1482 +def prop_widget(self, propkey, varname, tabindex=None):
1483 +    """specific method for CWProperty handling"""
1484 +    entity = self._cw.vreg['etypes'].etype_class('CWProperty')(self._cw)
1485 +    entity.eid = varname
1486 +    entity['pkey'] = propkey
1487 +    form = self._cw.vreg['forms'].select('edition', self._cw, entity=entity)
1488 +    form.build_context()
1489 +    vfield = form.field_by_name('value')
1490 +    renderer = formrenderers.FormRenderer(self._cw)
1491 +    return vfield.render(form, renderer, tabindex=tabindex) \
1492 +           + renderer.render_help(form, vfield)
1493 +
1494  _afs = uicfg.autoform_section
1495  _afs.tag_subject_of(('*', 'for_user', '*'), 'main', 'hidden')
1496  _afs.tag_object_of(('*', 'for_user', '*'), 'main', 'hidden')
1497  _aff = uicfg.autoform_field
1498  _aff.tag_attribute(('CWProperty', 'pkey'), PropertyKeyField)
diff --git a/web/views/editcontroller.py b/web/views/editcontroller.py
@@ -159,11 +159,11 @@
1499          try:
1500              entity = self._cw.execute(rql, rqlquery.kwargs).get_entity(0, 0)
1501              neweid = entity.eid
1502          except ValidationError, ex:
1503              self._to_create[eid] = ex.entity
1504 -            if self._cw.json_request: # XXX (syt) why?
1505 +            if self._cw.ajax_request: # XXX (syt) why?
1506                  ex.entity = eid
1507              raise
1508          self._to_create[eid] = neweid
1509          return neweid
1510 
diff --git a/web/views/facets.py b/web/views/facets.py
@@ -30,10 +30,11 @@
1511                                  match_context_prop, yes, relation_possible)
1512  from cubicweb.utils import json_dumps
1513  from cubicweb.uilib import css_em_num_value
1514  from cubicweb.view import AnyRsetView
1515  from cubicweb.web import component, facet as facetbase
1516 +from cubicweb.web.views.ajaxcontroller import ajaxfunc
1517 
1518  def facets(req, rset, context, mainvar=None, **kwargs):
1519      """return the base rql and a list of widgets for facets applying to the
1520      given rset/context (cached version of :func:`_facet`)
1521 
@@ -311,10 +312,32 @@
1522              for queued in widget_queue:
1523                  queued.render(w=w)
1524              w(u'</div>')
1525          w(u'</div>\n')
1526 
1527 +# python-ajax remote functions used by facet widgets #########################
1528 +
1529 +@ajaxfunc(output_type='json')
1530 +def filter_build_rql(self, names, values):
1531 +    form = self._rebuild_posted_form(names, values)
1532 +    self._cw.form = form
1533 +    builder = facetbase.FilterRQLBuilder(self._cw)
1534 +    return builder.build_rql()
1535 +
1536 +@ajaxfunc(output_type='json')
1537 +def filter_select_content(self, facetids, rql, mainvar):
1538 +    # Union unsupported yet
1539 +    select = self._cw.vreg.parse(self._cw, rql).children[0]
1540 +    filtered_variable = facetbase.get_filtered_variable(select, mainvar)
1541 +    facetbase.prepare_select(select, filtered_variable)
1542 +    update_map = {}
1543 +    for fid in facetids:
1544 +        fobj = facetbase.get_facet(self._cw, fid, select, filtered_variable)
1545 +        update_map[fid] = fobj.possible_values()
1546 +    return update_map
1547 +
1548 +
1549 
1550  # facets ######################################################################
1551 
1552  class CWSourceFacet(facetbase.RelationFacet):
1553      __regid__ = 'cw_source-facet'
diff --git a/web/views/forms.py b/web/views/forms.py
@@ -404,11 +404,11 @@
1554          """
1555          if self.force_session_key is not None:
1556              return self.force_session_key
1557          # XXX if this is a json request, suppose we should redirect to the
1558          # entity primary view
1559 -        if self._cw.json_request and self.edited_entity.has_eid():
1560 +        if self._cw.ajax_request and self.edited_entity.has_eid():
1561              return '%s#%s' % (self.edited_entity.absolute_url(), self.domid)
1562          # XXX we should not consider some url parameters that may lead to
1563          # different url after a validation error
1564          return '%s#%s' % (self._cw.url(), self.domid)
1565 
diff --git a/web/views/treeview.py b/web/views/treeview.py
@@ -29,10 +29,11 @@
1566  from cubicweb.utils import make_uid, json
1567  from cubicweb.selectors import adaptable
1568  from cubicweb.view import EntityView
1569  from cubicweb.mixins import _done_init
1570  from cubicweb.web.views import baseviews
1571 +from cubicweb.web.views.ajaxcontroller import ajaxfunc
1572 
1573  def treecookiename(treeid):
1574      return str('%s-treestate' % treeid)
1575 
1576 
@@ -278,5 +279,22 @@
1577          if is_open and not is_leaf: #  => rql is defined
1578              self.wview(parentvid, itree.children(entities=False), subvid=vid,
1579                         treeid=treeid, initial_load=False, **morekwargs)
1580          w(u'</li>')
1581 
1582 +
1583 +
1584 +@ajaxfunc
1585 +def node_clicked(self, treeid, nodeeid):
1586 +    """add/remove eid in treestate cookie"""
1587 +    cookies = self._cw.get_cookie()
1588 +    statename = treecookiename(treeid)
1589 +    treestate = cookies.get(statename)
1590 +    if treestate is None:
1591 +        self._cw.set_cookie(statename, nodeeid)
1592 +    else:
1593 +        marked = set(filter(None, treestate.value.split(':')))
1594 +        if nodeeid in marked:
1595 +            marked.remove(nodeeid)
1596 +        else:
1597 +            marked.add(nodeeid)
1598 +        self._cw.set_cookie(statename, ':'.join(marked))