From 51fb9e738454108615cb06915625070a87e94f05 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 22 Feb 2022 14:20:35 +0100
Subject: [PATCH 1/3] general: add possibily to layout placeholder cells in
 flex grid (#62072)

---
 .../0054_page_placeholder_options.py          | 19 ++++++++
 combo/data/models.py                          | 45 ++++++++++++++++++-
 combo/manager/forms.py                        | 14 ++++++
 combo/manager/static/css/combo.manager.css    |  7 +++
 combo/manager/templates/combo/page_view.html  |  5 ++-
 .../templates/combo/placeholder_options.html  | 18 ++++++++
 combo/manager/urls.py                         |  5 +++
 combo/manager/views.py                        | 28 ++++++++++++
 combo/public/templates/combo/placeholder.html |  2 +
 combo/public/templatetags/combo.py            |  2 +
 tests/test_manager.py                         | 33 ++++++++++++++
 tests/test_public.py                          | 19 ++++++++
 12 files changed, 194 insertions(+), 3 deletions(-)
 create mode 100644 combo/data/migrations/0054_page_placeholder_options.py
 create mode 100644 combo/manager/templates/combo/placeholder_options.html

diff --git a/combo/data/migrations/0054_page_placeholder_options.py b/combo/data/migrations/0054_page_placeholder_options.py
new file mode 100644
index 00000000..a304bf76
--- /dev/null
+++ b/combo/data/migrations/0054_page_placeholder_options.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.21 on 2022-02-22 14:21
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('data', '0053_page_variables'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='page',
+            name='placeholder_options',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict),
+        ),
+    ]
diff --git a/combo/data/models.py b/combo/data/models.py
index 6c1c56c0..bee5f450 100644
--- a/combo/data/models.py
+++ b/combo/data/models.py
@@ -195,6 +195,7 @@ class Page(models.Model):
     exclude_from_navigation = models.BooleanField(_('Exclude from navigation'), default=True)
     redirect_url = models.CharField(_('Redirect URL'), max_length=200, blank=True)
     extra_variables = JSONField(blank=True, default=dict)
+    placeholder_options = JSONField(blank=True, default=dict)
 
     public = models.BooleanField(_('Public'), default=True)
     groups = models.ManyToManyField(Group, verbose_name=_('Groups'), blank=True)
@@ -863,6 +864,10 @@ class CellBase(models.Model, metaclass=CellMeta):
             ]
         )
 
+    @property
+    def cleaned_extra_css_class(self):
+        return ' '.join([x for x in self.extra_css_class.split() if not x.startswith('size--')])
+
     @property
     def asset_css_classes(self):
         from combo.apps.assets.models import Asset
@@ -1105,7 +1110,45 @@ class CellBase(models.Model, metaclass=CellMeta):
                 (k, v['label']) for k, v in extra_templates.items()
             ]
             widgets = {'template_name': forms.Select(choices=template_names)}
-        return model_forms.modelform_factory(self.__class__, fields=fields, widgets=widgets)
+
+        page = self.page
+        cell = self
+
+        class OptionsForm(model_forms.ModelForm):
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+                if page.placeholder_options.get(cell.placeholder, {}).get('fx_grid_layout'):
+                    # add a size field that takes/stores its value in the extra_css_class
+                    # char field.
+                    self.fields['fx_size'] = forms.ChoiceField(
+                        label=_('Size'),
+                        choices=[
+                            ('', ''),
+                            ('size--1-1', '1/1'),
+                            ('size--t1-2', '1/2'),
+                            ('size--t1-3', '1/3'),
+                        ],
+                        required=False,
+                    )
+                    self.fields.move_to_end('extra_css_class')
+                    extra_css_class = self.initial['extra_css_class'].split()
+                    for css_class, _dummy in self.fields['fx_size'].choices:
+                        if css_class in extra_css_class:
+                            extra_css_class.remove(css_class)
+                            self.initial['extra_css_class'] = ' '.join(extra_css_class)
+                            self.initial['fx_size'] = css_class
+                            break
+
+            def save(self, *args, **kwargs):
+                if self.cleaned_data.get('fx_size'):
+                    self.instance.extra_css_class = (
+                        '%s %s'
+                        % (self.instance.extra_css_class or '', self.cleaned_data.get('fx_size') or '')
+                    ).strip()
+
+                return super().save(*args, **kwargs)
+
+        return model_forms.modelform_factory(self.__class__, form=OptionsForm, fields=fields, widgets=widgets)
 
     def get_extra_manager_context(self):
         return {}
diff --git a/combo/manager/forms.py b/combo/manager/forms.py
index 2556353b..0123de2d 100644
--- a/combo/manager/forms.py
+++ b/combo/manager/forms.py
@@ -345,3 +345,17 @@ class SiteSettingsForm(forms.ModelForm):
         self.fields['initial_login_page'].widget.template_name = 'combo/widgets/select_with_other_option.html'
         self.fields['initial_login_page'].widget.attrs['class'] = 'page-selector'
         self.fields['welcome_page'].queryset = self.fields['welcome_page'].queryset.filter(public=True)
+
+
+class PlaceholderOptionsForm(forms.Form):
+    fx_grid_layout = forms.ChoiceField(
+        label=_('Grid Layout'),
+        required=False,
+        choices=[
+            ('', _('None')),
+            ('fx-grid--auto', _('Automatic')),
+            ('fx-grid', _('1 column')),
+            ('fx-grid--t2', _('2 columns')),
+            ('fx-grid--t3', _('3 columns')),
+        ],
+    )
diff --git a/combo/manager/static/css/combo.manager.css b/combo/manager/static/css/combo.manager.css
index 57e32b70..a602f218 100644
--- a/combo/manager/static/css/combo.manager.css
+++ b/combo/manager/static/css/combo.manager.css
@@ -17,9 +17,16 @@ div.page-options span.subslug {
 }
 
 div.placeholder {
+	position: relative;
 	margin-bottom: 2em;
 }
 
+.placeholder-options-link {
+	position: absolute;
+	top: 1em;
+	right: 1em;
+}
+
 div#pages-list div.page {
 	display: flex;
 	align-items: center;
diff --git a/combo/manager/templates/combo/page_view.html b/combo/manager/templates/combo/page_view.html
index 10682cc6..38d69cfa 100644
--- a/combo/manager/templates/combo/page_view.html
+++ b/combo/manager/templates/combo/page_view.html
@@ -156,6 +156,7 @@
 {% for placeholder in placeholders %}
   <div class="placeholder" data-placeholder-key="{{ placeholder.key }}">
     <h2>{{ placeholder.name }}</h2>
+    <a class="placeholder-options-link" data-popup href="{% url 'combo-manage-placeholder-options' page_pk=object.id placeholder=placeholder.key %}">{% trans "Options" %}</a>
     <div class="cell-list">
       {% for cell in placeholder.cells %}
       <div id="cell-{{cell.get_reference}}" class="cell {{cell.class_name}}" data-cell-reference="{{ cell.get_reference }}">
@@ -164,8 +165,8 @@
                         {{ 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>
+                  {% if cell.cleaned_extra_css_class %}
+                  <span class="extra-css-class">[{{ cell.cleaned_extra_css_class }}]</span>
                   {% endif %}
                   <span class="additional-label"><i>{{cell.get_additional_label|default_if_none:""}}</i></span>
                   {% if cell.get_invalid_reason %}
diff --git a/combo/manager/templates/combo/placeholder_options.html b/combo/manager/templates/combo/placeholder_options.html
new file mode 100644
index 00000000..93c5ae2e
--- /dev/null
+++ b/combo/manager/templates/combo/placeholder_options.html
@@ -0,0 +1,18 @@
+{% extends "combo/manager_base.html" %}
+{% load i18n %}
+
+{% block appbar %}
+<h2>{% trans "Options" %}</h2>
+{% endblock %}
+
+{% block content %}
+
+<form method="post">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans "Submit" %}</button>
+    <a class="cancel" href="{% url 'combo-manager-page-view' pk=page.pk %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/combo/manager/urls.py b/combo/manager/urls.py
index 96d79e18..d299af1c 100644
--- a/combo/manager/urls.py
+++ b/combo/manager/urls.py
@@ -146,6 +146,11 @@ urlpatterns = [
         name='combo-manager-link-list-order',
     ),
     url(r'^pages/(?P<page_pk>\d+)/order$', views.cell_order, name='combo-manager-cell-order'),
+    url(
+        r'^pages/(?P<page_pk>\d+)/placeholder/(?P<placeholder>[\w_-]+)/options$',
+        views.placeholder_options,
+        name='combo-manage-placeholder-options',
+    ),
     url(r'^pages/order$', views.page_order, name='combo-manager-page-order'),
     url(
         r'^pages/(?P<page_path>[\w/_-]+)/$',
diff --git a/combo/manager/views.py b/combo/manager/views.py
index 218243b9..0376a19e 100644
--- a/combo/manager/views.py
+++ b/combo/manager/views.py
@@ -74,6 +74,7 @@ from .forms import (
     PageRestrictedAddForm,
     PageSelectTemplateForm,
     PageVisibilityForm,
+    PlaceholderOptionsForm,
     SiteExportForm,
     SiteImportForm,
     SiteSettingsForm,
@@ -1055,3 +1056,30 @@ class SiteSettingsView(UpdateView):
 
 
 site_settings = SiteSettingsView.as_view()
+
+
+class PlaceholderOptionsView(ManagedPageMixin, FormView):
+    form_class = PlaceholderOptionsForm
+    template_name = 'combo/placeholder_options.html'
+
+    def get_object(self):
+        return get_object_or_404(Page, pk=self.kwargs['page_pk'])
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['page'] = self.get_object()
+        return context
+
+    def get_initial(self):
+        return self.page.placeholder_options.get(self.kwargs['placeholder'])
+
+    def form_valid(self, form):
+        self.page.placeholder_options[self.kwargs['placeholder']] = form.cleaned_data
+        self.page.save()
+        return super().form_valid(form)
+
+    def get_success_url(self):
+        return reverse('combo-manager-page-view', kwargs={'pk': self.page.id})
+
+
+placeholder_options = PlaceholderOptionsView.as_view()
diff --git a/combo/public/templates/combo/placeholder.html b/combo/public/templates/combo/placeholder.html
index 6ce4f11e..a299b7ae 100644
--- a/combo/public/templates/combo/placeholder.html
+++ b/combo/public/templates/combo/placeholder.html
@@ -1,5 +1,6 @@
 {% load i18n %}
 {% if render %}
+{% if placeholder_options.fx_grid_layout %}<div class="{{ placeholder_options.fx_grid_layout }}">{% endif %}
 {% if render_skeleton %}
 {{ skeleton }}
 {% endif %}
@@ -15,4 +16,5 @@
      {% endwith %}
      ><div>{% render_cell cell %}</div></div>
 {% endfor %}
+{% if placeholder_options.fx_grid_layout %}</div>{% endif %}
 {% endif %}
diff --git a/combo/public/templatetags/combo.py b/combo/public/templatetags/combo.py
index 2fa21f8c..7f0d96ae 100644
--- a/combo/public/templatetags/combo.py
+++ b/combo/public/templatetags/combo.py
@@ -66,6 +66,8 @@ def skeleton_text(context, placeholder_name, content=''):
 @register.inclusion_tag('combo/placeholder.html', takes_context=True)
 def placeholder(context, placeholder_name, **options):
     placeholder = Placeholder(key=placeholder_name, cell=context.get('cell'), **options)
+    if 'page' in context:
+        context['placeholder_options'] = context['page'].placeholder_options.get(placeholder_name) or {}
     # make sure render_skeleton is available in context
     context['render_skeleton'] = context.get('render_skeleton')
     if context.get('placeholder_search_mode'):
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 523610e7..c9387825 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -2780,3 +2780,36 @@ def test_site_settings(app, admin_user):
     site_settings.refresh_from_db()
     assert site_settings.welcome_page == public_page
     assert site_settings.initial_login_page == private_page
+
+
+def test_manager_placeholder_grid(app, admin_user):
+    page = Page.objects.create(title='Page', slug='page')
+    cell = TextCell.objects.create(page=page, placeholder='content', text='Foobar', order=1)
+    app = login(app)
+    resp = app.get('/manage/pages/%s/' % page.id)
+    resp = resp.click('Options', href=r'cell.*options')
+    assert 'fx_size' not in resp.form.fields
+    resp = app.get('/manage/pages/%s/' % page.id)
+    resp = resp.click('Options', href=r'placeholder.*options')
+    resp.form['fx_grid_layout'].select(text='Automatic')
+    resp = resp.form.submit().follow()
+
+    page.refresh_from_db()
+    assert page.placeholder_options['content']['fx_grid_layout'] == 'fx-grid--auto'
+    resp = resp.click('Options', href=r'placeholder.*options')
+    assert resp.form['fx_grid_layout'].value == 'fx-grid--auto'
+
+    resp = app.get('/manage/pages/%s/' % page.id)
+    resp = resp.click('Options', href=r'cell.*options')
+    resp.form['cdata_textcell-%s-fx_size' % cell.id].select(text='1/2')
+    resp = resp.form.submit().follow()
+    cell.refresh_from_db()
+    assert cell.extra_css_class == 'size--t1-2'
+
+    resp = resp.click('Options', href=r'cell.*options')
+    assert resp.form['cdata_textcell-%s-fx_size' % cell.id].value == 'size--t1-2'
+    assert not resp.form['cdata_textcell-%s-extra_css_class' % cell.id].value
+    resp.form['cdata_textcell-%s-extra_css_class' % cell.id] = 'plop'
+    resp = resp.form.submit().follow()
+    cell.refresh_from_db()
+    assert set(cell.extra_css_class.split()) == {'plop', 'size--t1-2'}
diff --git a/tests/test_public.py b/tests/test_public.py
index 81da3b95..54a79fcb 100644
--- a/tests/test_public.py
+++ b/tests/test_public.py
@@ -1213,3 +1213,22 @@ def test_cell_asset_css_classes(settings, app, admin_user):
     assert cell.asset_css_classes == ''
     resp = app.get('/', status=200)
     assert 'class="cell text-cell textcell foo"' in re.sub(r' +', ' ', resp.text)
+
+
+def test_placeholder_grid(app):
+    Page.objects.all().delete()
+    page = Page(title='Home', slug='index', template_name='standard')
+    page.save()
+
+    TextCell.objects.create(page=page, placeholder='content', text='Foobar', order=1)
+
+    resp = app.get('/', status=200)
+    # no placeholder div if there's no grid layout
+    assert resp.pyquery('#content > .text-cell')
+
+    page.placeholder_options = {'content': {'fx_grid_layout': 'fx-grid--auto'}}
+    page.save()
+
+    resp = app.get('/', status=200)
+    # placeholder div in between
+    assert resp.pyquery('#content > .fx-grid--auto > .text-cell')
-- 
2.35.1

