From 1a072639eb599ceb44cadac9aee7ef3668268018 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 12 Nov 2021 10:06:16 +0100
Subject: [PATCH] blocks: min_items configuration (#58450)

---
 tests/form_pages/test_block.py                | 91 +++++++++++++++++++
 wcs/blocks.py                                 | 15 ++-
 wcs/fields.py                                 | 12 ++-
 wcs/qommon/form.py                            |  6 +-
 wcs/qommon/static/js/qommon.forms.js          |  4 +-
 wcs/qommon/templates/qommon/forms/widget.html |  1 +
 .../templates/qommon/forms/widgets/block.html |  1 +
 7 files changed, 122 insertions(+), 8 deletions(-)

diff --git a/tests/form_pages/test_block.py b/tests/form_pages/test_block.py
index 711c058f..eaae6142 100644
--- a/tests/form_pages/test_block.py
+++ b/tests/form_pages/test_block.py
@@ -895,6 +895,57 @@ def test_block_repeated(pub):
     assert '>bar2<' in resp
 
 
+def test_block_repeated_with_min(pub):
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.StringField(id='234', required=True, label='Test2', type='string'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.BlockField(
+            id='1', label='test', type='block:foobar', min_items=2, max_items=3, hint='hintblock'
+        ),
+        fields.PageField(id='2', label='2nd page', type='page'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    assert resp.text.count('>Test<') == 2
+    assert resp.text.count('>hintblock<') == 1
+    assert 'wcs-block-add-clicked' not in resp
+    assert 'Add another' in resp
+    assert resp.html.find('div', {'class': 'list-add'})
+    resp = resp.form.submit('f1$add_element')
+    assert resp.text.count('>Test<') == 3
+    assert resp.text.count('>hintblock<') == 1
+    assert resp.pyquery('.list-add').attr['style'] == 'display: none'
+
+    formdef.fields[1].min_items = 3
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    assert resp.text.count('>Test<') == 3
+    assert resp.text.count('>hintblock<') == 1
+    assert resp.pyquery('.list-add').attr['style'] == 'display: none'
+
+    formdef.fields[1].min_items = 4
+    formdef.store()
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    assert resp.text.count('>Test<') == 3
+
+
 def test_block_repeated_over_limit(pub):
     FormDef.wipe()
     BlockDef.wipe()
@@ -943,6 +994,46 @@ def test_block_repeated_over_limit(pub):
     assert 'Too many elements (maximum: 2)' in resp
 
 
+def test_block_repeated_under_limit(pub):
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.StringField(id='234', required=True, label='Test2', type='string'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.BlockField(id='1', label='test', type='block:foobar', min_items=2, max_items=3),
+        fields.PageField(id='2', label='2nd page', type='page'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    assert resp.text.count('>Test<') == 2
+
+    # fill items
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = 'bar'
+
+    # submit form - it's ok to have empty block items if block fields are not required
+    resp = resp.form.submit('submit')
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submit
+    resp = resp.follow()
+    assert '>foo<' in resp
+    assert '>bar<' in resp
+    formdata = formdef.data_class().select()[0]
+    assert len(formdata.data['1']['data']) == 1
+
+
 def test_block_repeated_files(pub):
     FormDef.wipe()
     BlockDef.wipe()
diff --git a/wcs/blocks.py b/wcs/blocks.py
index a1ec62ce..44475fec 100644
--- a/wcs/blocks.py
+++ b/wcs/blocks.py
@@ -245,7 +245,15 @@ class BlockWidget(WidgetList):
     always_include_add_button = True
 
     def __init__(
-        self, name, value=None, title=None, block=None, max_items=None, add_element_label=None, **kwargs
+        self,
+        name,
+        value=None,
+        title=None,
+        block=None,
+        min_items=None,
+        max_items=None,
+        add_element_label=None,
+        **kwargs,
     ):
         self.block = block
         self.readonly = kwargs.get('readonly')
@@ -254,8 +262,8 @@ class BlockWidget(WidgetList):
         element_values = None
         if value:
             element_values = value.get('data')
-        if not max_items:
-            max_items = 1
+        max_items = max_items or 1
+        min_items = min(min_items or 1, max_items)
         hint = kwargs.pop('hint', None)
         element_kwargs = {'block': self.block, 'render_br': False, 'remove_button': self.remove_button}
         element_kwargs.update(kwargs)
@@ -263,6 +271,7 @@ class BlockWidget(WidgetList):
             name,
             value=element_values,
             title=title,
+            min_items=min_items,
             max_items=max_items,
             element_type=BlockSubWidget,
             element_kwargs=element_kwargs,
diff --git a/wcs/fields.py b/wcs/fields.py
index f940a2d9..4e87c7c7 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -3323,8 +3323,16 @@ class BlockField(WidgetField):
     allow_complex = True
 
     widget_class = BlockWidget
+    min_items = 1
     max_items = 1
-    extra_attributes = ['block', 'max_items', 'add_element_label', 'label_display', 'remove_button']
+    extra_attributes = [
+        'block',
+        'min_items',
+        'max_items',
+        'add_element_label',
+        'label_display',
+        'remove_button',
+    ]
     add_element_label = ''
     label_display = 'normal'
     remove_button = False
@@ -3346,6 +3354,7 @@ class BlockField(WidgetField):
         super().fill_admin_form(form)
         if form.get_widget('prefill'):
             form.remove('prefill')
+        form.add(IntWidget, 'min_items', title=_('Minimum number of items'), value=self.min_items)
         form.add(IntWidget, 'max_items', title=_('Maximum number of items'), value=self.max_items)
         form.add(
             StringWidget, 'add_element_label', title=_('Label of "Add" button'), value=self.add_element_label
@@ -3366,6 +3375,7 @@ class BlockField(WidgetField):
 
     def get_admin_attributes(self):
         return super().get_admin_attributes() + [
+            'min_items',
             'max_items',
             'add_element_label',
             'label_display',
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index c2717bff..41b046b0 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -1682,6 +1682,7 @@ class WidgetList(quixote.form.widget.WidgetList):
         element_type=StringWidget,
         element_kwargs=None,
         add_element_label="Add row",
+        min_items=None,
         max_items=None,
         **kwargs,
     ):
@@ -1695,14 +1696,15 @@ class WidgetList(quixote.form.widget.WidgetList):
         self.element_type = element_type
         self.element_kwargs = element_kwargs or {}
         self.element_names = []
+        self.min_items = min_items or 1
         self.max_items = max_items
 
         # Add element widgets for initial value
         if value is not None:
             for element_value in value:
                 self.add_element(value=element_value)
-        if not self.element_names:
-            # Add at least an element widget
+        while len(self.element_names) < self.min_items:
+            # Add elements until min_items
             self.add_element()
 
         if not kwargs.get('readonly'):
diff --git a/wcs/qommon/static/js/qommon.forms.js b/wcs/qommon/static/js/qommon.forms.js
index e4366e17..2173f364 100644
--- a/wcs/qommon/static/js/qommon.forms.js
+++ b/wcs/qommon/static/js/qommon.forms.js
@@ -516,7 +516,7 @@ $(function() {
 
   function disable_single_block_remove_button() {
     $('.BlockSubWidget button.remove-button').each(function(i, elem) {
-      if ($(this).parents('.BlockWidget').find('.BlockSubWidget').length == 1) {
+      if ($(this).parents('.BlockWidget').find('.BlockSubWidget').length == parseInt($(this).parents('.BlockWidget').data('min-items'), 10)) {
         $(this).prop('disabled', true);
       }
     });
@@ -525,7 +525,7 @@ $(function() {
   if ($('.BlockWidget').length) {
     disable_single_block_remove_button();
     $('form').on('click', '.BlockSubWidget button.remove-button', function() {
-      if ($(this).parents('.BlockWidget').find('.BlockSubWidget').length > 1) {
+      if ($(this).parents('.BlockWidget').find('.BlockSubWidget').length > parseInt($(this).parents('.BlockWidget').data('min-items'), 10)) {
         $(this).parents('.BlockWidget').find('.list-add').show();
         $(this).parents('.BlockSubWidget').remove();
         disable_single_block_remove_button();
diff --git a/wcs/qommon/templates/qommon/forms/widget.html b/wcs/qommon/templates/qommon/forms/widget.html
index 89dc2502..9b12b6cf 100644
--- a/wcs/qommon/templates/qommon/forms/widget.html
+++ b/wcs/qommon/templates/qommon/forms/widget.html
@@ -20,6 +20,7 @@
      data-dynamic-display-value-in="{{widget.attrs|get:"data-dynamic-display-value-in"}}"
      {% endif %}
      {% if widget.live_condition_source %}data-live-source="true"{% endif %}
+     {% block widget-extra-data %}{% endblock %}
      >
   {% block widget-title %}
   {{widget.rendered_title}}
diff --git a/wcs/qommon/templates/qommon/forms/widgets/block.html b/wcs/qommon/templates/qommon/forms/widgets/block.html
index 53274e44..3af1513b 100644
--- a/wcs/qommon/templates/qommon/forms/widgets/block.html
+++ b/wcs/qommon/templates/qommon/forms/widgets/block.html
@@ -1,3 +1,4 @@
 {% extends "qommon/forms/widget.html" %}
 
 {% block widget-css-classes %}{{ block.super }} {% if widget.had_add_clicked %}wcs-block-add-clicked{% endif %} {% if widget.remove_button %}wcs-block-with-remove-button{% endif %}{% endblock %}
+{% block widget-extra-data %}data-min-items={{ widget.min_items }}{% endblock %}
-- 
2.33.0

