From 33dcc4d8b8447667cc0d744c1031e70b8d7942d3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 16 Apr 2022 20:18:56 +0200
Subject: [PATCH] forms: add an "autocomplete" display mode to items fields
 (#46161)

---
 tests/form_pages/test_all.py                  | 57 +++++++++++++++++++
 wcs/fields.py                                 | 18 ++++++
 wcs/qommon/form.py                            | 42 +++++++++++++-
 .../qommon/forms/widgets/multiselect.html     | 20 +++++++
 4 files changed, 136 insertions(+), 1 deletion(-)
 create mode 100644 wcs/qommon/templates/qommon/forms/widgets/multiselect.html

diff --git a/tests/form_pages/test_all.py b/tests/form_pages/test_all.py
index 23ade031f..67ce0e80b 100644
--- a/tests/form_pages/test_all.py
+++ b/tests/form_pages/test_all.py
@@ -615,6 +615,63 @@ def test_form_items_submit(pub):
     assert 'The form has been recorded' in page.text
 
 
+def test_form_items_autocomplete(pub):
+    formdef = create_formdef()
+    formdef.fields = [
+        fields.ItemsField(
+            id='0',
+            label='List of items',
+            type='items',
+            required=True,
+            varname='foo',
+            display_mode='autocomplete',
+            items=['Foo', 'Bar', 'Three', 'Four', 'Five', 'Six'],
+        )
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    resp = get_app(pub).get('/test/')
+    assert 'select2.min.js' in resp.text
+    resp = resp.forms[0].submit('submit')  # but the field is required
+    assert resp.pyquery('div.error').text() == 'required field'
+    resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar'])
+    resp = resp.forms[0].submit('submit')
+    assert resp.pyquery('[name="f0[]"] option[selected]').text() == 'Foo Bar'
+    assert resp.pyquery('#form_f0[readonly]').val() == 'Foo, Bar'
+    assert 'Check values then click submit.' in resp.text
+    resp = resp.forms[0].submit('submit').follow()
+    assert 'The form has been recorded' in resp.text
+    assert formdef.data_class().count() == 1
+    data_id = formdef.data_class().select()[0].id
+    data = formdef.data_class().get(data_id)
+    assert data.data['0'] == ['Foo', 'Bar']
+    assert data.data['0_display'] == 'Foo, Bar'
+
+    formdef.data_class().wipe()
+
+    formdef.fields[0].min_choices = 3
+    formdef.fields[0].max_choices = 4
+    formdef.store()
+
+    resp = get_app(pub).get('/test/')
+    resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar'])
+    resp = resp.forms[0].submit('submit')
+    assert resp.pyquery('div.error').text() == 'You must select at least 3 choices.'
+    assert resp.forms[0]['f0[]'].value == ['Foo', 'Bar']
+    resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar', 'Three', 'Four', 'Five'])
+    resp = resp.forms[0].submit('submit')
+    assert resp.pyquery('div.error').text() == 'You must select at most 4 choices.'
+    assert resp.forms[0]['f0[]'].value == ['Foo', 'Bar', 'Three', 'Four', 'Five']
+    resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar', 'Three'])
+    resp = resp.forms[0].submit('submit')  # -> validation
+    resp = resp.forms[0].submit('submit').follow()
+
+    data_id = formdef.data_class().select()[0].id
+    data = formdef.data_class().get(data_id)
+    assert data.data['0'] == ['Foo', 'Bar', 'Three']
+
+
 def test_form_string_with_invalid_xml_chars(pub):
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string')]
diff --git a/wcs/fields.py b/wcs/fields.py
index 9dc787e2f..d28a75b04 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -54,6 +54,7 @@ from .qommon.form import (
     JsonpSingleSelectWidget,
     MapMarkerSelectionWidget,
     MapWidget,
+    MultiSelectWidget,
     PasswordEntryWidget,
     RadiobuttonsWidget,
     RankedItemsWidget,
@@ -2297,6 +2298,7 @@ class ItemsField(WidgetField, ItemFieldMixin):
     data_source = {}
     in_filters = False
     display_disabled_items = False
+    display_mode = 'checkboxes'
 
     widget_class = CheckboxesWidget
 
@@ -2329,6 +2331,9 @@ class ItemsField(WidgetField, ItemFieldMixin):
         if len(kwargs['options']) > 3:
             kwargs['inline'] = False
 
+        if self.display_mode == 'autocomplete':
+            self.widget_class = MultiSelectWidget
+
     def fill_admin_form(self, form):
         WidgetField.fill_admin_form(self, form)
         form.add(
@@ -2338,6 +2343,18 @@ class ItemsField(WidgetField, ItemFieldMixin):
             value=self.in_filters,
             advanced=True,
         )
+        options = [
+            ('checkboxes', _('Checkboxes'), 'checkboxes'),
+            ('autocomplete', _('Autocomplete'), 'autocomplete'),
+        ]
+        form.add(
+            RadiobuttonsWidget,
+            'display_mode',
+            title=_('Display Mode'),
+            options=options,
+            value=self.display_mode,
+            extra_css_class='widget-inline-radio',
+        )
         self.add_items_fields_admin_form(form)
         form.add(
             IntWidget,
@@ -2366,6 +2383,7 @@ class ItemsField(WidgetField, ItemFieldMixin):
     def get_admin_attributes(self):
         return WidgetField.get_admin_attributes(self) + [
             'items',
+            'display_mode',
             'min_choices',
             'max_choices',
             'data_source',
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index 4b01fbe2a..28719596a 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -58,7 +58,7 @@ from quixote import get_publisher, get_request, get_response, get_session
 from quixote.form import CheckboxWidget as QuixoteCheckboxWidget
 from quixote.form import FileWidget
 from quixote.form import Form as QuixoteForm
-from quixote.form import HiddenWidget, IntWidget, PasswordWidget, SelectWidget
+from quixote.form import HiddenWidget, IntWidget, MultipleSelectWidget, PasswordWidget, SelectWidget
 from quixote.form import StringWidget as QuixoteStringWidget
 from quixote.form import TextWidget as QuixoteTextWidget
 from quixote.form import Widget
@@ -2486,6 +2486,46 @@ class SingleSelectHintWidget(SingleSelectWidget):
         return None
 
 
+class MultiSelectWidget(MultipleSelectWidget):
+    template_name = 'qommon/forms/widgets/multiselect.html'
+
+    def __init__(self, name, value=None, **kwargs):
+        self.options_with_attributes = kwargs.pop('options_with_attributes', None)
+        self.readonly = bool(kwargs.pop('readonly', False))
+        self.min_choices = int(kwargs.pop('min_choices', 0) or 0)
+        self.max_choices = int(kwargs.pop('max_choices', 0) or 0)
+        super().__init__(name, value=value, **kwargs)
+
+    def add_media(self):
+        if not self.readonly:
+            get_response().add_javascript(['select2.js'])
+
+    def get_options(self):
+        options = self.options_with_attributes or self.options
+        for option in options:
+            object, description, key = option[:3]
+            yield {
+                'value': key,
+                'label': description,
+                'disabled': bool(self.options_with_attributes and option[-1].get('disabled')),
+                'selected': self.is_selected(object),
+            }
+
+    def get_selected_options_labels(self):
+        return list(x.get('label') for x in self.get_options() if x.get('selected'))
+
+    def _parse(self, request):
+        orig_name, self.name = self.name, self.name + '[]'
+        try:
+            super()._parse(request)
+        finally:
+            self.name = orig_name
+        if self.value and self.min_choices and len(self.value) < self.min_choices:
+            self.set_error(_('You must select at least %d choices.') % self.min_choices)
+        if self.value and self.max_choices and len(self.value) > self.max_choices:
+            self.set_error(_('You must select at most %d choices.') % self.max_choices)
+
+
 class WidgetListAsTable(WidgetList):
     def render_content(self):
         r = TemplateIO(html=True)
diff --git a/wcs/qommon/templates/qommon/forms/widgets/multiselect.html b/wcs/qommon/templates/qommon/forms/widgets/multiselect.html
new file mode 100644
index 000000000..c58d59375
--- /dev/null
+++ b/wcs/qommon/templates/qommon/forms/widgets/multiselect.html
@@ -0,0 +1,20 @@
+{% extends "qommon/forms/widget.html" %}
+{% block widget-control %}
+<select
+    {% if widget.readonly %}
+    hidden
+    id="form_{{widget.name}}_hidden" name="{{widget.name}}[]"
+    {% else %}
+    id="form_{{widget.name}}" name="{{widget.name}}[]"
+    data-autocomplete="true"
+    {% endif %}
+    {% if widget.required %}data-required="true"{% endif %}
+    {% for attr in widget.attrs.items %}{% if attr.0 != 'readonly' %}{{attr.0}}="{{attr.1}}" {% endif %}{% endfor %}>
+  {% for option in widget.get_options %}
+  <option value="{{ option.value }}" {% if option.disabled %}disabled{% endif %} {% if option.selected %}selected{% endif %}>{{ option.label }}</option>
+  {% endfor %}
+</select>
+{% if widget.readonly %}
+<input readonly id="form_{{widget.name}}" value="{{ widget.get_selected_options_labels|join:", " }}">
+{% endif %}
+{% endblock %}
-- 
2.35.2

