From 8b7092ea813d638c5beb5e6e12538cb8a1186402 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 8 Nov 2022 18:02:04 +0100
Subject: [PATCH] applications: sort elements by category in add popup (#63883)

---
 .../hobo/applications/add-element.html        | 15 ++++++-
 hobo/applications/views.py                    | 45 ++++++++++++++++++-
 tests/test_application.py                     | 44 ++++++++++++++++--
 tox.ini                                       |  1 +
 4 files changed, 98 insertions(+), 7 deletions(-)

diff --git a/hobo/applications/templates/hobo/applications/add-element.html b/hobo/applications/templates/hobo/applications/add-element.html
index 6ed027c..7a16074 100644
--- a/hobo/applications/templates/hobo/applications/add-element.html
+++ b/hobo/applications/templates/hobo/applications/add-element.html
@@ -9,8 +9,13 @@
   <form method="post">
     {% csrf_token %}
     <div class="application-elements">
-      {% for element in elements %}
-        <label data-slugged-text="{{ element.text|slugify }}"><input type="checkbox" name="elements" value="{{ element.id }}">{{ element.text }}</label>
+      {% for category in categories %}
+        <div>
+          <h4>{{ category.name }}</h4>
+          {% for element in category.elements %}
+            <label data-slugged-text="{{ element.text|slugify }}"><input type="checkbox" name="elements" value="{{ element.id }}">{{ element.text }}</label>
+          {% endfor %}
+        </div>
       {% endfor %}
     </div>
     <div style="text-align: right">
@@ -27,14 +32,20 @@
         $('.application-elements').css('height', $('.application-elements').height());
         $('.application-elements').css('width', $('.application-elements').width());
         if (!val) {
+          $('.application-elements div').show();
           $('.application-elements label').show();
         } else {
           $('.application-elements label').each(function(idx, elem) {
+            var container = $(elem).parent();
             var slugged_text = $(elem).attr('data-slugged-text');
             if (slugged_text.indexOf(val) > -1) {
               $(elem).show();
+              container.show();
             } else {
               $(elem).hide();
+              if (!$('label:visible', container).lenght) {
+                container.hide();
+              }
             }
           });
         }
diff --git a/hobo/applications/views.py b/hobo/applications/views.py
index 83bc738..6f74751 100644
--- a/hobo/applications/views.py
+++ b/hobo/applications/views.py
@@ -14,9 +14,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import dataclasses
 import io
 import json
+import re
 import tarfile
+import unicodedata
 import urllib.parse
 
 from django.conf import settings
@@ -25,6 +28,7 @@ from django.db.models import Prefetch
 from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404
 from django.urls import reverse, reverse_lazy
+from django.utils.encoding import force_text
 from django.utils.translation import ugettext_lazy as _
 from django.views.generic import DetailView, FormView, ListView, TemplateView
 from django.views.generic.edit import CreateView, DeleteView, UpdateView
@@ -146,6 +150,21 @@ class MetadataView(UpdateView):
 metadata = MetadataView.as_view()
 
 
+@dataclasses.dataclass
+class Category:
+    name: str
+    elements: list
+
+
+def simplify(s, space='-'):
+    if s is None:
+        return ''
+    s = force_text(unicodedata.normalize('NFKD', s).encode('ascii', 'ignore'))
+    s = re.sub(r'[^\w\s\'%s]' % space, '', s).strip().lower()
+    s = re.sub(r'[\s\'%s]+' % space, space, s)
+    return s
+
+
 class AppAddElementView(TemplateView):
     template_name = 'hobo/applications/add-element.html'
 
@@ -157,14 +176,36 @@ class AppAddElementView(TemplateView):
                 context['type'] = object_type
                 url = object_type['urls']['list']
                 response = requests.get(url)
-                context['elements'] = response.json()['data']
+                elements = response.json()['data']
+                category_names = {el.get('category') or '' for el in elements}
+                categories = [
+                    Category(
+                        name=c,
+                        elements=sorted(
+                            [el for el in elements if el.get('category') == c],
+                            key=lambda a: simplify(a['text']),
+                        ),
+                    )
+                    for c in sorted(list(category_names))
+                    if c
+                ]
+                categories.append(
+                    Category(
+                        name=_('Uncategorized'),
+                        elements=sorted(
+                            [el for el in elements if not el.get('category')],
+                            key=lambda a: simplify(a['text']),
+                        ),
+                    )
+                )
+                context['categories'] = categories
                 break
         return context
 
     def post(self, request, app_slug, type):
         context = self.get_context_data()
         app = context['app']
-        element_infos = {x['id']: x for x in context['elements']}
+        element_infos = {x['id']: x for c in context['categories'] for x in c.elements}
         for element_slug in request.POST.getlist('elements'):
             element, created = Element.objects.get_or_create(
                 type=type, slug=element_slug, defaults={'name': element_infos[element_slug]['text']}
diff --git a/tests/test_application.py b/tests/test_application.py
index f85a624..f4f2a0c 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -7,6 +7,7 @@ import tarfile
 import httmock
 import pytest
 from httmock import HTTMock
+from pyquery import PyQuery
 from test_manager import login
 from webtest import Upload
 
@@ -102,6 +103,36 @@ WCS_AVAILABLE_FORMS = {
                 "dependencies": "https://wcs.example.invalid/api/export-import/forms/test2-form/dependencies/",
             },
         },
+        {
+            "id": "foo2-form",
+            "text": "Foo2 Test Form",
+            "type": "forms",
+            "category": "Foo",
+            "urls": {
+                "export": "https://wcs.example.invalid/api/export-import/forms/foo2-form/",
+                "dependencies": "https://wcs.example.invalid/api/export-import/forms/foo2-form/dependencies/",
+            },
+        },
+        {
+            "id": "foo-form",
+            "text": "Foo Test Form",
+            "type": "forms",
+            "category": "Foo",
+            "urls": {
+                "export": "https://wcs.example.invalid/api/export-import/forms/foo-form/",
+                "dependencies": "https://wcs.example.invalid/api/export-import/forms/foo-form/dependencies/",
+            },
+        },
+        {
+            "id": "bar-form",
+            "text": "Bar Test Form",
+            "type": "forms",
+            "category": "Bar",
+            "urls": {
+                "export": "https://wcs.example.invalid/api/export-import/forms/bar-form/",
+                "dependencies": "https://wcs.example.invalid/api/export-import/forms/bar-form/dependencies/",
+            },
+        },
     ]
 }
 
@@ -203,9 +234,16 @@ def test_create_application(app, admin_user, settings, analyze):
         # add forms
         assert '/add/forms/' in resp
         resp = resp.click('Forms')
-        assert resp.form.fields['elements'][0]._value == 'test-form'
-        assert resp.form.fields['elements'][1]._value == 'test2-form'
-        resp.form.fields['elements'][0].checked = True
+        assert len(resp.pyquery('.application-elements div')) == 3
+        assert resp.pyquery('.application-elements div:nth-child(1) h4').text() == 'Bar'
+        assert PyQuery(resp.pyquery('.application-elements div:nth-child(1) input')[0]).val() == 'bar-form'
+        assert resp.pyquery('.application-elements div:nth-child(2) h4').text() == 'Foo'
+        assert PyQuery(resp.pyquery('.application-elements div:nth-child(2) input')[0]).val() == 'foo-form'
+        assert PyQuery(resp.pyquery('.application-elements div:nth-child(2) input')[1]).val() == 'foo2-form'
+        assert resp.pyquery('.application-elements div:nth-child(3) h4').text() == 'Uncategorized'
+        assert PyQuery(resp.pyquery('.application-elements div:nth-child(3) input')[0]).val() == 'test2-form'
+        assert PyQuery(resp.pyquery('.application-elements div:nth-child(3) input')[1]).val() == 'test-form'
+        resp.form.fields['elements'][4].checked = True
         resp = resp.form.submit().follow()
         assert application.elements.count() == 1
         element = application.elements.all()[0]
diff --git a/tox.ini b/tox.ini
index f3061a3..124f45c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -42,6 +42,7 @@ deps:
 	pytest-cov
 	pytest-django
 	pytest-mock
+	pyquery
 	coverage
 	cssselect
 	WebTest
-- 
2.35.1

