From 3db59c0826bdebb4e034d324ccb188abdbb5ba1f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 29 Sep 2020 21:07:22 +0200
Subject: [PATCH] misc: add dynamic autocomplete for <select> of cards (#47152)

---
 tests/test_form_pages.py | 43 ++++++++++++++++++++++++++++++++++++++++
 wcs/api.py               | 13 +++++++++---
 wcs/carddef.py           |  9 ++++++---
 wcs/data_sources.py      |  7 ++++++-
 wcs/sessions.py          | 11 ++++++----
 5 files changed, 72 insertions(+), 11 deletions(-)

diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 5d8f90a8d..2e5a5240f 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -6091,6 +6091,49 @@ def test_item_field_autocomplete_jsonp_source(http_requests, pub):
         assert '0_structured' not in formdef.data_class().select()[0].data
 
 
+def test_item_field_autocomplete_cards_source(pub):
+    user = create_user(pub)
+    formdef = create_formdef()
+    formdef.data_class().wipe()
+
+    carddef = CardDef()
+    carddef.name = 'items'
+    carddef.digest_template = '{{form_var_name}}'
+    carddef.fields = [
+        fields.StringField(id='0', label='string', varname='name'),
+        fields.StringField(id='1', label='string', varname='attr'),
+    ]
+    carddef.store()
+    for i, value in enumerate(['foo', 'bar', 'baz']):
+        carddata = carddef.data_class()()
+        carddata.data = {
+            '0': value,
+            '1': 'attr%s' % i,
+        }
+        carddata.just_created()
+        carddata.store()
+
+    ds = {'type': 'carddef:%s' % carddef.url_name}
+    formdef.fields = [
+        fields.ItemField(id='0', label='string', type='item',
+            data_source=ds,
+            display_mode='autocomplete',
+        ),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get('/test/')
+    select2_url = resp.pyquery('select').attr['data-select2-url']
+    resp2 = app.get(select2_url + '?q=ba')
+    assert [x['text'] for x in resp2.json['data']] == ['bar', 'baz']
+    resp.form['f0'].force_value(str(resp2.json['data'][0]['id']))
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submit
+    assert formdef.data_class().select()[0].data['0'] == '2'
+    assert formdef.data_class().select()[0].data['0_display'] == 'bar'
+
+
 def test_form_data_keywords(pub):
     formdef = create_formdef()
     formdef.keywords = 'hello,world'
diff --git a/wcs/api.py b/wcs/api.py
index 4e018e78f..d047160be 100644
--- a/wcs/api.py
+++ b/wcs/api.py
@@ -829,11 +829,18 @@ class ApiTrackingCodeDirectory(Directory):
 
 class AutocompleteDirectory(Directory):
     def _q_lookup(self, component):
-        url = get_session().get_data_source_query_url_from_token(component)
-        if not url:
+        info = get_session().get_data_source_query_info_from_token(component)
+        if not info:
             raise AccessForbiddenError()
+        get_response().set_content_type('application/json')
+        if info.startswith('carddef:'):
+            from wcs.carddef import CardDef
+            values = CardDef.get_data_source_items(info,
+                    query=get_request().form['q'],
+                    limit=get_request().form.get('page_limit'))
+            return json.dumps({'data': [{'id': x['id'], 'text': x['text']} for x in values]})
+        url = info
         url += urllib.quote(get_request().form['q'])
-        unsigned_url = url
         url = sign_url_auto_orig(url)
         get_response().set_content_type('application/json')
         return misc.urlopen(url).read()
diff --git a/wcs/carddef.py b/wcs/carddef.py
index a076848d4..1783290d5 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -19,7 +19,7 @@ import types
 
 from quixote import get_publisher
 from .qommon import _, N_, misc
-from .qommon.storage import Equal, NotEqual
+from .qommon.storage import Equal, NotEqual, ILike
 
 from wcs.carddata import CardData
 from wcs.formdef import FormDef
@@ -154,7 +154,7 @@ class CardDef(FormDef):
             yield (data_source_id, '%s - %s' % (carddef['name'], custom_view.title), data_source_id)
 
     @classmethod
-    def get_data_source_items(cls, data_source_id):
+    def get_data_source_items(cls, data_source_id, query=None, limit=None):
         assert data_source_id.startswith('carddef:')
         parts = data_source_id.split(':')
         try:
@@ -178,10 +178,13 @@ class CardDef(FormDef):
             criterias.extend(form_page.get_view_criterias())
             order_by = custom_view.order_by
 
+        if query:
+            criterias.append(ILike('digest', query))
         items = [x.get_data_source_structured_item()
                  for x in carddef.data_class().select(
                      clause=criterias,
-                     order_by=order_by)]
+                     order_by=order_by,
+                     limit=limit)]
         if order_by is None:
             items.sort(key=lambda x: misc.simplify(x['text']))
         return items
diff --git a/wcs/data_sources.py b/wcs/data_sources.py
index 73ab1b716..251948c76 100644
--- a/wcs/data_sources.py
+++ b/wcs/data_sources.py
@@ -335,6 +335,8 @@ class NamedDataSource(XmlStorableObject):
             return True
         if self.type == 'json' and self.query_parameter:
             return True
+        if self.type and self.type.startswith('carddef:'):
+            return True
         return False
 
     def migrate(self):
@@ -416,7 +418,10 @@ class NamedDataSource(XmlStorableObject):
             return self.data_source.get('value')
         if self.type == 'json' and self.query_parameter:
             return '/api/autocomplete/%s' % (
-                    get_session().get_data_source_query_url_token(self.get_json_query_url()))
+                    get_session().get_data_source_query_info_token(self.get_json_query_url()))
+        if self.type and self.type.startswith('carddef:'):
+            return '/api/autocomplete/%s' % (
+                    get_session().get_data_source_query_info_token(self.type))
         return None
 
     def get_value_by_id(self, param_name, param_value):
diff --git a/wcs/sessions.py b/wcs/sessions.py
index caf447b60..3ffb612c0 100644
--- a/wcs/sessions.py
+++ b/wcs/sessions.py
@@ -142,18 +142,21 @@ class BasicSession(Session):
                 del session.visiting_objects[object_key]
                 session.store()
 
-    def get_data_source_query_url_token(self, url):
+    def get_data_source_query_info_token(self, info):
+        # info can be an URL in case of remote JSON source or a cards
+        # reference, like carddef:foobar. It is stored in a dictionary
+        # with _url_ in its name for historical reasons.
         if not self.data_source_query_url_tokens:
             self.data_source_query_url_tokens = {}
         for key, value in self.data_source_query_url_tokens.items():
-            if value == url:
+            if value == info:
                 return key
         key = str(uuid.uuid4())
-        self.data_source_query_url_tokens[key] = url
+        self.data_source_query_url_tokens[key] = info
         self.store()
         return key
 
-    def get_data_source_query_url_from_token(self, token):
+    def get_data_source_query_info_from_token(self, token):
         if not self.data_source_query_url_tokens:
             return None
         return self.data_source_query_url_tokens.get(token)
-- 
2.28.0

