From 1c87aa12d5e5631013bd0a912fe98bc7504542c5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Wed, 23 Mar 2022 22:01:25 +0100
Subject: [PATCH] manager: use sidetabs to navigate between cell options
 (#62965)

---
 combo/apps/maps/models.py                     |  16 ++-
 combo/data/models.py                          |  59 +++++++++-
 combo/manager/static/css/combo.manager.scss   |  90 ++++++++++------
 combo/manager/static/js/combo.manager.js      |  85 ++++++++-------
 .../templates/combo/cell_appearance.html      |   6 ++
 .../templates/combo/cell_visibility.html      |  28 +----
 .../combo/manager_edit_cell_block.html        |  53 +++++----
 combo/manager/templates/combo/page_view.html  |  28 ++---
 combo/manager/templatetags/cells.py           |  15 ++-
 combo/manager/urls.py                         |  15 ---
 combo/manager/views.py                        | 102 ++++++++++--------
 combo/settings.py                             |   1 +
 combo/utils/forms.py                          |   2 +-
 tests/test_dataviz.py                         |  64 +++++------
 tests/test_family.py                          |   5 +-
 tests/test_gallery_cell.py                    |   8 +-
 tests/test_kb.py                              |   5 +-
 tests/test_manager.py                         |  61 +++++------
 tests/test_maps_manager.py                    |   4 +-
 tests/test_wcs.py                             |  25 +++--
 tests/utils.py                                |  16 +++
 21 files changed, 398 insertions(+), 290 deletions(-)
 create mode 100644 combo/manager/templates/combo/cell_appearance.html
 create mode 100644 tests/utils.py

diff --git a/combo/apps/maps/models.py b/combo/apps/maps/models.py
index a7893f4d..42689d07 100644
--- a/combo/apps/maps/models.py
+++ b/combo/apps/maps/models.py
@@ -403,14 +403,24 @@ class Map(CellBase):
         fields = (
             'title',
             'initial_state',
-            'initial_zoom',
-            'min_zoom',
-            'max_zoom',
             'group_markers',
             'marker_behaviour_onclick',
         )
         return forms.models.modelform_factory(self.__class__, fields=fields)
 
+    def get_manager_tabs(self):
+        zoom_fields = ['initial_zoom', 'min_zoom', 'max_zoom']
+        tabs = super().get_manager_tabs()
+        tabs.insert(
+            1,
+            {
+                'slug': 'zoom',
+                'name': _('Zoom'),
+                'form': forms.models.modelform_factory(self.__class__, fields=zoom_fields),
+            },
+        )
+        return tabs
+
     @classmethod
     def is_enabled(cls):
         return MapLayer.objects.exists()
diff --git a/combo/data/models.py b/combo/data/models.py
index 5010453a..5f9eda2d 100644
--- a/combo/data/models.py
+++ b/combo/data/models.py
@@ -781,6 +781,7 @@ class CellMeta(MediaDefiningClass, ModelBase):
 
 @python_2_unicode_compatible
 class CellBase(models.Model, metaclass=CellMeta):
+    # noqa pylint: disable=too-many-public-methods
 
     page = models.ForeignKey(Page, on_delete=models.CASCADE)
     placeholder = models.CharField(max_length=20)
@@ -803,6 +804,8 @@ class CellBase(models.Model, metaclass=CellMeta):
     default_form_class = None
     manager_form_factory_kwargs = {}
     manager_form_template = 'combo/cell_form.html'
+    manager_visibility_template = 'combo/cell_visibility.html'
+    manager_appearance_template = 'combo/cell_appearance.html'
     children_placeholder_prefix = None
 
     visible = True
@@ -841,6 +844,21 @@ class CellBase(models.Model, metaclass=CellMeta):
     def get_additional_label(self):
         return ''
 
+    def get_manager_visibility_css_class(self):
+        if self.public:
+            return 'visibility-all' if not self.restricted_to_unlogged else ''
+        elif self.restricted_to_unlogged:
+            return 'visibility-off'
+        return ''
+
+    def get_manager_visibility_content(self):
+        if self.public and not self.restricted_to_unlogged:
+            return ''
+        group_names = ', '.join([x.name for x in self.groups.all()])
+        if group_names:
+            return group_names
+        return _('unlogged users') if self.restricted_to_unlogged else _('logged users')
+
     @property
     def legacy_class_name(self):
         # legacy class name used in some themes
@@ -1063,6 +1081,38 @@ class CellBase(models.Model, metaclass=CellMeta):
     def get_label(self):
         return self.get_verbose_name()
 
+    def get_manager_tabs(self):
+        from combo.manager.forms import CellVisibilityForm
+
+        tabs = []
+        form_class = self.get_default_form_class()
+        if form_class:
+            tabs.append(
+                {
+                    'slug': 'general',
+                    'name': _('General'),
+                    'template': self.manager_form_template,
+                    'form': form_class,
+                }
+            )
+        tabs.append(
+            {
+                'slug': 'visibility',
+                'name': _('Visibility'),
+                'template': self.manager_visibility_template,
+                'form': CellVisibilityForm,
+            }
+        )
+        tabs.append(
+            {
+                'slug': 'appearance',
+                'name': _('Appearance'),
+                'template': self.manager_appearance_template,
+                'form': self.get_appearance_form_class(),
+            }
+        )
+        return tabs
+
     def get_default_form_fields(self):
         return [
             x.name
@@ -1083,19 +1133,20 @@ class CellBase(models.Model, metaclass=CellMeta):
             )
         ]
 
-    def get_default_form_class(self):
+    def get_default_form_class(self, fields=None):
         if self.default_form_class:
             return self.default_form_class
 
-        fields = self.get_default_form_fields()
         if not fields:
-            return None
+            fields = self.get_default_form_fields()
+            if not fields:
+                return None
 
         return model_forms.modelform_factory(
             self.__class__, fields=fields, **self.manager_form_factory_kwargs
         )
 
-    def get_options_form_class(self):
+    def get_appearance_form_class(self):
         fields = ['slug', 'extra_css_class']
         widgets = None
         extra_templates = settings.COMBO_CELL_TEMPLATES.get(self.get_cell_type_str())
diff --git a/combo/manager/static/css/combo.manager.scss b/combo/manager/static/css/combo.manager.scss
index 57e32b70..140bf3fc 100644
--- a/combo/manager/static/css/combo.manager.scss
+++ b/combo/manager/static/css/combo.manager.scss
@@ -1,3 +1,16 @@
+// Only display content to screen readers
+@mixin sr-only {
+	position: absolute !important;
+	width: 1px !important;
+	height: 1px !important;
+	padding: 0 !important;
+	margin: -1px !important;
+	overflow: hidden !important;
+	clip: rect(0, 0, 0, 0) !important;
+	white-space: nowrap !important;
+	border: 0 !important;
+}
+
 body.no-header #header {
 	display: none;
 }
@@ -48,31 +61,23 @@ div.cell-list > div {
 	margin: 1ex 0;
 	box-shadow: rgba(0, 0, 0, 0.04) 0px 1px 1px 0px;
 	position: relative;
+	> div {
+		display: none;
+		overflow: hidden;
+	}
+	&.toggled > div {
+		display: block;
+	}
 }
 
 div.cell-list > div > div form {
 	padding: 1ex;
 }
 
-div.cell-list > div > div {
-	display: none;
-	transition: max-height linear 0.2s;
-	overflow: hidden;
-}
-
 .cell-form textarea {
 	width: 100%;
 }
 
-div.cell-list > div.untoggled > div {
-	display: block;
-	max-height: 0;
-}
-
-div.cell-list > div.toggled > div {
-	display: block;
-}
-
 div.cell > h3 {
 	background: #fafafa;
 	margin: 0;
@@ -132,15 +137,19 @@ div.page span.visibility-summary {
 
 div.cell h3 span.visibility-summary::before,
 div.page span.visibility-summary::before {
-	content: "\f06e"; /* fa-eye */
+	content: "\f06e "; /* fa-eye */
 	font-family: FontAwesome;
 }
 
 div.cell h3 span.visibility-summary.visibility-off::before {
-	content: "\f070"; /* fa-eye-slash */
+	content: "\f070 "; /* fa-eye-slash */
 	font-family: FontAwesome;
 }
 
+div.cell h3 span.visibility-summary.visibility-all::before {
+	content: none;
+}
+
 div.cell h3 span.visibility-summary {
 	max-width: 30%;
 }
@@ -167,20 +176,6 @@ div.cell-list div.toggled h3:after {
 	content: "\f106"; /* angle-up */
 }
 
-div.cell-list button.save {
-	position: relative;
-	right: 2ex;
-	bottom: 1ex;
-	float: right;
-}
-
-
-div.cell div.cell-buttons {
-	clear: both;
-	margin-top: 2em;
-	margin-bottom: 1ex;
-}
-
 div.cell-list .empty-cell {
 	list-style: none;
 	margin: 0;
@@ -682,3 +677,36 @@ form .choices {
 .search-engine-add {
 	margin: 2em 0;
 }
+
+.cell-properties {
+	.django-ckeditor-widget {
+		display: block !important;
+	}
+	&--buttons {
+		margin-top: 1em;
+		display: flex;
+		justify-content: space-between;
+	}
+	.duplicate-button {
+		&::before {
+			font-family: FontAwesome;
+			content: "\f24d";  /* clone */
+		}
+		span {
+			@include sr-only();
+		}
+	}
+	.delete-button {
+		&::before {
+			font-family: FontAwesome;
+			content: "\f014";  /* trash */
+		}
+		span {
+			@include sr-only();
+		}
+	}
+}
+
+.cell.map .pk-tabs-container--content-panels .buttons {
+	margin: 1em 0;
+}
diff --git a/combo/manager/static/js/combo.manager.js b/combo/manager/static/js/combo.manager.js
index 344ea8c1..8a74af32 100644
--- a/combo/manager/static/js/combo.manager.js
+++ b/combo/manager/static/js/combo.manager.js
@@ -190,16 +190,6 @@ function init_pages_list(toggle_state)
   });
 }
 
-function compute_max_height($cell) {
-  var cell_id = $cell.attr('id');
-  $('style#for-' + cell_id).remove();
-  var h = $cell.find('h3 + div').height() + 40;
-  h += $cell.find('.multisort').length * 250;
-  h += $cell.find('.django-ckeditor-widget').length * 200;
-  var style = '<style id="for-' + cell_id + '">div#' + cell_id + '.toggled h3 + div { max-height: '+h+'px; }</style>';
-  $(style).appendTo('head');
-}
-
 $(function() {
   $('div.cell-list h3').on('click', function() {
     $(this).parent().toggleClass('toggled').toggleClass('untoggled');
@@ -270,6 +260,7 @@ $(function() {
   $('div.cell button.save').on('click', function(event) {
      var $button = $(this);
      var $form = $(this).closest('form');
+     var $cell = $(this).closest('div.cell');
      var button_label = $button.text();
      for (instance in CKEDITOR.instances) {
         CKEDITOR.instances[instance].updateElement();
@@ -281,27 +272,54 @@ $(function() {
          beforeSend: function() { $button.attr('disabled', 'disabled'); },
          success: function(data) {
             $button.attr('disabled', null);
-            if (data.indexOf('ckeditortype') == -1) {
-              /* update form with new content, unless it has a ckeditor, as
-               * this causes an unpleasant flickering */
-              $button.parents('form').find('div.cell-form').html(data);
-              compute_max_height($form.parents('div.cell'));
-            }
-            $form.parents('div.cell').trigger('combo:cellform-reloaded');
-            if (data.indexOf('class="errorlist"') == -1) {
-              $.getJSON($form.data('label-url'),
-                function(data) {
-                    $form.parents('div.cell').find('.additional-label i').text(data['label']);
-                    if (data['invalid_reason']) {
-                      if (! $form.parents('div.cell').find('.invalid').length) {
-                        $('<span class="invalid"></span>').insertAfter($form.parents('div.cell').find('.additional-label'));
-                      }
-                      $form.parents('div.cell').find('.invalid').text(data['invalid_reason']);
-                    } else {
-                      $form.parents('div.cell').find('.invalid').remove();
-                    }
+            for (const tab_slug in data.tabs) {
+              var $tab_content = $form.find('[data-tab-slug="' + tab_slug + '"]');
+              if (data.tabs[tab_slug].indexOf('ckeditortype') == -1) {
+                /* update form with new content, unless it has a ckeditor, as
+                 * this causes an unpleasant flickering */
+                $tab_content.html(data.tabs[tab_slug]);
+              } else {
+                for (const error in data.errorlist[tab_slug]) {
+                  var $widget_p = $('[name="' + data.prefix + '-' + error).closest('p');
+                  var $widget_ul = $('<ul class="errorlist"></ul>');
+                  for (idx in data.errorlist[form_slug][error]) {
+                    $widget_ul.append($('<li>', {text: data.errorlist[form_slug][error][idx]}));
+                  }
+                  $widget_p.before($widget_ul);
                 }
-              );
+              }
+            }
+            // update title labels
+            $cell.find('.visibility-summary').removeClass(
+              ).addClass('visibility-summary').addClass(data.visibility_css_class
+              ).text(data.visibility_content);
+            function wrap(label, sign) {
+              if (! label) return '';
+              if (sign == '(') return '(' + label + ')';
+              if (sign == '[') return '[' + label + ']';
+              return label;
+            }
+            $cell.find('h3 .cell-slug').text(wrap(data.slug, '['));
+            $cell.find('h3 .cell-template-label').text(wrap(data.template_label, '('));
+            $cell.find('h3 .extra-css-class').text(wrap(data.extra_css_class, '['));
+            $cell.find('h3 .additional-label i').text(wrap(data.additional_label));
+            if (data['invalid_reason']) {
+              if (! $cell.find('.invalid').length) {
+                $('<span class="invalid"></span>').insertAfter($cell.find('.additional-label'));
+              }
+              $cell.find('.invalid').text(data['invalid_reason']);
+            } else {
+              $cell.find('.invalid').remove();
+            }
+
+            // select first panel with errors (if any)
+            const $current_tab = $form.find('[role=tabpanel]:not([hidden])');
+            if ($current_tab.find('.errorlist').length == 0) {
+              const $panel_with_error = $form.find('[role=tabpanel] .errorlist').first().parents('[role=tabpanel]').first();
+              if ($panel_with_error.length) {
+                const $tab_button = $('[role=tab][aria-controls="' + $panel_with_error.attr('id') + '"]');
+                window.selectTab($tab_button[0]);
+              }
             }
          }
      });
@@ -334,7 +352,6 @@ $(function() {
 
   $('.cell.tipi-payment-form-cell select').on('change', function() {
       handle_tipi_form($(this));
-      compute_max_height($(this).parents('div.cell'));
   });
   $('.cell.tipi-payment-form-cell select').trigger('change');
 
@@ -348,7 +365,6 @@ $(function() {
 
   $('div.cell').each(function(i, x) {
     const $cell = $(this);
-    compute_max_height($cell);
     if (window.location.hash == '#' + $cell.attr('id')) {
       $cell.addClass('toggled');
     } else {
@@ -720,7 +736,6 @@ Card_cell_custom.prototype = {
 
     this.grid_cell__add(schema_cell);
     $(this.cell).trigger("custom_cell:change");
-    compute_max_height($(this.cell));
   },
   grid_cell__init: function() {
     if (!this.gridSchema_existing) return;
@@ -729,10 +744,6 @@ Card_cell_custom.prototype = {
     this.gridSchema.cells.forEach(function(el){
       _self.grid_cell__add(el);
     });
-
-    $(this.cell).on("cell:open", function(){
-      compute_max_height($(this));
-    })
   },
   // Init methods
   on: function() {
diff --git a/combo/manager/templates/combo/cell_appearance.html b/combo/manager/templates/combo/cell_appearance.html
new file mode 100644
index 00000000..994bcc56
--- /dev/null
+++ b/combo/manager/templates/combo/cell_appearance.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+{{ appearance_form.as_p }}
+{% if cell.can_have_assets %}
+<p><a rel="popup" data-selector="div#assets-listing" href="{% url 'combo-manager-slot-assets' cell_reference=cell.get_reference %}"
+     >{% trans 'Assets' %}</a></p>
+{% endif %}
diff --git a/combo/manager/templates/combo/cell_visibility.html b/combo/manager/templates/combo/cell_visibility.html
index 2388180f..a94b28ff 100644
--- a/combo/manager/templates/combo/cell_visibility.html
+++ b/combo/manager/templates/combo/cell_visibility.html
@@ -1,26 +1,8 @@
-{% extends "combo/manager_base.html" %}
-{% load i18n %}
-
-{% block appbar %}
-<h2>{% trans 'Cell Visibility' %}</h2>
-{% endblock %}
-
-{% block content %}
-
-<form method="post" enctype="multipart/form-data" id="visibility-form">
-  {% csrf_token %}
-  {{ form.as_p }}
-  <div class="buttons">
-    <button class="submit-button">{% trans "Save" %}</button>
-    {% if object.id %}
-    <a class="cancel" href="{{ object.get_absolute_url }}">{% trans 'Cancel' %}</a>
-    {% else %}
-    <a class="cancel" href="{% url 'combo-manager-homepage' %}">{% trans 'Cancel' %}</a>
-    {% endif %}
-  </div>
+<div id="visibility-form-{{cell.get_reference}}">
+  {{ visibility_form.as_p }}
   <script>
   $(function() {
-    var $form = $('#visibility-form');
+    var $form = $('#visibility-form-{{cell.get_reference}}');
     $form.find('select').first().on('change', function() {
       var val = $(this).val();
       var $groups = $form.find('select').last().parent('p');
@@ -33,6 +15,4 @@
     $form.find('select').first().trigger('change');
   });
   </script>
-</form>
-
-{% endblock %}
+</div>
diff --git a/combo/manager/templates/combo/manager_edit_cell_block.html b/combo/manager/templates/combo/manager_edit_cell_block.html
index 50ecbb35..395b421e 100644
--- a/combo/manager/templates/combo/manager_edit_cell_block.html
+++ b/combo/manager/templates/combo/manager_edit_cell_block.html
@@ -1,28 +1,39 @@
 {% load i18n %}
 {% block cell-form %}
-<form action="{{ url }}" method="post" data-label-url="{% url 'combo-manager-page-get-additional-label' page_pk=page.id cell_reference=cell.get_reference %}">
-{% csrf_token %}
-{% if form %}
-  <div class="cell-form">
-    {% block cell-form-content %}
-      {% include cell.manager_form_template %}
-    {% endblock %}
+<div class="cell-properties pk-tabs-container">
+  <div class="pk-tabs-container--tablist" role="tablist" aria-label="{% trans "Cell Properties" %}">
+   {% for tab in manager_tabs %}
+   <button role="tab"
+      aria-selected="{{ forloop.first|yesno:"true,false" }}"
+      aria-controls="panel-{{cell.get_reference}}-{{ forloop.counter }}"
+      id="tab-{{cell.get_reference}}-{{ forloop.counter }}"
+      tabindex="{{ forloop.first|yesno:"0,-1" }}">{{ tab.name }}</button>
+   {% endfor %}
   </div>
-{% else %}
-  <p>{% trans "There are no options for this cell." %}</p>
-{% endif %}
-{% endblock %}
-<div class="cell-buttons">
+
+<form class="pk-tabs-container--panels" action="{{ url }}" method="post">
+{% csrf_token %}
+   {% for tab in manager_tabs %}
+   <div id="panel-{{cell.get_reference}}-{{forloop.counter}}"
+        role="tabpanel" tabindex="0" {% if not forloop.first %}hidden{% endif %}
+        data-tab-slug="{{ tab.slug }}"
+        aria-labelledby="tab-{{cell.get_reference}}-{{ forloop.counter }}">
+     {% if tab.template %}{% include tab.template %}{% else %}{{ tab.form_instance.as_p }}{% endif %}
+   </div>
+   {% endfor %}
+<div class="cell-properties--buttons">
 {% block cell-buttons %}
-<a rel="popup" href="{% url 'combo-manager-page-delete-cell' page_pk=page.id cell_reference=cell.get_reference %}">{% trans 'Delete' %}</a> |
-<a rel="popup" href="{% url 'combo-manager-page-visibility-cell' page_pk=page.id cell_reference=cell.get_reference %}">{% trans 'Visibility' %}</a> |
-<a rel="popup" href="{% url 'combo-manager-page-options-cell' page_pk=page.id cell_reference=cell.get_reference %}">{% trans 'Options' %}</a> |
-{% if cell.can_have_assets %}<a rel="popup" data-selector="div#assets-listing" href="{% url 'combo-manager-slot-assets' cell_reference=cell.get_reference %}">{% trans 'Assets' %}</a> |{% endif %}
-<a rel="popup" href="{% url 'combo-manager-page-duplicate-cell' page_pk=page.id cell_reference=cell.get_reference %}">{% trans 'Duplicate' %}</a> |
-<a class="close-button" href="#">{% trans 'Close' %}</a>
-{% if form %}
-<button class="save submit-button">{% trans 'Save' %}</button>
-{% endif %}
+<button class="submit-button save">{% trans 'Save' %}</button>
+<span>
+<a class="pk-button duplicate-button" rel="popup" title="{% trans 'Duplicate' %}"
+   href="{% url 'combo-manager-page-duplicate-cell' page_pk=page.id cell_reference=cell.get_reference %}"
+   ><span>{% trans 'Duplicate' %}</span></a>
+<a class="pk-button delete-button" rel="popup" title="{% trans 'Delete' %}"
+   href="{% url 'combo-manager-page-delete-cell' page_pk=page.id cell_reference=cell.get_reference %}"
+   ><span>{% trans 'Delete' %}</span></a>
+</span>
 {% endblock %}
 </div>
 </form>
+</div>
+{% endblock %}
diff --git a/combo/manager/templates/combo/page_view.html b/combo/manager/templates/combo/page_view.html
index f985f6ff..ac789c41 100644
--- a/combo/manager/templates/combo/page_view.html
+++ b/combo/manager/templates/combo/page_view.html
@@ -161,12 +161,11 @@
       <div id="cell-{{cell.get_reference}}" class="cell {{cell.class_name}}" data-cell-reference="{{ cell.get_reference }}">
         <h3><span class="handle">⣿</span>
                 <span class="group1">
-                        {{ cell.get_label }}
-                        {% if cell.template_name %} ({{cell.get_template_label}}){% endif %}
-                        {% if cell.slug %} [{{cell.slug}}] {% endif %}
-                  {% if cell.extra_css_class %}
-                  <span class="extra-css-class">[{{ cell.extra_css_class }}]</span>
-                  {% endif %}
+                   {{ cell.get_label }}
+                   <span class="cell-template-label"
+                     >{% if cell.template_name %}({{cell.get_template_label}}){% endif %}</span>
+                   <span class="cell-slug">{% if cell.slug %}[{{cell.slug}}]{% endif %}</span>
+                   <span class="extra-css-class">{% if cell.extra_css_class %}[{{ cell.extra_css_class }}]{% endif %}</span>
                   <span class="additional-label"><i>{{cell.get_additional_label|default_if_none:""}}</i></span>
                   {% if cell.get_invalid_reason %}
                   <span class="invalid">{{ cell.get_invalid_reason }}{% if cell.class_name != 'link-list-cell' %} -
@@ -178,20 +177,9 @@
                     {% endif %}</span>
                   {% endif %}
                 </span>
-                {% if not cell.public %}
-                  <span class="visibility-summary
-                    {% if cell.restricted_to_unlogged %}visibility-off{% endif %}" title="{% trans 'Restricted visibility' %}">
-                  {% if cell.groups.all|length %}
-                  {% for group in cell.groups.all %}{{group.name}}{% if not forloop.last %}, {% endif %}{% endfor %}
-                  {% else %}
-                  {% trans "logged users" %}
-                  {% endif %}
-                  </span>
-                {% elif cell.restricted_to_unlogged %}
-                  <span class="visibility-summary" title="{% trans 'Restricted visibility' %}">
-                  {% trans "unlogged users" %}
-                  </span>
-                {% endif %}
+                <span class="visibility-summary {{ cell.get_manager_visibility_css_class }}"
+                      title="{% trans 'Restricted visibility' %}"
+                        >{{ cell.get_manager_visibility_content }}</span>
         </h3>
         <div>{% cell_form cell %}</div>
       </div>
diff --git a/combo/manager/templatetags/cells.py b/combo/manager/templatetags/cells.py
index 154ed5d5..ee7f71a4 100644
--- a/combo/manager/templatetags/cells.py
+++ b/combo/manager/templatetags/cells.py
@@ -26,11 +26,16 @@ def cell_form(context, cell):
         'combo-manager-page-edit-cell',
         kwargs={'page_pk': cell.page_id, 'cell_reference': cell.get_reference()},
     )
-    form_class = cell.get_default_form_class()
-    if cell.is_enabled() and form_class:
-        context['form'] = form_class(instance=cell, prefix='c%s' % cell.get_reference())
-    else:
-        context['form'] = None
+    context['manager_tabs'] = cell.get_manager_tabs()
+    for tab in context['manager_tabs']:
+        if tab['slug'] == 'general':
+            form_name = 'form'
+            if not cell.is_enabled():
+                continue
+        else:
+            form_name = '%s_form' % tab['slug']
+        tab['form_instance'] = tab['form'](initial={}, instance=cell, prefix='c%s' % cell.get_reference())
+        context[form_name] = tab['form_instance']
     context['cell'] = cell
     cell_form_template = template.loader.get_template('combo/manager_edit_cell_block.html')
     with context.push():
diff --git a/combo/manager/urls.py b/combo/manager/urls.py
index 9943c8f3..d438d057 100644
--- a/combo/manager/urls.py
+++ b/combo/manager/urls.py
@@ -116,21 +116,6 @@ urlpatterns = [
         views.page_duplicate_cell,
         name='combo-manager-page-duplicate-cell',
     ),
-    url(
-        r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/options$',
-        views.page_cell_options,
-        name='combo-manager-page-options-cell',
-    ),
-    url(
-        r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/visibility$',
-        views.page_cell_visibility,
-        name='combo-manager-page-visibility-cell',
-    ),
-    url(
-        r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/label$',
-        views.page_get_additional_label,
-        name='combo-manager-page-get-additional-label',
-    ),
     url(
         r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/add-link/(?P<link_code>[\w-]+)$',
         views.page_list_cell_add_link,
diff --git a/combo/manager/views.py b/combo/manager/views.py
index da2cb3a8..7c897f9b 100644
--- a/combo/manager/views.py
+++ b/combo/manager/views.py
@@ -20,12 +20,14 @@ import json
 import tarfile
 from operator import attrgetter, itemgetter
 
+from django import template
 from django.conf import settings
 from django.contrib import messages
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
-from django.http import Http404, HttpResponse, HttpResponseRedirect
+from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
 from django.shortcuts import get_object_or_404, redirect, render
+from django.template import engines
 from django.urls import reverse, reverse_lazy
 from django.utils.encoding import force_bytes, force_text
 from django.utils.formats import date_format
@@ -61,7 +63,6 @@ from combo.utils.misc import is_portal_agent
 
 from .forms import (
     CellDuplicateForm,
-    CellVisibilityForm,
     PageAddForm,
     PageDuplicateForm,
     PageEditDescriptionForm,
@@ -664,6 +665,8 @@ page_add_cell = PageAddCellView.as_view()
 
 
 class PageEditCellView(ManagedPageMixin, UpdateView):
+    http_method_names = ['post']
+
     def get_template_names(self):
         return [self.template_name or self.object.manager_form_template]
 
@@ -685,15 +688,61 @@ class PageEditCellView(ManagedPageMixin, UpdateView):
     def get_prefix(self):
         return 'c%s' % self.kwargs.get('cell_reference')
 
-    def get_form_class(self):
-        return self.object.get_default_form_class()
+    def post(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        response = {'errorlist': {}, 'tabs': {}, 'prefix': self.get_prefix()}
+        context = {}
+        context['page'] = self.object.page
+        context['cell'] = self.object
+        context.update(self.object.get_extra_manager_context())
+        tab_error_forms = {}
 
-    def get_success_url(self):
-        return (
-            reverse('combo-manager-page-view', kwargs={'pk': self.kwargs.get('page_pk')})
-            + '#cell-'
-            + self.object.get_reference()
-        )
+        class Rollback(Exception):
+            pass
+
+        try:
+            with transaction.atomic():
+                for tab in self.object.get_manager_tabs():
+                    form = tab['form'](**self.get_form_kwargs())
+                    if form.is_valid():
+                        self.object = form.save()
+                    else:
+                        tab_error_forms[tab['slug']] = form
+                        response['errorlist'][tab['slug']] = form.errors
+                        self.object.refresh_from_db()
+                if response['errorlist']:
+                    raise Rollback()
+        except Rollback:
+            pass
+
+        for tab in self.object.get_manager_tabs():
+            # if current form had no errors, create it anew
+            # so it can get new dynamic fields
+            form = tab_error_forms.get(tab['slug']) or tab['form'](**self.get_form_kwargs())
+            if tab['slug'] == 'general':
+                form_name = 'form'
+            else:
+                form_name = '%s_form' % tab['slug']
+            context[form_name] = form
+            if tab.get('template'):
+                cell_form_template = template.loader.get_template(tab['template'])
+            else:
+                cell_form_template = engines['django'].from_string('{{ %s.as_p }}' % form_name)
+            response['tabs'][tab['slug']] = cell_form_template.render(context)
+
+        response['visibility_css_class'] = self.object.get_manager_visibility_css_class()
+        response['visibility_content'] = self.object.get_manager_visibility_content()
+        response['extra_css_class'] = self.object.extra_css_class
+        response['slug'] = self.object.slug
+        response['template_label'] = self.object.get_template_label()
+        response['additional_label'] = self.object.get_additional_label()
+        response['invalid_reason'] = self.object.get_invalid_reason()
+
+        if not response['errorlist']:
+            PageSnapshot.take(
+                self.object.page, request=self.request, comment=_('changed cell "%s"') % self.object
+            )
+        return JsonResponse(response)
 
     def form_valid(self, form):
         if self.request.is_ajax():
@@ -787,26 +836,6 @@ class PageDuplicateCellView(ManagedPageMixin, FormView):
 page_duplicate_cell = PageDuplicateCellView.as_view()
 
 
-class PageCellVisibilityView(PageEditCellView):
-    template_name = 'combo/cell_visibility.html'
-
-    def get_form_class(self):
-        return CellVisibilityForm
-
-
-page_cell_visibility = PageCellVisibilityView.as_view()
-
-
-class PageCellOptionsView(PageEditCellView):
-    template_name = 'combo/cell_options.html'
-
-    def get_form_class(self):
-        return self.object.get_options_form_class()
-
-
-page_cell_options = PageCellOptionsView.as_view()
-
-
 class PageCellOrder(ManagedPageMixin, View):
     def get(self, *args, **kwargs):
         request = self.request
@@ -877,19 +906,6 @@ def page_order(request):
     return redirect(reverse('combo-manager-homepage'))
 
 
-def page_get_additional_label(request, page_pk, cell_reference):
-    cell = CellBase.get_cell(cell_reference, page_id=page_pk)
-    response = HttpResponse(content_type='application/json')
-    json.dump(
-        {
-            'label': force_text(cell.get_additional_label()) or '',
-            'invalid_reason': force_text(cell.get_invalid_reason() or ''),
-        },
-        response,
-    )
-    return response
-
-
 def menu_json(request):
     if settings.TEMPLATE_VARS.get('site_title'):
         label = _('Editing "%(site_title)s"') % settings.TEMPLATE_VARS
diff --git a/combo/settings.py b/combo/settings.py
index f8522133..b5f2322c 100644
--- a/combo/settings.py
+++ b/combo/settings.py
@@ -186,6 +186,7 @@ CKEDITOR_CONFIGS = {
         ],
         'toolbar': 'Own',
         'resize_enabled': False,
+        'width': '100%',
     },
 }
 
diff --git a/combo/utils/forms.py b/combo/utils/forms.py
index 948c6226..c8b3d23c 100644
--- a/combo/utils/forms.py
+++ b/combo/utils/forms.py
@@ -65,7 +65,7 @@ class MultiSortWidget(forms.SelectMultiple):
 
     def value_from_datadict(self, data, files, name):
         if isinstance(data, MultiValueDict):
-            return {'data': data.getlist(name)}
+            return json.dumps({'data': data.getlist(name)})
         return data.get(name, None)
 
     def format_value(self, value):
diff --git a/tests/test_dataviz.py b/tests/test_dataviz.py
index bc260afa..357cd7bd 100644
--- a/tests/test_dataviz.py
+++ b/tests/test_dataviz.py
@@ -15,6 +15,7 @@ from combo.data.models import Page, ValidityInfo
 from combo.utils.spooler import refresh_statistics_data
 
 from .test_public import login
+from .utils import manager_submit_cell
 
 pytestmark = pytest.mark.django_db
 
@@ -1296,11 +1297,11 @@ def test_chartng_cell_manager_new_api(app, admin_user, new_api_statistics):
         ('combo', False, 'Combo'),
     ]
     resp.form[field_prefix + 'service'] = ''
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'service'].value == ''
 
     resp.form[field_prefix + 'ou'] = 'default'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'ou'].value == 'default'
     cell.refresh_from_db()
     assert cell.get_filter_params() == {'ou': 'default', 'time_interval': 'month'}
@@ -1318,20 +1319,20 @@ def test_chartng_cell_manager_new_api(app, admin_user, new_api_statistics):
     ]
 
     resp.form[field_prefix + 'ou'] = ''
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'ou'].value == ''
     cell.refresh_from_db()
     assert cell.get_filter_params() == {'time_interval': 'month'}
 
     resp.form[field_prefix + 'time_range'] = 'previous-year'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
     assert cell.time_range == 'previous-year'
 
     resp.form[field_prefix + 'time_range'] = 'range'
     resp.form[field_prefix + 'time_range_start'] = '2020-10-01'
     resp.form[field_prefix + 'time_range_end'] = '2020-11-03'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
     assert cell.time_range == 'range'
     assert cell.time_range_start == date(year=2020, month=10, day=1)
@@ -1339,14 +1340,14 @@ def test_chartng_cell_manager_new_api(app, admin_user, new_api_statistics):
 
     resp.form[field_prefix + 'time_range_start'] = ''
     resp.form[field_prefix + 'time_range_end'] = ''
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
     assert cell.time_range_start is None
     assert cell.time_range_end is None
 
     no_filters_stat = Statistic.objects.get(slug='two-series')
     resp.form[field_prefix + 'statistic'] = no_filters_stat.pk
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'statistic'].value == str(no_filters_stat.pk)
     assert field_prefix + 'time_interval' not in resp.form.fields
     assert field_prefix + 'ou' not in resp.form.fields
@@ -1356,9 +1357,9 @@ def test_chartng_cell_manager_new_api(app, admin_user, new_api_statistics):
 
     filter_multiple_stat = Statistic.objects.get(slug='filter-multiple')
     resp.form[field_prefix + 'statistic'] = filter_multiple_stat.pk
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     resp.form[field_prefix + 'color'].select_multiple(texts=['Blue', 'Green'])
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'color'].value == ['green', 'blue']
     cell.refresh_from_db()
     assert cell.filter_params == {'color': ['green', 'blue']}
@@ -1376,7 +1377,7 @@ def test_chartng_cell_manager_new_api(app, admin_user, new_api_statistics):
     ]
 
     resp.form[field_prefix + 'color'].select_multiple(texts=[])
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'color'].value is None
     cell.refresh_from_db()
     assert cell.get_filter_params() == {}
@@ -1440,20 +1441,20 @@ def test_chartng_cell_manager_subfilters(app, admin_user, new_api_statistics):
 
     # choice with no subfilter
     resp.form[field_prefix + 'form'] = 'contact'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
 
     assert len(new_api_mock.call['requests']) == 1
     assert 'menu' not in resp.form.fields
 
     resp.form[field_prefix + 'form'] = 'error'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
 
     assert len(new_api_mock.call['requests']) == 2
     assert 'menu' not in resp.form.fields
 
     # choice with subfilter
     resp.form[field_prefix + 'form'] = 'food-request'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
 
     assert len(new_api_mock.call['requests']) == 3
     menu_field = resp.form[field_prefix + 'menu']
@@ -1465,14 +1466,14 @@ def test_chartng_cell_manager_subfilters(app, admin_user, new_api_statistics):
     ]
 
     resp.form[field_prefix + 'menu'] = 'meat'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'menu'].value == 'meat'
     cell.refresh_from_db()
     assert cell.get_filter_params() == {'form': 'food-request', 'menu': 'meat'}
 
     # choice with no subfilter
     resp.form[field_prefix + 'form'] = 'contact'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
 
     assert len(new_api_mock.call['requests']) == 4
     assert 'menu' not in resp.form.fields
@@ -1481,7 +1482,7 @@ def test_chartng_cell_manager_subfilters(app, admin_user, new_api_statistics):
 
     # changing another filter doesn't trigger request
     resp.form[field_prefix + 'other'] = 'one'
-    resp = resp.form.submit().follow()
+    resp = resp.form.submit()
     assert len(new_api_mock.call['requests']) == 4
 
 
@@ -1500,7 +1501,7 @@ def test_chartng_cell_manager_new_api_time_range_templates(app, admin_user, new_
     resp.form[field_prefix + 'time_range'] = 'range-template'
     resp.form[field_prefix + 'time_range_start_template'] = 'today|add_days:"7"|adjust_to_week_monday'
     resp.form[field_prefix + 'time_range_end_template'] = 'now|add_days:"14"|adjust_to_week_monday'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
     assert cell.time_range == 'range-template'
     assert cell.time_range_start_template == 'today|add_days:"7"|adjust_to_week_monday'
@@ -1508,23 +1509,23 @@ def test_chartng_cell_manager_new_api_time_range_templates(app, admin_user, new_
 
     resp.form[field_prefix + 'time_range_start_template'] = ''
     resp.form[field_prefix + 'time_range_end_template'] = ''
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
     assert cell.time_range_start_template == ''
     assert cell.time_range_end_template == ''
 
     resp.form[field_prefix + 'time_range_start_template'] = 'xxx'
-    resp = resp.form.submit()
+    manager_submit_cell(resp.form, expect_errors=True)
     assert 'Template does not evaluate to a valid date.' in resp.text
 
     resp = app.get('/manage/pages/%s/' % page.id)
     resp.form[field_prefix + 'time_range_start_template'] = 'today|xxx'
-    resp = resp.form.submit()
+    manager_submit_cell(resp.form, expect_errors=True)
     assert 'Invalid filter' in resp.text
 
     resp = app.get('/manage/pages/%s/' % page.id)
     resp.form[field_prefix + 'time_range_start_template'] = 'today|date:xxx'
-    resp = resp.form.submit()
+    manager_submit_cell(resp.form, expect_errors=True)
     assert 'Failed lookup for key [xxx]' in resp.text
 
 
@@ -1539,7 +1540,7 @@ def test_chartng_cell_manager_new_api_dynamic_fields(app, admin_user, new_api_st
     field_prefix = 'cdataviz_chartngcell-%s-' % cell.id
     resp.form[field_prefix + 'statistic'] = statistic.pk
     resp = app.post(resp.form.action, params=resp.form.submit_fields(), xhr=True)
-    assert 'time_interval' in resp.text
+    assert 'time_interval' in resp.json['tabs']['general']
 
 
 @with_httmock(new_api_mock)
@@ -1575,7 +1576,7 @@ def test_chartng_cell_manager_new_api_page_variables(app, admin_user, new_api_st
     ]
 
     resp.form[field_prefix + 'ou'] = 'variable:foo'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form[field_prefix + 'ou'].value == 'variable:foo'
     cell.refresh_from_db()
     assert cell.filter_params['ou'] == 'variable:foo'
@@ -2043,7 +2044,7 @@ def test_chartng_cell_new_api_aggregation(new_api_statistics, app, admin_user, n
         ('year', False, 'Year'),
         ('weekday', False, 'Week day'),
     ]
-    resp.form.submit()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
 
     chart = cell.get_chart()
@@ -2053,8 +2054,9 @@ def test_chartng_cell_new_api_aggregation(new_api_statistics, app, admin_user, n
     assert chart.raw_series[0][0][:8] == [0, 0, 0, 0, 0, 0, 0, 1]
     assert chart.raw_series[1][0][:8] == [2, 0, 0, 0, 0, 0, 0, 2]
 
+    time_interval_field = resp.form['cdataviz_chartngcell-%s-time_interval' % cell.id]
     time_interval_field.value = 'month'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     time_interval_field = resp.form['cdataviz_chartngcell-%s-time_interval' % cell.id]
     assert time_interval_field.options == [
         ('day', False, 'Day'),
@@ -2075,6 +2077,7 @@ def test_chartng_cell_new_api_aggregation(new_api_statistics, app, admin_user, n
         ([4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], {'title': 'Serie 2'}),
     ]
 
+    time_interval_field = resp.form['cdataviz_chartngcell-%s-time_interval' % cell.id]
     time_interval_field.value = 'year'
     resp.form.submit()
     cell.refresh_from_db()
@@ -2087,8 +2090,9 @@ def test_chartng_cell_new_api_aggregation(new_api_statistics, app, admin_user, n
         ([5, 0, 0], {'title': 'Serie 2'}),
     ]
 
+    time_interval_field = resp.form['cdataviz_chartngcell-%s-time_interval' % cell.id]
     time_interval_field.value = 'weekday'
-    resp.form.submit()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
 
     chart = cell.get_chart()
@@ -2099,8 +2103,9 @@ def test_chartng_cell_new_api_aggregation(new_api_statistics, app, admin_user, n
         ([1, 4, 0, 0, 0, 0, 0], {'title': 'Serie 2'}),
     ]
 
+    time_interval_field = resp.form['cdataviz_chartngcell-%s-time_interval' % cell.id]
     time_interval_field.value = 'week'
-    resp.form.submit()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
 
     chart = cell.get_chart()
@@ -2115,8 +2120,7 @@ def test_chartng_cell_new_api_aggregation(new_api_statistics, app, admin_user, n
 
     cell.statistic = Statistic.objects.get(slug='leap-week')
     cell.save()
-    resp = app.get('/manage/pages/%s/' % page.id)
-    resp.form.submit()
+    manager_submit_cell(resp.form)
     chart = cell.get_chart()
     assert 'time_interval=day' in new_api_mock.call['requests'][1].url
     assert len(chart.x_labels) == 1
@@ -2335,7 +2339,7 @@ def test_chart_filters_cell_with_subfilters(new_api_statistics, app, admin_user,
     # select a choice with subfilters in manager
     resp = app.get('/manage/pages/%s/' % page.id)
     resp.forms[1]['cdataviz_chartngcell-%s-form' % cell.id] = 'food-request'
-    resp = resp.forms[1].submit().follow()
+    manager_submit_cell(resp.forms[1])
 
     resp = app.get('/')
     assert 'form' in resp.form.fields
diff --git a/tests/test_family.py b/tests/test_family.py
index 84bcb082..19d78837 100644
--- a/tests/test_family.py
+++ b/tests/test_family.py
@@ -9,6 +9,7 @@ from combo.apps.wcs.context_processors import Cards
 from combo.data.models import Page
 
 from .test_manager import login
+from .utils import manager_submit_cell
 
 pytestmark = pytest.mark.django_db
 
@@ -63,7 +64,7 @@ def test_manager_weeklyagenda_cell(app, admin_user):
     resp.forms[0]['c%s-agenda_categories' % cell.get_reference()].value = 'bar,foo'
     resp.forms[0]['c%s-start_date_filter' % cell.get_reference()].value = '{{ start_date }}'
     resp.forms[0]['c%s-end_date_filter' % cell.get_reference()].value = '{{ end_date }}'
-    resp = resp.forms[0].submit().follow()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
     assert cell.agenda_type == 'subscribed'
     assert cell.agenda_references_template == ''
@@ -74,7 +75,7 @@ def test_manager_weeklyagenda_cell(app, admin_user):
     resp.forms[0]['c%s-agenda_type' % cell.get_reference()].value = 'manual'
     resp.forms[0]['c%s-agenda_references_template' % cell.get_reference()].value = 'foo,bar'
     resp.forms[0]['c%s-agenda_categories' % cell.get_reference()].value = 'bar,foo'
-    resp = resp.forms[0].submit().follow()
+    manager_submit_cell(resp.form)
     cell.refresh_from_db()
     assert cell.agenda_type == 'manual'
     assert cell.agenda_references_template == 'foo,bar'
diff --git a/tests/test_gallery_cell.py b/tests/test_gallery_cell.py
index e7085200..af063694 100644
--- a/tests/test_gallery_cell.py
+++ b/tests/test_gallery_cell.py
@@ -26,13 +26,7 @@ def test_adding_gallery_images(app, admin_user):
     )
 
     app = login(app)
-    resp = app.get(
-        reverse(
-            'combo-manager-page-edit-cell',
-            kwargs={'page_pk': page.id, 'cell_reference': cell.get_reference()},
-        ),
-        status=200,
-    )
+    resp = app.get('/manage/pages/%s/' % page.id)
     assert '<ul class="gallery"' in resp.text
     assert 'js/combo.gallery.js' in resp.text
 
diff --git a/tests/test_kb.py b/tests/test_kb.py
index 66fc940b..bd11c29f 100644
--- a/tests/test_kb.py
+++ b/tests/test_kb.py
@@ -22,6 +22,8 @@ from django.test.client import RequestFactory
 from combo.apps.kb.models import LatestPageUpdatesCell
 from combo.data.models import CellBase, Page, TextCell
 
+from .utils import manager_submit_cell
+
 pytestmark = pytest.mark.django_db
 
 
@@ -49,8 +51,7 @@ def test_manage_updated_pages_cell(app, admin_user):
     resp = resp.follow()
     cells = CellBase.get_cells(page_id=page.id)
     assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
-    resp = resp.forms[0].submit()
-    assert resp.status_int == 302
+    manager_submit_cell(resp.forms[0])
 
     resp = app.get('/example-page/', status=200)
     div = resp.html.find('div', attrs={'class': 'latest-page-updates-cell'})
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 7baf6aa6..877e4072 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -46,6 +46,8 @@ from combo.data.models import (
 )
 from combo.manager.forms import PageAddForm, PageSelectTemplateForm
 
+from .utils import manager_submit_cell
+
 pytestmark = pytest.mark.django_db
 
 TESTS_DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
@@ -699,7 +701,7 @@ def test_edit_page_num_queries(settings, app, admin_user):
     app.get('/manage/pages/%s/' % page.pk)  # load once to populate caches
     with CaptureQueriesContext(connection) as ctx:
         app.get('/manage/pages/%s/' % page.pk)
-    assert len(ctx.captured_queries) == 33
+    assert len(ctx.captured_queries) == 41
 
 
 def test_delete_page(app, admin_user):
@@ -1269,9 +1271,7 @@ def test_add_edit_cell(app, admin_user):
     resp = app.get('/manage/pages/%s/' % page.id)
     assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
     resp.forms[0]['c%s-text' % cells[0].get_reference()].value = 'Hello world'
-    resp = resp.forms[0].submit()
-    assert resp.status_int == 302
-    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.id, cells[0].get_reference()))
+    manager_submit_cell(resp.forms[0])
 
     resp = app.get('/manage/pages/%s/' % page.id)
     assert resp.forms[0]['c%s-text' % cells[0].get_reference()].value == 'Hello world'
@@ -1279,9 +1279,8 @@ def test_add_edit_cell(app, admin_user):
     resp = app.get('/manage/pages/%s/' % page.id)
     assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
     resp.forms[0]['c%s-text' % cells[0].get_reference()].value = 'World Hello'
-    resp = resp.forms[0].submit(xhr=True)
-    assert resp.status_int == 200
-    assert resp.text.strip().startswith('<p><label')
+    manager_submit_cell(resp.forms[0])
+    assert resp.forms[0]['c%s-text' % cells[0].get_reference()].value == 'World Hello'
 
     resp = app.get('/manage/pages/%s/' % page.id)
     assert resp.forms[0]['c%s-text' % cells[0].get_reference()].value == 'World Hello'
@@ -1440,7 +1439,6 @@ def test_edit_cell_visibility(app, admin_user):
 
     app = login(app)
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/visibility' % cell.id)
     assert resp.form['cdata_textcell-%s-visibility' % cell.id].value == 'all'
     resp.form['cdata_textcell-%s-visibility' % cell.id] = 'logged'
     resp = resp.form.submit('submit')
@@ -1449,7 +1447,6 @@ def test_edit_cell_visibility(app, admin_user):
     assert TextCell.objects.get(id=cell.id).groups.count() == 0
 
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/visibility' % cell.id)
     assert resp.form['cdata_textcell-%s-visibility' % cell.id].value == 'logged'
     resp.form['cdata_textcell-%s-visibility' % cell.id] = 'unlogged'
     resp = resp.form.submit('submit')
@@ -1458,7 +1455,6 @@ def test_edit_cell_visibility(app, admin_user):
     assert TextCell.objects.get(id=cell.id).groups.count() == 0
 
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/visibility' % cell.id)
     assert resp.form['cdata_textcell-%s-visibility' % cell.id].value == 'unlogged'
     resp.form['cdata_textcell-%s-visibility' % cell.id] = 'all'
     resp = resp.form.submit('submit')
@@ -1472,7 +1468,6 @@ def test_edit_cell_visibility(app, admin_user):
     group2.save()
 
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/visibility' % cell.id)
     resp.form['cdata_textcell-%s-visibility' % cell.id] = 'groups-any'
     resp.form['cdata_textcell-%s-groups' % cell.id] = [group1.id]
     resp = resp.form.submit('submit')
@@ -1482,7 +1477,6 @@ def test_edit_cell_visibility(app, admin_user):
     assert TextCell.objects.get(id=cell.id).groups.all()[0].name == 'A group'
 
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/visibility' % cell.id)
     resp.form['cdata_textcell-%s-visibility' % cell.id] = 'groups-none'
     resp.form['cdata_textcell-%s-groups' % cell.id] = [group2.id]
     resp = resp.form.submit('submit')
@@ -1501,7 +1495,6 @@ def test_edit_cell_options(app, admin_user):
 
     app = login(app)
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/options' % cell.id)
     assert resp.form['cdata_textcell-%s-slug' % cell.id].value == ''
     assert resp.form['cdata_textcell-%s-extra_css_class' % cell.id].value == ''
     resp.form['cdata_textcell-%s-slug' % cell.id] = 'SLUG'
@@ -1510,14 +1503,12 @@ def test_edit_cell_options(app, admin_user):
     assert 'SLUG' in app.get('/manage/pages/%s/' % page.id)
 
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/options' % cell.id)
     resp.form['cdata_textcell-%s-slug' % cell.id] = ''
     resp = resp.form.submit('submit')
     assert TextCell.objects.get(id=cell.id).slug == ''
     assert 'SLUG' not in app.get('/manage/pages/%s/' % page.id)
 
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/options' % cell.id)
     resp.form['cdata_textcell-%s-extra_css_class' % cell.id] = 'CSS'
     resp = resp.form.submit('submit')
     assert TextCell.objects.get(id=cell.id).extra_css_class == 'CSS'
@@ -1533,16 +1524,13 @@ def test_edit_cell_options_template(app, admin_user):
 
     app = login(app)
     resp = app.get('/manage/pages/%s/' % page.id)
-    resp = resp.click(href='/data_textcell-%s/options' % cell.id)
     assert 'cdata_textcell-%s-template_name' % cell.id not in resp.form.fields
     assert resp.form['cdata_textcell-%s-slug' % cell.id].value == ''
     assert resp.form['cdata_textcell-%s-extra_css_class' % cell.id].value == ''
 
     with override_settings(COMBO_CELL_TEMPLATES={'data_textcell': {'extra': {'label': 'Extra'}}}):
         resp = app.get('/manage/pages/%s/' % page.id)
-        resp = resp.click(href='/data_textcell-%s/options' % cell.id)
         resp.form['cdata_textcell-%s-template_name' % cell.id].value = 'extra'
-
         resp = resp.form.submit('submit')
         assert TextCell.objects.get(id=cell.id).template_name == 'extra'
         assert '(Extra)' in app.get('/manage/pages/%s/' % page.id)
@@ -1661,7 +1649,10 @@ def test_edit_config_json_cell(app, admin_user):
 
         resp = app.get('/manage/pages/%s/' % page.id)
         assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
-        assert 'There are no options for this cell.' in resp.form.text
+        assert [x.attrib['data-tab-slug'] for x in resp.pyquery('[data-tab-slug]')] == [
+            'visibility',
+            'appearance',
+        ]
 
     # make it configurable
     with override_settings(
@@ -1697,20 +1688,21 @@ def test_edit_config_json_cell(app, admin_user):
         TEMPLATES=templates_settings,
     ):
         resp = app.get('/manage/pages/%s/' % page.id)
-        assert 'There are no options for this cell.' not in resp.form.text
+        assert [x.attrib['data-tab-slug'] for x in resp.pyquery('[data-tab-slug]')] == [
+            'general',
+            'visibility',
+            'appearance',
+        ]
 
         resp.form['c%s-test' % cells[0].get_reference()].value = 'Hello world'
         resp.form['c%s-test3' % cells[0].get_reference()].value = 'Hello again'
-        resp = resp.form.submit()
-        assert resp.status_int == 302
-        assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.id, cells[0].get_reference()))
+        manager_submit_cell(resp.form)
 
         # test form error
         resp = app.get('/manage/pages/%s/' % page.id)
         resp.form['c%s-test' % cells[0].get_reference()].value = ''
         resp.form['c%s-test3' % cells[0].get_reference()].value = 'Hello'
-        resp = resp.form.submit()
-        assert resp.status_int == 200
+        resp = manager_submit_cell(resp.form, expect_errors=True)
         assert resp.context['form'].errors['test'] == ['This field is required.']
 
         resp = app.get('/manage/pages/%s/' % page.id)
@@ -1724,9 +1716,8 @@ def test_edit_config_json_cell(app, admin_user):
         resp.forms[0]['c%s-test2' % cells[0].get_reference()].checked = True
         assert resp.form['c%s-test4' % cells[0].get_reference()].tag == 'textarea'
         resp.forms[0]['c%s-test4' % cells[0].get_reference()].value = 'Text Area'
-        resp = resp.form.submit(xhr=True)
-        assert resp.status_int == 200
-        assert resp.text.strip().startswith('<p><label')
+        resp = manager_submit_cell(resp.form)
+        assert resp.json['tabs']['general'].strip().startswith('<p><label')
 
         resp = app.get('/manage/pages/%s/' % page.id)
         assert resp.form['c%s-test' % cells[0].get_reference()].value == 'World Hello'
@@ -2338,7 +2329,7 @@ def test_page_versionning(app, admin_user):
 
     # change cell text
     resp.forms[0]['c%s-text' % cell1.get_reference()].value = 'Hello world'
-    resp = resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[0])
     assert PageSnapshot.objects.all().count() == 2
 
     # reorder cells
@@ -2379,15 +2370,15 @@ def test_page_versionning(app, admin_user):
     cell_id = JsonCell.objects.last().id
     resp.forms[3]['cdata_jsoncell-%s-template_string' % cell_id].value = 'A{{json.data.0.text}}B'
     resp.forms[3]['cdata_jsoncell-%s-url' % cell_id].value = 'http://example.com'
-    resp = resp.forms[3].submit().follow()
+    manager_submit_cell(resp.forms[3])
     assert PageSnapshot.objects.all().count() == 5  # add + change
 
     resp.forms[3]['cdata_jsoncell-%s-template_string' % cell_id].value = 'C{{json.data.0.text}}D'
-    resp = resp.forms[3].submit().follow()
+    manager_submit_cell(resp.forms[3])
     assert PageSnapshot.objects.all().count() == 6
 
     resp.forms[1]['c%s-text' % cell1.get_reference()].value = 'Foo back to 1'
-    resp = resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[1])
 
     resp = resp.click('History')
     assert 'added cell' in resp.text
@@ -2420,7 +2411,7 @@ def test_page_versionning(app, admin_user):
     resp = resp.click('restore', index=6)
     with CaptureQueriesContext(connection) as ctx:
         resp = resp.form.submit().follow()
-        assert len(ctx.captured_queries) == 146
+        assert len(ctx.captured_queries) == 152
 
     resp2 = resp.click('See online')
     assert resp2.text.index('Foobar1') < resp2.text.index('Foobar2') < resp2.text.index('Foobar3')
@@ -2537,14 +2528,14 @@ def test_json_cell_syntax_validation(app, admin_user):
     resp.forms[0]['cdata_jsoncell-%s-template_string' % cell_id].value = '{% syntax|error %}'
     resp.forms[0]['cdata_jsoncell-%s-url' % cell_id].value = 'http://example.com'
     resp = resp.forms[0].submit()
-    assert 'syntax error: Invalid block tag' in resp.text
+    assert resp.json['errorlist']['general']['template_string']
     assert JsonCell.objects.count() == 1
     assert JsonCell.objects.first().template_string is None
     # valid syntax
     resp = app.get('/manage/pages/%s/' % page.id)
     resp.forms[0]['cdata_jsoncell-%s-template_string' % cell_id].value = '{{ ok }}'
     resp.forms[0]['cdata_jsoncell-%s-url' % cell_id].value = 'http://example.com'
-    resp = resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[0])
     assert 'syntax error' not in resp.text
     assert JsonCell.objects.count() == 1
     assert JsonCell.objects.first().template_string == '{{ ok }}'
diff --git a/tests/test_maps_manager.py b/tests/test_maps_manager.py
index 47a95f40..96484f74 100644
--- a/tests/test_maps_manager.py
+++ b/tests/test_maps_manager.py
@@ -5,6 +5,8 @@ import pytest
 from combo.apps.maps.models import Map, MapLayer, MapLayerOptions
 from combo.data.models import Page
 
+from .utils import manager_submit_cell
+
 pytestmark = pytest.mark.django_db
 
 
@@ -254,7 +256,7 @@ def test_add_delete_layer(app, admin_user, layer, tiles_layer):
     resp = resp.click('Cancel')
 
     resp.form['cmaps_map-%s-marker_behaviour_onclick' % cell.pk] = 'display_data'
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     resp = resp.click(href='.*/layer/%s/edit/$' % options.pk)
     assert 'this setting has no effect' not in resp.context['form'].fields['properties'].help_text
     resp.form['properties'] = 'a, b'
diff --git a/tests/test_wcs.py b/tests/test_wcs.py
index 4f655d01..e37e390d 100644
--- a/tests/test_wcs.py
+++ b/tests/test_wcs.py
@@ -39,6 +39,7 @@ from combo.data.models import CellBase, LinkListCell, Page, ValidityInfo
 from combo.utils import NothingInCacheException
 
 from .test_manager import login
+from .utils import manager_submit_cell
 
 pytestmark = pytest.mark.django_db
 
@@ -1532,8 +1533,14 @@ def test_manager_forms_of_category_cell(mock_send, app, admin_user):
     resp = app.get('/manage/pages/%s/' % page.id)
     assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
     resp.forms[0]['c%s-ordering' % cells[0].get_reference()].value = 'manual'
-    resp = resp.forms[0].submit()
-    assert resp.status_int == 302
+    manager_submit_cell(resp.forms[0])
+    cells[0].refresh_from_db()
+    resp.forms[0]['c%s-manual_order' % cells[0].get_reference()].select_multiple(
+        ['default::a-second-form-title', 'default::third-form-title']
+    )
+    manager_submit_cell(resp.forms[0])
+    cells[0].refresh_from_db()
+    assert cells[0].manual_order == {'data': ['default::a-second-form-title', 'default::third-form-title']}
 
 
 @mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
@@ -1554,7 +1561,7 @@ def test_manager_current_forms(mock_send, settings, app, admin_user):
     assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
     assert len(resp.form['c%s-categories' % cells[0].get_reference()].options) == 4
     resp.form['c%s-categories' % cells[0].get_reference()].value = ['default:test-3', 'default:test-9']
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
     assert resp.form['c%s-categories' % cells[0].get_reference()].value == [
         'default:test-3',
         'default:test-9',
@@ -1565,7 +1572,7 @@ def test_manager_current_forms(mock_send, settings, app, admin_user):
     assert 'Please choose at least one option among the following: Current Forms, Done Forms' in resp
     resp = app.get('/manage/pages/%s/' % page.id)
     resp.form['c%s-done_forms' % cells[0].get_reference()] = True
-    resp = resp.form.submit().follow()
+    manager_submit_cell(resp.form)
 
     # check wcs_site field is a select box
     assert resp.form['c%s-wcs_site' % cells[0].get_reference()].tag == 'select'
@@ -1587,7 +1594,7 @@ def test_manager_cards_cell(mock_send, app, admin_user):
     assert cell.without_user is False
     assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value == 'on'
     resp.forms[0]['c%s-with_user' % cell.get_reference()].value = False
-    resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[0])
     cell.refresh_from_db()
     assert cell.without_user is True
     assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value is None
@@ -1947,7 +1954,7 @@ def test_card_cell_setup(mock_send, app, admin_user):
     resp = app.get('/manage/pages/%s/' % page.pk)
     assert resp.forms[0]['c%s-customize_display' % cell.get_reference()].value == 'on'
     resp.forms[0]['c%s-customize_display' % cell.get_reference()].value = False
-    resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[0])
     cell.refresh_from_db()
     assert cell.custom_schema == {}
 
@@ -1956,7 +1963,7 @@ def test_card_cell_setup(mock_send, app, admin_user):
     resp = app.get('/manage/pages/%s/' % page.pk)
     assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == '--'
     resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = '42'
-    resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[0])
     cell.refresh_from_db()
     assert cell.related_card_path == ''
     assert cell.card_ids == ''
@@ -1964,7 +1971,7 @@ def test_card_cell_setup(mock_send, app, admin_user):
     assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == '--'
     resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value = ''
     resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = '42'
-    resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[0])
     cell.refresh_from_db()
     assert cell.related_card_path == ''
     assert cell.card_ids == '42'
@@ -2192,7 +2199,7 @@ def test_manager_card_cell(mock_send, app, admin_user):
     assert cell.without_user is False
     assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value == 'on'
     resp.forms[0]['c%s-with_user' % cell.get_reference()].value = False
-    resp = resp.forms[0].submit().follow()
+    manager_submit_cell(resp.forms[0])
     cell.refresh_from_db()
     assert cell.without_user is True
     assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value is None
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 00000000..f676c964
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,16 @@
+from pyquery import PyQuery
+
+
+def manager_submit_cell(form, expect_errors=False):
+    # submit cell edit form and replace current page body with
+    # new tab contents.
+    resp = form.response
+    action = form.action
+    pq = PyQuery(resp.body)
+    resp2 = form.submit()
+    for tab in resp2.json['tabs']:
+        pq.find('form[action="%s"] [data-tab-slug="%s"]' % (action, tab)).html(resp2.json['tabs'][tab])
+    resp.text = pq.html()
+    resp._forms_indexed = None
+    assert expect_errors or not resp2.json['errorlist'], 'got unexpected errors'
+    return resp2
-- 
2.35.1

