From 3e2404e80a1fa40470578a82d207aaed1ddcdade Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Mon, 1 Feb 2021 19:37:42 +0100
Subject: [PATCH] blocks: add option to have a remove button (#45368)

---
 tests/form_pages/test_block.py                | 73 +++++++++++++++++++
 wcs/blocks.py                                 | 13 +++-
 wcs/fields.py                                 |  6 +-
 wcs/qommon/form.py                            |  7 +-
 wcs/qommon/static/css/dc2/admin.scss          | 27 +++++++
 wcs/qommon/static/js/qommon.forms.js          | 17 +++++
 .../templates/qommon/forms/widgets/block.html |  3 +
 .../qommon/forms/widgets/block_sub.html       |  9 +++
 8 files changed, 148 insertions(+), 7 deletions(-)
 create mode 100644 wcs/qommon/templates/qommon/forms/widgets/block.html
 create mode 100644 wcs/qommon/templates/qommon/forms/widgets/block_sub.html

diff --git a/tests/form_pages/test_block.py b/tests/form_pages/test_block.py
index 0e76e390b..e38a09837 100644
--- a/tests/form_pages/test_block.py
+++ b/tests/form_pages/test_block.py
@@ -870,6 +870,79 @@ def test_block_repeated_files(pub, blocks_feature):
     assert 'test2.txt' in resp
 
 
+@pytest.mark.parametrize('removed_line', [0, 1, 2])
+def test_block_repeated_remove_line(pub, blocks_feature, removed_line):
+    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',
+            max_items=5, hint='hintblock', remove_button=True),
+        fields.PageField(id='2', label='2nd page', type='page'),
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    assert resp.text.count('>Test<') == 1
+    resp = resp.form.submit('f1$add_element')
+    assert resp.text.count('>Test<') == 2
+    resp = resp.form.submit('f1$add_element')
+    assert resp.text.count('>Test<') == 3
+
+    # fill items on three rows
+    resp.form['f1$element0$f123'] = 'foo1'
+    resp.form['f1$element0$f234'] = 'bar1'
+    resp.form['f1$element1$f123'] = 'foo2'
+    resp.form['f1$element1$f234'] = 'bar2'
+    resp.form['f1$element2$f123'] = 'foo3'
+    resp.form['f1$element2$f234'] = 'bar3'
+
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+    assert resp.form['f1$element0$f123'].value == 'foo1'
+    assert resp.form['f1$element0$f234'].value == 'bar1'
+    assert resp.form['f1$element1$f123'].value == 'foo2'
+    assert resp.form['f1$element1$f234'].value == 'bar2'
+    assert resp.form['f1$element2$f123'].value == 'foo3'
+    assert resp.form['f1$element2$f234'].value == 'bar3'
+
+    resp = resp.form.submit('previous')  # -> 2nd page
+    resp = resp.form.submit('previous')  # -> 1st page
+    # simulate javascript removing of block elements from DOM
+    resp.form.field_order.remove(('f1$element%s$f123' % removed_line, resp.form.fields['f1$element%s$f123' % removed_line][0]))
+    del resp.form.fields['f1$element%s$f123' % removed_line]
+    resp.form.field_order.remove(('f1$element%s$f234' % removed_line, resp.form.fields['f1$element%s$f234' % removed_line][0]))
+    del resp.form.fields['f1$element%s$f234' % removed_line]
+
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    values = ['1', '2', '3']
+    del values[removed_line]
+    assert resp.form['f1$element0$f123'].value == 'foo%s' % values[0]
+    assert resp.form['f1$element0$f234'].value == 'bar%s' % values[0]
+    assert resp.form['f1$element1$f123'].value == 'foo%s' % values[1]
+    assert resp.form['f1$element1$f234'].value == 'bar%s' % values[1]
+    assert 'f1$element2$f123' not in resp.form.fields
+    assert 'f1$element2$f234' not in resp.form.fields
+
+    resp = resp.form.submit('submit')  # -> submit
+    assert len(formdef.data_class().select()[0].data['1']['data']) == 2
+
+
 @pytest.mark.parametrize('block_name', ['foobar', 'Foo bar'])
 def test_block_digest(pub, blocks_feature, block_name):
     FormDef.wipe()
diff --git a/wcs/blocks.py b/wcs/blocks.py
index c9d57cd55..4157a681d 100644
--- a/wcs/blocks.py
+++ b/wcs/blocks.py
@@ -191,9 +191,12 @@ class BlockDef(StorableObject):
 
 
 class BlockSubWidget(CompositeWidget):
+    template_name = 'qommon/forms/widgets/block_sub.html'
+
     def __init__(self, name, value=None, *args, **kwargs):
         self.block = kwargs.pop('block')
         self.readonly = kwargs.get('readonly')
+        self.remove_button = kwargs.pop('remove_button', False)
         super().__init__(name, value, *args, **kwargs)
         for field in self.block.fields:
             if 'readonly' in kwargs:
@@ -239,19 +242,22 @@ class BlockSubWidget(CompositeWidget):
 
 
 class BlockWidget(WidgetList):
+    template_name = 'qommon/forms/widgets/block.html'
+
     def __init__(
         self, name, value=None, title=None, block=None, max_items=None, add_element_label=None, **kwargs
     ):
         self.block = block
         self.readonly = kwargs.get('readonly')
         self.label_display = kwargs.pop('label_display') or 'normal'
+        self.remove_button = kwargs.pop('remove_button', False)
         element_values = None
         if value:
             element_values = value.get('data')
         if not max_items:
             max_items = 1
         hint = kwargs.pop('hint', None)
-        element_kwargs = {'block': self.block, 'render_br': False}
+        element_kwargs = {'block': self.block, 'render_br': False, 'remove_button': self.remove_button}
         element_kwargs.update(kwargs)
         super().__init__(
             name,
@@ -274,8 +280,9 @@ class BlockWidget(WidgetList):
         # (maybe this could be moved to WidgetList)
         prefix = '%s$element' % self.name
         known_prefixes = {x.split('$', 2)[1] for x in request.form.keys() if x.startswith(prefix)}
-        for i in range(len(known_prefixes) - len(self.element_names)):
-            self.add_element()
+        for prefix in known_prefixes:
+            if prefix not in self.element_names:
+                self.add_element(element_name=prefix)
         super()._parse(request)
         if self.value:
             self.value = {'data': self.value}
diff --git a/wcs/fields.py b/wcs/fields.py
index 9903ff390..72765c491 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -2763,7 +2763,7 @@ class BlockField(WidgetField):
 
     widget_class = BlockWidget
     max_items = 1
-    extra_attributes = ['block', 'max_items', 'add_element_label', 'label_display']
+    extra_attributes = ['block', 'max_items', 'add_element_label', 'label_display', 'remove_button']
     add_element_label = ''
     label_display = 'normal'
 
@@ -2796,9 +2796,11 @@ class BlockField(WidgetField):
         form.add(
             SingleSelectWidget, 'label_display', title=_('Label display'),
             value=self.label_display or 'normal', options=display_options)
+        form.add(CheckboxWidget, 'remove_button', title=_('Include remove button'))
 
     def get_admin_attributes(self):
-        return super().get_admin_attributes() + ['max_items', 'add_element_label', 'label_display']
+        return super().get_admin_attributes() + [
+                'max_items', 'add_element_label', 'label_display', 'remove_button']
 
     def store_display_value(self, data, field_id):
         value = data.get(field_id)
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index a3fc3f6c7..cff90d095 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -1608,8 +1608,11 @@ class WidgetList(quixote.form.widget.WidgetList):
                     self.widgets.remove(self.get_widget('add_element'))
                     del self._names['add_element']
 
-    def add_element(self, value=None):
-        name = "element%d" % len(self.element_names)
+    def add_element(self, value=None, element_name=None):
+        if element_name:
+            name = element_name
+        else:
+            name = 'element%d' % len(self.element_names)
         self.add(self.element_type, name, value=value, **self.element_kwargs)
         self.element_names.append(name)
 
diff --git a/wcs/qommon/static/css/dc2/admin.scss b/wcs/qommon/static/css/dc2/admin.scss
index 90c64cac0..7160d73d8 100644
--- a/wcs/qommon/static/css/dc2/admin.scss
+++ b/wcs/qommon/static/css/dc2/admin.scss
@@ -3,6 +3,20 @@ $secondary-color: #00d6eb;
 $string-color: str-slice($primary-color + '', 2);
 $actions: add, duplicate, edit, remove;
 
+@mixin clearfix {
+	&::after {
+		display: block;
+		clear: both;
+		content: "";
+	}
+	@supports (display: flow-root) {
+		display: flow-root;
+		&::after {
+			content: none;
+		}
+	}
+}
+
 %block {
 	background: white;
 	padding: 0.5rem;
@@ -2104,3 +2118,16 @@ div.timetable-widget {
 		padding-bottom: 1ex;
 	}
 }
+
+.wcs-block-with-remove-button {
+	.BlockSubWidget {
+		@include clearfix();
+		position: relative;
+	}
+	.remove-button {
+		position: absolute;
+		right: 0;
+		bottom: 1.5em;
+		margin-right: 0;
+	}
+}
diff --git a/wcs/qommon/static/js/qommon.forms.js b/wcs/qommon/static/js/qommon.forms.js
index 1ec171be3..3033296d3 100644
--- a/wcs/qommon/static/js/qommon.forms.js
+++ b/wcs/qommon/static/js/qommon.forms.js
@@ -470,4 +470,21 @@ $(function() {
     const table = elem.querySelector('table');
     new Responsive_table_widget(table);
   });
+
+  function disable_single_block_remove_button() {
+    $('.BlockSubWidget button.remove-button').each(function(i, elem) {
+      if ($(this).parents('.BlockWidget').find('.BlockSubWidget').length == 1) {
+        $(this).prop('disabled', true);
+      }
+    });
+  }
+
+  disable_single_block_remove_button();
+  $('.BlockSubWidget button.remove-button').on('click', function() {
+    if ($(this).parents('.BlockWidget').find('.BlockSubWidget').length > 1) {
+      $(this).parents('.BlockSubWidget').remove();
+      disable_single_block_remove_button();
+    }
+    return false;
+  });
 });
diff --git a/wcs/qommon/templates/qommon/forms/widgets/block.html b/wcs/qommon/templates/qommon/forms/widgets/block.html
new file mode 100644
index 000000000..3c7b4a510
--- /dev/null
+++ b/wcs/qommon/templates/qommon/forms/widgets/block.html
@@ -0,0 +1,3 @@
+{% extends "qommon/forms/widget.html" %}
+
+{% block widget-css-classes %}{{ block.super }} wcs-block-with-remove-button{% endblock %}
diff --git a/wcs/qommon/templates/qommon/forms/widgets/block_sub.html b/wcs/qommon/templates/qommon/forms/widgets/block_sub.html
new file mode 100644
index 000000000..cba21e133
--- /dev/null
+++ b/wcs/qommon/templates/qommon/forms/widgets/block_sub.html
@@ -0,0 +1,9 @@
+{% extends "qommon/forms/widget.html" %}
+{% load i18n %}
+
+{% block widget-content %}
+{% for subwidget in widget.get_widgets %}
+  {{ subwidget.render|safe }}
+{% endfor %}
+{% if not widget.readonly and widget.remove_button %}<button class="remove-button" title="{% trans "Remove" %}">-</button>{% endif %}
+{% endblock %}
-- 
2.30.0

