From 6a73eb1a84ded9371c158cfd3da4e51da22c9960 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 26 Mar 2021 09:08:21 +0100
Subject: [PATCH] cards: create related card in a popup (#48534)

---
 tests/backoffice_pages/test_carddata.py       | 109 ++++++++++++++++++
 wcs/api.py                                    |   4 +-
 wcs/backoffice/data_management.py             |  41 ++++---
 wcs/backoffice/submission.py                  |   5 +-
 wcs/carddef.py                                |   8 ++
 wcs/fields.py                                 |  17 ++-
 wcs/forms/root.py                             |  20 +++-
 wcs/qommon/form.py                            |   3 +-
 wcs/qommon/static/css/dc2/admin.scss          |   9 ++
 wcs/qommon/static/css/qommon.scss             |   4 +
 wcs/qommon/static/js/popup_response.js        |   4 +
 wcs/qommon/static/js/qommon.admin.js          |  49 ++++++++
 .../qommon/forms/widgets/select_jsonp.html    |   7 ++
 .../wcs/backoffice/popup_response.html        |  11 ++
 wcs/templates/wcs/formdata_filling.html       |   2 +-
 wcs/templates/wcs/formdata_popup_filling.html |  15 +++
 wcs/views.py                                  |  27 +++--
 17 files changed, 294 insertions(+), 41 deletions(-)
 create mode 100644 wcs/qommon/static/js/popup_response.js
 create mode 100644 wcs/templates/wcs/backoffice/popup_response.html
 create mode 100644 wcs/templates/wcs/formdata_popup_filling.html

diff --git a/tests/backoffice_pages/test_carddata.py b/tests/backoffice_pages/test_carddata.py
index bb7bd9c2..474a64b8 100644
--- a/tests/backoffice_pages/test_carddata.py
+++ b/tests/backoffice_pages/test_carddata.py
@@ -650,3 +650,112 @@ def test_carddata_edit_user_selection(pub):
     assert 'Associated User' in resp
     assert carddef.data_class().get(carddata.id).user_id == str(user.id)
     assert '/user-pending-forms' not in resp.text
+
+
+def test_carddata_add_related(pub):
+    user = create_user(pub)
+
+    BlockDef.wipe()
+    block = BlockDef()
+    block.name = 'child'
+    block.fields = [
+        fields.ItemField(
+            id='1',
+            label='Child',
+            type='item',
+            data_source={'type': 'carddef:child'},
+            display_mode='autocomplete',
+        ),
+    ]
+    block.store()
+
+    CardDef.wipe()
+    family = CardDef()
+    family.name = 'Family'
+    family.fields = [
+        fields.ItemField(
+            id='1',
+            label='RL1',
+            type='item',
+            data_source={'type': 'carddef:adult'},
+            display_mode='autocomplete',
+        ),
+        fields.ItemField(
+            id='2',
+            label='RL2',
+            type='item',
+            data_source={'type': 'carddef:adult'},
+            display_mode='autocomplete',
+        ),
+        fields.BlockField(id='3', label='Children', type='block:child', max_items=42),
+    ]
+    family.backoffice_submission_roles = user.roles
+    family.workflow_roles = {'_editor': user.roles[0]}
+    family.store()
+    family.data_class().wipe()
+
+    adult = CardDef()
+    adult.name = 'Adult'
+    adult.fields = [
+        fields.ItemField(
+            id='1',
+            label='First name',
+            type='string',
+        ),
+        fields.ItemField(
+            id='2',
+            label='Last name',
+            type='string',
+        ),
+    ]
+    adult.backoffice_submission_roles = user.roles
+    adult.workflow_roles = {'_editor': user.roles[0]}
+    adult.store()
+    adult.data_class().wipe()
+
+    child = CardDef()
+    child.name = 'Child'
+    child.fields = [
+        fields.ItemField(
+            id='1',
+            label='First name',
+            type='string',
+        ),
+        fields.ItemField(
+            id='2',
+            label='Last name',
+            type='string',
+        ),
+    ]
+    child.backoffice_submission_roles = user.roles
+    child.workflow_roles = {'_editor': user.roles[0]}
+    child.store()
+    child.data_class().wipe()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/data/family/add/')
+    assert 'Add another RL1' in resp
+    assert 'Add another RL2' in resp
+    assert 'Add another Child' in resp
+    assert resp.text.count('/backoffice/data/adult/add/?_popup=1') == 2
+    assert '/backoffice/data/child/add/?_popup=1' in resp
+
+    # no autocompletion for RL1
+    family.fields[0].display_mode = []
+    family.store()
+    resp = app.get('/backoffice/data/family/add/')
+    assert 'Add another RL1' not in resp
+    assert 'Add another RL2' in resp
+    assert 'Add another Child' in resp
+    assert resp.text.count('/backoffice/data/adult/add/?_popup=1') == 1
+    assert '/backoffice/data/child/add/?_popup=1' in resp
+
+    # user ha no creation rights on child
+    child.backoffice_submission_roles = None
+    child.store()
+    resp = app.get('/backoffice/data/family/add/')
+    assert 'Add another RL1' not in resp
+    assert 'Add another RL2' in resp
+    assert 'Add another Child' not in resp
+    assert resp.text.count('/backoffice/data/adult/add/?_popup=1') == 1
+    assert '/backoffice/data/child/add/?_popup=1' not in resp
diff --git a/wcs/api.py b/wcs/api.py
index 879d8342..e5d34d4a 100644
--- a/wcs/api.py
+++ b/wcs/api.py
@@ -278,7 +278,7 @@ class ApiCardPage(ApiFormPageMixin, BackofficeCardPage):
         json_input = get_request().json
         formdata = self.formdef.data_class()()
 
-        if not (user and self.can_user_add_cards()):
+        if not (user and self.formdef.can_user_add_cards(user)):
             raise AccessForbiddenError('cannot create card')
 
         if 'data' in json_input:
@@ -335,7 +335,7 @@ class ApiCardPage(ApiFormPageMixin, BackofficeCardPage):
         if get_request().get_method() != 'PUT':
             raise MethodNotAllowedError(allowed_methods=['PUT'])
         get_request()._user = get_user_from_api_query_string()
-        if not (get_request()._user and self.can_user_add_cards()):
+        if not (get_request()._user and self.formdef.can_user_add_cards(get_request()._user)):
             raise AccessForbiddenError('cannot import cards')
 
         afterjob = bool(get_request().form.get('async') == 'on')
diff --git a/wcs/backoffice/data_management.py b/wcs/backoffice/data_management.py
index 906ddebc..78f90511 100644
--- a/wcs/backoffice/data_management.py
+++ b/wcs/backoffice/data_management.py
@@ -17,6 +17,7 @@
 import csv
 import datetime
 import io
+import json
 
 from quixote import get_publisher, get_request, get_response, redirect
 from quixote.html import htmltext
@@ -29,7 +30,8 @@ from ..qommon import N_, _, errors, template
 from ..qommon.afterjobs import AfterJob
 from ..qommon.backoffice.menu import html_top
 from ..qommon.form import FileWidget, Form
-from .management import FormBackOfficeStatusPage, FormFillPage, FormPage, ManagementDirectory
+from .management import FormBackOfficeStatusPage, FormPage, ManagementDirectory
+from .submission import FormFillPage
 
 
 class DataManagementDirectory(ManagementDirectory):
@@ -103,16 +105,8 @@ class CardPage(FormPage):
     def add(self):
         return CardFillPage(self.formdef.url_name)
 
-    def can_user_add_cards(self):
-        if not self.formdef.backoffice_submission_roles:
-            return False
-        for role in get_request().user.get_roles():
-            if role in self.formdef.backoffice_submission_roles:
-                return True
-        return False
-
     def listing_top_actions(self):
-        if not self.can_user_add_cards():
+        if not self.formdef.can_user_add_cards(get_request().user):
             return ''
         return htmltext('<span class="actions"><a href="./add/">%s</a></span>') % _('Add')
 
@@ -136,7 +130,7 @@ class CardPage(FormPage):
 
     def get_formdata_sidebar_actions(self, qs=''):
         r = super().get_formdata_sidebar_actions(qs=qs)
-        if self.can_user_add_cards():
+        if self.formdef.can_user_add_cards(get_request().user):
             r += htmltext('<li><a rel="popup" href="import-csv">%s</a></li>') % _(
                 'Import data from a CSV file'
             )
@@ -191,7 +185,7 @@ class CardPage(FormPage):
         return output.getvalue()
 
     def import_csv(self):
-        if not self.can_user_add_cards():
+        if not self.formdef.can_user_add_cards(get_request().user):
             raise errors.AccessForbiddenError()
         context = {'required_fields': []}
 
@@ -309,10 +303,29 @@ class CardFillPage(FormFillPage):
         if self.formdef.user_support == 'optional':
             self.has_user_support = True
 
-    def submitted(self, form, *args):
-        super().submitted(form, *args)
+    def redirect_after_submitted(self, form, filled):
+        if get_request().form.get('_popup'):
+            popup_response_data = json.dumps(
+                {
+                    'value': str(filled.id),
+                    'obj': str(filled.digest),
+                }
+            )
+            return template.QommonTemplateResponse(
+                templates=['wcs/backoffice/popup_response.html'],
+                context={'popup_response_data': popup_response_data},
+                is_django_native=True,
+            )
+        result = super().redirect_after_submitted(form, filled)
         if get_response().get_header('location').endswith('/backoffice/submission/'):
             return redirect('..')
+        return result
+
+    def create_form(self, *args, **kwargs):
+        form = super().create_form(*args, **kwargs)
+        if get_request().form.get('_popup'):
+            form.add_hidden('_popup', 1)
+        return form
 
 
 class CardBackOfficeStatusPage(FormBackOfficeStatusPage):
diff --git a/wcs/backoffice/submission.py b/wcs/backoffice/submission.py
index f7206ba9..41e71ae2 100644
--- a/wcs/backoffice/submission.py
+++ b/wcs/backoffice/submission.py
@@ -97,6 +97,7 @@ class FormFillPage(PublicFormFillPage):
     ]
 
     filling_templates = ['wcs/formdata_filling.html']
+    popup_filling_templates = ['wcs/formdata_popup_filling.html']
     validation_templates = ['wcs/formdata_validation.html']
     steps_templates = ['wcs/formdata_steps.html']
     has_channel_support = True
@@ -307,7 +308,7 @@ class FormFillPage(PublicFormFillPage):
         get_response().filter['sidebar'] = self.get_sidebar(data)
         r += htmltext('<div id="appbar">')
         r += htmltext('<h2>%s</h2>') % self.formdef.name
-        if not self.edit_mode:
+        if not self.edit_mode and not getattr(self, 'is_popup', False):
             draft_formdata_id = data.get('draft_formdata_id')
             if draft_formdata_id:
                 r += htmltext('<a rel="popup" href="remove/%s">%s</a>') % (
@@ -340,7 +341,9 @@ class FormFillPage(PublicFormFillPage):
         self.set_tracking_code(filled)
         get_session().remove_magictoken(get_request().form.get('magictoken'))
         self.clean_submission_context()
+        return self.redirect_after_submitted(form, filled)
 
+    def redirect_after_submitted(self, form, filled):
         url = filled.perform_workflow()
         if url:
             pass  # always redirect to an URL the workflow returned
diff --git a/wcs/carddef.py b/wcs/carddef.py
index df39ff6e..7a62b477 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -142,6 +142,14 @@ class CardDef(FormDef):
         base_url = get_publisher().get_frontoffice_url()
         return '%s/api/cards/%s/' % (base_url, self.url_name)
 
+    def can_user_add_cards(self, user):
+        if not self.backoffice_submission_roles:
+            return False
+        for role in user.get_roles():
+            if role in self.backoffice_submission_roles:
+                return True
+        return False
+
     def store(self, comment=None):
         self.roles = self.backoffice_submission_roles
         return super().store(comment=comment)
diff --git a/wcs/fields.py b/wcs/fields.py
index bd3bf320..abdbd686 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -1865,12 +1865,23 @@ class ItemField(WidgetField, MapOptionsMixin, ItemFieldMixin):
 
         return self.display_mode
 
+    def get_carddef(self):
+        from wcs.carddef import CardDef
+
+        try:
+            return CardDef.get_by_urlname(self.data_source['type'][8:])
+        except KeyError:
+            return None
+
     def perform_more_widget_changes(self, form, kwargs, edit=True):
         data_source = data_sources.get_object(self.data_source)
         display_mode = self.get_display_mode(data_source)
 
         if display_mode == 'autocomplete' and data_source and data_source.can_jsonp():
             self.url = kwargs['url'] = data_source.get_jsonp_url()
+            carddef = self.get_carddef()
+            if carddef and carddef.can_user_add_cards(get_request().user):
+                kwargs['add_related_url'] = carddef.get_backoffice_submission_url()
             self.widget_class = JsonpSingleSelectWidget
             return
 
@@ -1932,10 +1943,10 @@ class ItemField(WidgetField, MapOptionsMixin, ItemFieldMixin):
             and self.data_source.get('type', '').startswith('carddef:')
         ):
             return value
-        from wcs.carddef import CardDef
-
+        carddef = self.get_carddef()
+        if not carddef:
+            return value
         try:
-            carddef = CardDef.get_by_urlname(self.data_source['type'][8:])
             carddata = carddef.data_class().get(value_id)
         except KeyError:
             return value
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index d17053ea..84e973d3 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -554,6 +554,8 @@ class FormPage(Directory, FormTemplateMixin):
 
         self.formdef.set_live_condition_sources(form, displayed_fields)
 
+        self.is_popup = form._names.get('_popup')
+
         if had_prefill:
             # pass over prefilled fields that are used as live source of item
             # fields
@@ -576,10 +578,11 @@ class FormPage(Directory, FormTemplateMixin):
         if page:
             form.add_hidden('page_id', page.id)
 
-        cancel_label = _('Cancel')
-        if self.has_draft_support() and not (data and data.get('is_recalled_draft')):
-            cancel_label = _('Discard')
-        form.add_submit('cancel', cancel_label, css_class='cancel')
+        if not self.is_popup:
+            cancel_label = _('Cancel')
+            if self.has_draft_support() and not (data and data.get('is_recalled_draft')):
+                cancel_label = _('Discard')
+            form.add_submit('cancel', cancel_label, css_class='cancel')
 
         if self.has_draft_support():
             form.add_submit(
@@ -595,7 +598,7 @@ class FormPage(Directory, FormTemplateMixin):
         context = {
             'view': self,
             'page_no': lambda: self.get_current_page_no(page),
-            'form': form,
+            'form_obj': form,
             'formdef': LazyFormDef(self.formdef),
             'form_side': lambda: self.form_side(0, page, data=data, magictoken=magictoken),
             'steps': lambda: self.step(0, page),
@@ -604,6 +607,13 @@ class FormPage(Directory, FormTemplateMixin):
             context['tracking_code_box'] = lambda: self.tracking_code_box(data, magictoken)
         self.modify_filling_context(context, page, data)
 
+        if self.is_popup:
+            return template.QommonTemplateResponse(
+                templates=list(self.get_formdef_template_variants(self.popup_filling_templates)),
+                context=context,
+                is_django_native=True,
+            )
+
         return template.QommonTemplateResponse(
             templates=list(self.get_formdef_template_variants(self.filling_templates)), context=context
         )
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index c2a89274..042a511b 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -2289,9 +2289,10 @@ class RankedItemsWidget(CompositeWidget):
 class JsonpSingleSelectWidget(Widget):
     template_name = 'qommon/forms/widgets/select_jsonp.html'
 
-    def __init__(self, name, value=None, url=None, **kwargs):
+    def __init__(self, name, value=None, url=None, add_related_url=None, **kwargs):
         super().__init__(name, value=value, **kwargs)
         self.url = url
+        self.add_related_url = add_related_url
 
     def add_media(self):
         get_response().add_javascript(['select2.js'])
diff --git a/wcs/qommon/static/css/dc2/admin.scss b/wcs/qommon/static/css/dc2/admin.scss
index b7257b6f..e4401ece 100644
--- a/wcs/qommon/static/css/dc2/admin.scss
+++ b/wcs/qommon/static/css/dc2/admin.scss
@@ -684,6 +684,15 @@ div.DateTimeWidget .content .content {
 	display: inline-block;
 }
 
+div.JsonpSingleSelectWidget a.add-related {
+	border-bottom: none;
+	&::before {
+		content: "\f067"; /* plus */
+		font-family: FontAwesome;
+		padding-right: 1ex;
+	}
+}
+
 aside#sidebar input.inline-input {
 	margin-right: 1em;
 }
diff --git a/wcs/qommon/static/css/qommon.scss b/wcs/qommon/static/css/qommon.scss
index 68dbb6e0..56ca8d1d 100644
--- a/wcs/qommon/static/css/qommon.scss
+++ b/wcs/qommon/static/css/qommon.scss
@@ -1,3 +1,7 @@
+body.no-header #header {
+	display: none;
+}
+
 a {
 	color: #028;
 }
diff --git a/wcs/qommon/static/js/popup_response.js b/wcs/qommon/static/js/popup_response.js
new file mode 100644
index 00000000..b3f78649
--- /dev/null
+++ b/wcs/qommon/static/js/popup_response.js
@@ -0,0 +1,4 @@
+(function() {
+    var initData = JSON.parse(document.getElementById('popup-response-constants').dataset.popupResponse);
+    opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj);
+})();
diff --git a/wcs/qommon/static/js/qommon.admin.js b/wcs/qommon/static/js/qommon.admin.js
index 2315c82e..3fc235bb 100644
--- a/wcs/qommon/static/js/qommon.admin.js
+++ b/wcs/qommon/static/js/qommon.admin.js
@@ -311,4 +311,53 @@ $(function() {
       }
     });
     $('[type=radio][name=display_mode]:checked').trigger('change');
+
+    // IE doesn't accept periods or dashes in the window name, but the element IDs
+    // we use to generate popup window names may contain them, therefore we map them
+    // to allowed characters in a reversible way so that we can locate the correct
+    // element when the popup window is dismissed.
+    function id_to_windowname(text) {
+        text = text.replace(/\./g, '__dot__');
+        text = text.replace(/\-/g, '__dash__');
+        return text;
+    }
+
+    function windowname_to_id(text) {
+        text = text.replace(/__dot__/g, '.');
+        text = text.replace(/__dash__/g, '-');
+        return text;
+    }
+
+    function showAddRelatedObjectPopup(triggeringLink) {
+        var name = triggeringLink.id.replace(/^add_/, '');
+        name = id_to_windowname(name);
+        console.log(name)
+        var href = triggeringLink.href;
+        console.log(href)
+        var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
+        win.focus();
+        return false;
+    }
+
+    function dismissAddRelatedObjectPopup(win, newId, newRepr) {
+        var name = windowname_to_id(win.name);
+        var elem = document.getElementById(name);
+        if (elem) {
+            var elemName = elem.nodeName.toUpperCase();
+            if (elemName === 'SELECT') {
+                elem.options[elem.options.length] = new Option(newRepr, newId, true, true);
+            }
+            // Trigger a change event to update related links if required.
+            $(elem).trigger('change');
+        }
+        win.close();
+    }
+    window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup;
+
+    $('body').on('click', '.add-related', function(e) {
+        e.preventDefault();
+        if (this.href) {
+            showAddRelatedObjectPopup(this);
+        }
+    });
 });
diff --git a/wcs/qommon/templates/qommon/forms/widgets/select_jsonp.html b/wcs/qommon/templates/qommon/forms/widgets/select_jsonp.html
index 3913a0c6..3bf4d595 100644
--- a/wcs/qommon/templates/qommon/forms/widgets/select_jsonp.html
+++ b/wcs/qommon/templates/qommon/forms/widgets/select_jsonp.html
@@ -1,4 +1,5 @@
 {% extends "qommon/forms/widget.html" %}
+{% load i18n %}
 {% block widget-control %}
 <select id="form_{{widget.name}}" name="{{widget.name}}"
     data-select2-url="{{widget.get_select2_url}}"
@@ -6,4 +7,10 @@
     data-required="{% if widget.is_required %}true{% endif %}"
     data-initial-display-value="{{widget.get_display_value|default_if_none:''}}">
 </select>
+{% if widget.add_related_url %}
+<a class="add-related" id="add_form_{{ widget.name }}"
+    href="{{ widget.add_related_url }}?_popup=1"
+    title="{% blocktrans with card=widget.get_title %}Add another {{ card }}{% endblocktrans %}">
+</a>
+{% endif %}
 {% endblock %}
diff --git a/wcs/templates/wcs/backoffice/popup_response.html b/wcs/templates/wcs/backoffice/popup_response.html
new file mode 100644
index 00000000..114057a7
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/popup_response.html
@@ -0,0 +1,11 @@
+{% load i18n static %}<!DOCTYPE html>
+<html>
+  <head><title>{% trans 'Popup closing...' %}</title></head>
+  <body>
+    <script type="text/javascript"
+            id="popup-response-constants"
+            src="{% static "/js/popup_response.js" %}"
+            data-popup-response="{{ popup_response_data }}">
+    </script>
+  </body>
+</html>
diff --git a/wcs/templates/wcs/formdata_filling.html b/wcs/templates/wcs/formdata_filling.html
index 51a48c2b..83298596 100644
--- a/wcs/templates/wcs/formdata_filling.html
+++ b/wcs/templates/wcs/formdata_filling.html
@@ -44,7 +44,7 @@ This form is limited to one per user and the associated user has already one.
 {% endif %}
 {% endblock %}
 
-{{ form.render|safe }}
+{{ form_obj.render|safe }}
 {% endblock %}
 
 {% endblock %}
diff --git a/wcs/templates/wcs/formdata_popup_filling.html b/wcs/templates/wcs/formdata_popup_filling.html
new file mode 100644
index 00000000..e0b3ba55
--- /dev/null
+++ b/wcs/templates/wcs/formdata_popup_filling.html
@@ -0,0 +1,15 @@
+{% extends "wcs/backoffice.html" %}
+
+{% block bodyargs %}class="no-header"{% endblock %}
+{% block site-header %}{% endblock %}
+{% block user-links %}{% endblock %}
+{% block sidepage %}{% endblock %}
+
+{% block main-content %}
+{% block form-side %}
+{{ form_side|default:"" }}
+{{ publisher.get_request.session.display_message|safe }}
+{% endblock %}
+
+{{ form_obj.render|safe }}
+{% endblock %}
diff --git a/wcs/views.py b/wcs/views.py
index 8c9ed2c4..b965590c 100644
--- a/wcs/views.py
+++ b/wcs/views.py
@@ -31,20 +31,19 @@ class Backoffice(compat.TemplateWithFallbackView):
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
 
-        with compat.request(self.request):
-            get_request().response.filter = {'admin_ezt': True}
-            body = get_publisher().try_publish(get_request())
-            if isinstance(body, template.QommonTemplateResponse):
-                body.add_media()
-                if body.is_django_native:
-                    self.template_names = body.templates[0]
-                    context.update(body.context)
-                else:
-                    body = template.render(body.templates, body.context)
-                    self.template_names = None
-                get_publisher().session_manager.finish_successful_request()
-            self.quixote_response = get_request().response
-            context.update(template.get_decorate_vars(body, get_response(), generate_breadcrumb=True))
+        get_request().response.filter = {'admin_ezt': True}
+        body = get_publisher().try_publish(get_request())
+        if isinstance(body, template.QommonTemplateResponse):
+            body.add_media()
+            if body.is_django_native:
+                self.template_names = body.templates[0]
+                context.update(body.context)
+            else:
+                body = template.render(body.templates, body.context)
+                self.template_names = None
+            get_publisher().session_manager.finish_successful_request()
+        self.quixote_response = get_request().response
+        context.update(template.get_decorate_vars(body, get_response(), generate_breadcrumb=True))
 
         return context
 
-- 
2.30.2

