From 47652753ec5ca18e21cfee576aef7eeb4cce782f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 2 Jul 2022 12:20:44 +0200
Subject: [PATCH] forms: add dynamic source support to items field (#53763)

---
 tests/form_pages/test_live.py                 | 85 ++++++++++++++++++-
 wcs/fields.py                                 | 18 ++--
 wcs/formdef.py                                |  2 +-
 wcs/forms/common.py                           |  4 +-
 wcs/qommon/static/js/qommon.forms.js          | 32 ++++++-
 .../qommon/forms/widgets/checkboxes.html      |  2 +-
 6 files changed, 128 insertions(+), 15 deletions(-)

diff --git a/tests/form_pages/test_live.py b/tests/form_pages/test_live.py
index 196d78289..c4a847be8 100644
--- a/tests/form_pages/test_live.py
+++ b/tests/form_pages/test_live.py
@@ -5,7 +5,7 @@ import json
 from unittest import mock
 
 import pytest
-from webtest import Hidden
+from webtest import Checkbox, Hidden
 
 from wcs import fields
 from wcs.blocks import BlockDef
@@ -591,6 +591,89 @@ def test_field_live_select(pub, http_requests):
     assert len(live_resp.json['result']['3']['items']) == 1
 
 
+def test_field_live_items_checkboxes(pub, http_requests):
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'Foo'
+    formdef.fields = [
+        fields.StringField(type='string', id='2', label='Bar2', size='40', required=True, varname='bar2'),
+        fields.ItemsField(
+            type='items',
+            id='3',
+            label='Foo',
+            data_source={
+                'type': 'json',
+                'value': '{% if form_var_bar2 %}http://remote.example.net/json-list?plop={{form_var_bar2}}{% endif %}',
+            },
+        ),
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    app = get_app(pub)
+    resp = app.get('/foo/')
+    assert resp.html.find('div', {'data-field-id': '2'}).attrs['data-live-source'] == 'true'
+    assert resp.html.find('div', {'data-field-id': '3'}).find('ul')
+    assert not resp.html.find('div', {'data-field-id': '3'}).find('ul li')
+    resp.form['f2'] = 'plop'
+    live_resp = app.post('/foo/live?modified_field_id=2', params=resp.form.submit_fields())
+    assert len(live_resp.json['result']['3']['items']) == 1
+    # simulate js, add relevant checkboxes
+    for option in live_resp.json['result']['3']['items']:
+        checkbox_name = '%s$element%s' % (
+            resp.html.find('div', {'data-field-id': '3'}).attrs['data-widget-name'],
+            option['id'],
+        )
+        resp.form.fields[checkbox_name] = Checkbox(
+            form=resp.form, name=checkbox_name, tag='input', value='yes', pos=10
+        )
+        resp.form.field_order.append((checkbox_name, resp.form.fields[checkbox_name]))
+    resp.form.fields[checkbox_name].checked = True
+    resp = resp.form.submit('submit')  # -> validation
+    assert resp.pyquery('.CheckboxesWidget li label').text() == 'b'
+    resp = resp.form.submit('submit')  # -> submitted
+    assert formdef.data_class().select()[0].data['3'] == ['a']
+    assert formdef.data_class().select()[0].data['3_display'] == 'b'
+
+
+def test_field_live_items_select_multiple(pub, http_requests):
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'Foo'
+    formdef.fields = [
+        fields.StringField(type='string', id='2', label='Bar2', size='40', required=True, varname='bar2'),
+        fields.ItemsField(
+            type='items',
+            display_mode='autocomplete',
+            id='3',
+            label='Foo',
+            data_source={
+                'type': 'json',
+                'value': '{% if form_var_bar2 %}http://remote.example.net/json-list?plop={{form_var_bar2}}{% endif %}',
+            },
+        ),
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    app = get_app(pub)
+    resp = app.get('/foo/')
+    assert resp.html.find('div', {'data-field-id': '2'}).attrs['data-live-source'] == 'true'
+    assert resp.html.find('div', {'data-field-id': '3'}).find('select')
+    assert not resp.html.find('div', {'data-field-id': '3'}).find('select option')
+    resp.form['f2'] = 'plop'
+    live_resp = app.post('/foo/live?modified_field_id=2', params=resp.form.submit_fields())
+    assert len(live_resp.json['result']['3']['items']) == 1
+    # simulate js, add relevant options
+    resp.form['f3[]'].options = [(x['id'], False, x['text']) for x in live_resp.json['result']['3']['items']]
+    resp.form['f3[]'].select_multiple(['a'])
+    resp = resp.form.submit('submit')  # -> validation
+    assert resp.pyquery('select option[selected]').text() == 'b'
+    resp = resp.form.submit('submit')  # -> submitted
+    assert formdef.data_class().select()[0].data['3'] == ['a']
+    assert formdef.data_class().select()[0].data['3_display'] == 'b'
+
+
 def test_field_live_template_content(pub, http_requests):
     FormDef.wipe()
     formdef = FormDef()
diff --git a/wcs/fields.py b/wcs/fields.py
index c685cd77b..5a453cccb 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -2066,6 +2066,15 @@ class ItemFieldMixin:
         except KeyError:
             return None
 
+    def get_extended_options(self):
+        if self.data_source:
+            return data_sources.get_structured_items(
+                self.data_source, mode='lazy', include_disabled=self.display_disabled_items
+            )
+        if self.items:
+            return [{'id': x, 'text': x} for x in self.items]
+        return []
+
 
 class ItemField(WidgetField, MapOptionsMixin, ItemFieldMixin):
     key = 'item'
@@ -2126,15 +2135,6 @@ class ItemField(WidgetField, MapOptionsMixin, ItemFieldMixin):
             return data_sources.get_id_by_option_text(self.data_source, text_value)
         return text_value
 
-    def get_extended_options(self):
-        if self.data_source:
-            return data_sources.get_structured_items(
-                self.data_source, mode='lazy', include_disabled=self.display_disabled_items
-            )
-        if self.items:
-            return [{'id': x, 'text': x} for x in self.items]
-        return []
-
     def get_display_mode(self, data_source=None):
         if not data_source:
             data_source = data_sources.get_object(self.data_source)
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 7732ef1a5..cc3a6dc04 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -967,7 +967,7 @@ class FormDef(StorableObject):
                     if varname not in live_condition_fields:
                         live_condition_fields[varname] = []
                     live_condition_fields[varname].append(field)
-            if field.key == 'item' and field.data_source:
+            if field.key in ('item', 'items') and field.data_source:
                 data_source = data_sources.get_object(field.data_source)
                 if data_source.type not in ('json', 'geojson') and not data_source.type.startswith(
                     'carddef:'
diff --git a/wcs/forms/common.py b/wcs/forms/common.py
index 22fbd2eb6..9bb14cd3c 100644
--- a/wcs/forms/common.py
+++ b/wcs/forms/common.py
@@ -785,7 +785,7 @@ class FormStatusPage(Directory, FormTemplateMixin):
                     break
 
         for field in displayed_fields:
-            if field.key == 'item' and field.data_source:
+            if field.key in ('item', 'items') and field.data_source:
                 data_source = data_sources.get_object(field.data_source)
                 if data_source.type not in ('json', 'geojson') and not data_source.type.startswith(
                     'carddef:'
@@ -793,7 +793,7 @@ class FormStatusPage(Directory, FormTemplateMixin):
                     continue
                 varnames = data_source.get_referenced_varnames(field.formdef)
                 if (not modified_field_varnames or modified_field_varnames.intersection(varnames)) and (
-                    field.display_mode == 'autocomplete' and data_source.can_jsonp()
+                    field.display_mode == 'autocomplete' and data_source.can_jsonp() and field.type != 'items'
                 ):
                     # computed earlier, in perform_more_widget_changes, when the field
                     # was added to the form
diff --git a/wcs/qommon/static/js/qommon.forms.js b/wcs/qommon/static/js/qommon.forms.js
index 76161e9e4..82cfa574a 100644
--- a/wcs/qommon/static/js/qommon.forms.js
+++ b/wcs/qommon/static/js/qommon.forms.js
@@ -514,6 +514,35 @@ $(function() {
               $label.appendTo($content);
             }
             $hint.appendTo($content);
+          } else if (value.items && $widget.is('.CheckboxesWidget')) {
+            var widget_name = $widget.data('widget-name');
+            var $ul = $widget.find('ul');
+            var current_value = $ul.find('input[type=checkbox]'
+                    ).filter(function() {return this.checked}
+                    ).map(function() {return this.name;}
+                    ).toArray();
+            var base_for_name = $ul.data('base-for-name');
+            var input_name = $widget.data('widget-name');
+            $ul.empty();
+            for (var i=0; i<value.items.length; i++) {
+              var $li = $('<li>');
+              var $label = $('<label>', {'for': base_for_name + i});
+              var $input = $('<input>', {
+                      type: 'checkbox', 'id': base_for_name + i,
+                      value: 'yes', name: widget_name + '$element' + value.items[i].id});
+              if (current_value.indexOf(widget_name + '$element' + value.items[i].id) != -1) {
+                $input.attr('checked', 'checked');
+              }
+              if (value.items[i].disabled) {
+                $input.prop('disabled', true);
+                $li.addClass('disabled');
+              }
+              var $span = $('<span>', {text: value.items[i].text});
+              $input.appendTo($label);
+              $span.appendTo($label);
+              $label.appendTo($li);
+              $li.appendTo($ul);
+            }
           } else if (value.items) {
             // replace <select> contents
             var $select = $widget.find('select');
@@ -527,7 +556,8 @@ $(function() {
             }
             for (var i=0; i<value.items.length; i++) {
               var $option = $('<option></option>', {value: value.items[i].id, text: value.items[i].text});
-              if (value.items[i].id == current_value) {
+              if ((Array.isArray(current_value) && current_value.indexOf(value.items[i].id.toString()) != -1) ||
+                      value.items[i].id == current_value) {
                 $option.attr('selected', 'selected');
                 value.items[i].selected = true;
               }
diff --git a/wcs/qommon/templates/qommon/forms/widgets/checkboxes.html b/wcs/qommon/templates/qommon/forms/widgets/checkboxes.html
index 951256c41..c17993b3d 100644
--- a/wcs/qommon/templates/qommon/forms/widgets/checkboxes.html
+++ b/wcs/qommon/templates/qommon/forms/widgets/checkboxes.html
@@ -1,7 +1,7 @@
 {% extends "qommon/forms/widget.html" %}
 
 {% block widget-control %}
-<ul {% if widget.inline %}class="inline"{% endif %}>
+<ul {% if widget.inline %}class="inline"{% endif %} data-base-for-name="{{ widget.get_name_for_id }}_op_">
   {% for option in widget.get_options %}
   <li {% if option.disabled %}class="disabled"{% endif %}><label for="{{ widget.get_name_for_id }}_op_{{ forloop.counter0 }}"><input
    id="{{ widget.get_name_for_id }}_op_{{ forloop.counter0 }}"
-- 
2.36.1

