From 14fae99f4f6830c242c07a6bcbe85f340666ea34 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 7 Jan 2020 15:27:44 +0100
Subject: [PATCH 2/2] cells: add a cell type "list of links" (#11006)

---
 combo/apps/wcs/models.py                      |   8 +-
 combo/data/forms.py                           |  13 +-
 combo/data/migrations/0039_link_list_cell.py  |  38 +++++
 combo/data/models.py                          |  63 +++++++-
 .../combo/manager/link-list-cell-form.html    |  27 ++++
 combo/manager/static/js/combo.manager.js      |  15 ++
 .../templates/combo/link_cell_form.html       |  22 +++
 combo/manager/urls.py                         |  12 ++
 combo/manager/views.py                        | 143 +++++++++++++++++-
 .../templates/combo/link-list-cell.html       |  11 ++
 tests/test_cells.py                           |  66 +++++++-
 tests/test_manager.py                         |  72 ++++++++-
 tests/test_pages.py                           |  35 ++++-
 tests/test_wcs.py                             |  92 ++++++++++-
 14 files changed, 605 insertions(+), 12 deletions(-)
 create mode 100644 combo/data/migrations/0039_link_list_cell.py
 create mode 100644 combo/data/templates/combo/manager/link-list-cell-form.html
 create mode 100644 combo/manager/templates/combo/link_cell_form.html
 create mode 100644 combo/public/templates/combo/link-list-cell.html

diff --git a/combo/apps/wcs/models.py b/combo/apps/wcs/models.py
index e183070..318810e 100644
--- a/combo/apps/wcs/models.py
+++ b/combo/apps/wcs/models.py
@@ -36,14 +36,18 @@ from .utils import get_wcs_json, is_wcs_enabled, get_wcs_services
 
 @register_cell_class
 class WcsFormCell(CellBase):
-    template_name = 'combo/wcs/form.html'
-
     formdef_reference = models.CharField(_('Form'), max_length=150)
 
     cached_title = models.CharField(_('Title'), max_length=150)
     cached_url = models.URLField(_('URL'))
     cached_json = JSONField(blank=True)
 
+    template_name = 'combo/wcs/form.html'
+    add_as_link_label = _('add a form link')
+    add_link_label = _('New form link')
+    edit_link_label = _('Edit form link')
+    add_as_link_code = 'form-link'
+
     is_enabled = classmethod(is_wcs_enabled)
 
     class Meta:
diff --git a/combo/data/forms.py b/combo/data/forms.py
index ffd4c92..6bbc976 100644
--- a/combo/data/forms.py
+++ b/combo/data/forms.py
@@ -18,7 +18,7 @@ import copy
 
 from django import forms
 
-from .models import Page, ParametersCell, MenuCell, LinkCell, ConfigJsonCell
+from .models import Page, ParametersCell, MenuCell, LinkCell, LinkListCell, ConfigJsonCell
 from jsonfield.widgets import JSONWidget
 
 from combo.utils import cache_during_request
@@ -41,6 +41,7 @@ class ParametersForm(forms.Form):
                 break
         return self.cleaned_data
 
+
 class ParametersCellForm(forms.ModelForm):
     class Meta:
         model = ParametersCell
@@ -61,6 +62,7 @@ def get_page_choices():
     pages = Page.get_as_reordered_flat_hierarchy(Page.objects.all())
     return [(x.id, '%s %s' % (u'\u00a0' * x.level * 2, x.title)) for x in pages]
 
+
 class MenuCellForm(forms.ModelForm):
     class Meta:
         model = MenuCell
@@ -82,6 +84,15 @@ class LinkCellForm(forms.ModelForm):
                 choices=[(None, '-----')] + get_page_choices())
 
 
+class LinkListCellForm(forms.ModelForm):
+    class Meta:
+        model = LinkListCell
+        fields = []
+
+    def __init__(self, *args, **kwargs):
+        super(LinkListCellForm, self).__init__(*args, **kwargs)
+
+
 class ConfigJsonForm(forms.ModelForm):
     formdef = []
 
diff --git a/combo/data/migrations/0039_link_list_cell.py b/combo/data/migrations/0039_link_list_cell.py
new file mode 100644
index 0000000..e770447
--- /dev/null
+++ b/combo/data/migrations/0039_link_list_cell.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0008_alter_user_username_max_length'),
+        ('data', '0038_increase_jsoncell_url_max_length'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='LinkListCell',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('placeholder', models.CharField(max_length=20)),
+                ('order', models.PositiveIntegerField()),
+                ('slug', models.SlugField(blank=True, verbose_name='Slug')),
+                ('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')),
+                ('public', models.BooleanField(default=True, verbose_name='Public')),
+                ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
+                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
+                ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
+            ],
+            options={
+                'verbose_name': 'List of links',
+            },
+        ),
+        migrations.AddField(
+            model_name='linklistcell',
+            name='page',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page'),
+        ),
+    ]
diff --git a/combo/data/models.py b/combo/data/models.py
index d4f7093..dc680eb 100644
--- a/combo/data/models.py
+++ b/combo/data/models.py
@@ -451,6 +451,8 @@ class Page(models.Model):
         new_page.groups.set(self.groups.all())
 
         for cell in self.get_cells():
+            if cell.placeholder and cell.placeholder.startswith('_'):
+                continue
             cell.duplicate(page_target=new_page)
 
         return new_page
@@ -756,12 +758,14 @@ class CellBase(six.with_metaclass(CellMeta, models.Model)):
     def import_subobjects(self, cell_json):
         pass
 
-    def duplicate(self, page_target=None):
+    def duplicate(self, page_target=None, placeholder=None):
         # clone current cell
         new_cell = copy.deepcopy(self)
         new_cell.pk = None
         # set page
         new_cell.page = page_target or self.page
+        # set placeholder
+        new_cell.placeholder = placeholder or new_cell.placeholder
         # store new cell
         new_cell.save()
 
@@ -905,6 +909,10 @@ class LinkCell(CellBase):
     anchor = models.CharField(_('Anchor'), max_length=150, blank=True)
 
     template_name = 'combo/link-cell.html'
+    add_as_link_label = _('add a link')
+    add_link_label = _('New link')
+    edit_link_label = _('Edit link')
+    add_as_link_code = 'link'
 
     class Meta:
         verbose_name = _('Link')
@@ -950,6 +958,59 @@ class LinkCell(CellBase):
         return []
 
 
+@register_cell_class
+class LinkListCell(CellBase):
+    template_name = 'combo/link-list-cell.html'
+    manager_form_template = 'combo/manager/link-list-cell-form.html'
+
+    class Meta:
+        verbose_name = _('List of links')
+
+    @property
+    def link_placeholder(self):
+        return '_linkslist:{}'.format(self.pk)
+
+    def get_items(self):
+        return CellBase.get_cells(page=self.page, placeholder=self.link_placeholder)
+
+    def get_cell_extra_context(self, context):
+        render_skeleton = context.get('render_skeleton')
+        request = context.get('request')
+        extra_context = super(LinkListCell, self).get_cell_extra_context(context)
+        extra_context['links'] = [item.get_cell_extra_context(context) for item in self.get_items()]
+        return extra_context
+
+    def get_link_cell_classes(self):
+        return CellBase.get_cell_classes(lambda x: hasattr(x, 'add_as_link_label'))
+
+    def get_default_form_class(self):
+        from .forms import LinkListCellForm
+        return LinkListCellForm
+
+    def render_for_search(self):
+        return ''
+
+    def export_subobjects(self):
+        return {'links': json.loads(
+            serializers.serialize(
+                'json',
+                self.get_items(),
+                use_natural_foreign_keys=True,
+                use_natural_primary_keys=True)
+            )}
+
+    def import_subobjects(self, cell_json):
+        for link in cell_json['links']:
+            link['fields']['placeholder'] = self.link_placeholder
+        for link in serializers.deserialize('json', json.dumps(cell_json['links'])):
+            link.save()
+
+    def duplicate_m2m(self, new_cell):
+        # duplicate also link items
+        for link in self.get_items():
+            link.duplicate(page_target=new_cell.page, placeholder=new_cell.link_placeholder)
+
+
 @register_cell_class
 class FeedCell(CellBase):
     title = models.CharField(_('Title'), max_length=150, blank=True)
diff --git a/combo/data/templates/combo/manager/link-list-cell-form.html b/combo/data/templates/combo/manager/link-list-cell-form.html
new file mode 100644
index 0000000..35eebeb
--- /dev/null
+++ b/combo/data/templates/combo/manager/link-list-cell-form.html
@@ -0,0 +1,27 @@
+{% extends "combo/cell_form.html" %}
+{% load i18n %}
+
+{% block cell-form %}
+{% with cell.get_items as links %}
+{% if links %}
+<table class="main">
+  <tbody class="link-list" data-link-list-order-url="{% url 'combo-manager-link-list-order' page_pk=page.pk cell_reference=cell.get_reference %}">
+    {% for link in links %}
+    <tr class="link-item" data-link-item-id="{{ link.pk }}">
+      <td><span class="handle-link-item">⣿</span> {{ link }}</td>
+      <td>
+        <a rel="popup" href="{% url 'combo-manager-page-list-cell-edit-link' page_pk=page.id cell_reference=cell.get_reference link_cell_reference=link.get_reference %}">{% trans "Edit" %}</a> |
+        <a rel="popup" href="{% url 'combo-manager-page-list-cell-delete-link' page_pk=page.id cell_reference=cell.get_reference link_cell_reference=link.get_reference %}">{% trans "Delete" %}</a>
+      </td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+{% endif %}
+{% endwith %}
+<div class="buttons">
+  {% for klass in cell.get_link_cell_classes %}
+  <a rel="popup" href="{% url 'combo-manager-page-list-cell-add-link' page_pk=page.id cell_reference=cell.get_reference link_code=klass.add_as_link_code %}">{{ klass.add_as_link_label }}</a> {% if not forloop.last %}|{% endif %}
+  {% endfor %}
+</div>
+{% endblock %}
diff --git a/combo/manager/static/js/combo.manager.js b/combo/manager/static/js/combo.manager.js
index f074e8d..fe7b169 100644
--- a/combo/manager/static/js/combo.manager.js
+++ b/combo/manager/static/js/combo.manager.js
@@ -220,6 +220,21 @@ $(function() {
      return false;
   });
 
+  $('.link-list').sortable({
+     handle: '.handle-link-item',
+     stop: function(event, ui) {
+       var new_order = Object();
+       $('.link-item').each(function(i, x) {
+           var suffix = $(x).data('link-item-id');
+           new_order['pos_' + suffix] = i;
+       });
+       $.ajax({
+           url: $(this).data('link-list-order-url'),
+           data: new_order
+       });
+     }
+  });
+
   function compute_max_height($cell) {
     var cell_id = $cell.attr('id');
     $('style#for-' + cell_id).remove();
diff --git a/combo/manager/templates/combo/link_cell_form.html b/combo/manager/templates/combo/link_cell_form.html
new file mode 100644
index 0000000..c702251
--- /dev/null
+++ b/combo/manager/templates/combo/link_cell_form.html
@@ -0,0 +1,22 @@
+{% extends "combo/manager_base.html" %}
+{% load i18n %}
+
+{% block appbar %}
+{% if form.instance.pk %}
+<h2>{{ form.instance.edit_link_label }}</h2>
+{% else %}
+<h2>{{ form.instance.add_link_label }}</h2>
+{% endif %}
+{% endblock %}
+
+{% block content %}
+
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans "Save" %}</button>
+    <a class="cancel" href="{% url 'combo-manager-page-view' pk=form.instance.page_id %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/combo/manager/urls.py b/combo/manager/urls.py
index 66d34ee..a3f55e4 100644
--- a/combo/manager/urls.py
+++ b/combo/manager/urls.py
@@ -75,6 +75,18 @@ urlpatterns = [
             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,
+            name='combo-manager-page-list-cell-add-link'),
+        url(r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/link/(?P<link_cell_reference>[\w_-]+)/$',
+            views.page_list_cell_edit_link,
+            name='combo-manager-page-list-cell-edit-link'),
+        url(r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/link/(?P<link_cell_reference>[\w_-]+)/delete$',
+            views.page_list_cell_delete_link,
+            name='combo-manager-page-list-cell-delete-link'),
+        url(r'^pages/(?P<page_pk>\d+)/cell/(?P<cell_reference>[\w_-]+)/order$',
+            views.link_list_order,
+            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/order$', views.page_order,
diff --git a/combo/manager/views.py b/combo/manager/views.py
index 5992fa2..2eec499 100644
--- a/combo/manager/views.py
+++ b/combo/manager/views.py
@@ -33,7 +33,7 @@ from django.views.decorators.csrf import requires_csrf_token
 from django.views.generic import (RedirectView, DetailView,
         CreateView, UpdateView, ListView, DeleteView, FormView)
 
-from combo.data.models import Page, CellBase, ParentContentCell, PageSnapshot
+from combo.data.models import Page, CellBase, ParentContentCell, PageSnapshot, LinkListCell
 from combo.data.library import get_cell_class
 from combo.data.utils import export_site, import_site, MissingGroups
 from combo import plugins
@@ -124,7 +124,6 @@ class PageAddChildView(PageAddView):
         return super(CreateView, self).get_initial()
 
     def dispatch(self, request, *args, **kwargs):
-        print(request.POST)
         self.parent = get_object_or_404(Page, pk=kwargs['pk'])
         return super(PageAddChildView, self).dispatch(request, *args, **kwargs)
 
@@ -564,3 +563,143 @@ def menu_json(request):
     response = HttpResponse(content_type=content_type)
     response.write(json_str)
     return response
+
+
+class PageListCellAddLinkView(CreateView):
+    template_name = 'combo/link_cell_form.html'
+
+    def dispatch(self, request, *args, **kwargs):
+        try:
+            self.cell = CellBase.get_cell(kwargs['cell_reference'], page=kwargs['page_pk'])
+        except LinkListCell.DoesNotExist:
+            raise Http404
+        for klass in self.cell.get_link_cell_classes():
+            if klass.add_as_link_code == kwargs['link_code']:
+                self.model = klass
+                break
+        if self.model is None:
+            raise Http404
+        return super(PageListCellAddLinkView, self).dispatch(request, *args, **kwargs)
+
+    def get_form_class(self):
+        return self.model().get_default_form_class()
+
+    def get_form_kwargs(self):
+        kwargs = super(PageListCellAddLinkView, self).get_form_kwargs()
+        kwargs['instance'] = self.model(page=self.cell.page, placeholder=self.cell.link_placeholder)
+        return kwargs
+
+    def form_valid(self, form):
+        orders = [x.order for x in self.cell.get_items()]
+        if orders:
+            form.instance.order = max(orders)+1
+        else:
+            form.instance.order = 1
+        return super(PageListCellAddLinkView, self).form_valid(form)
+
+    def get_success_url(self):
+        return '%s#cell-%s' % (
+            reverse('combo-manager-page-view', kwargs={'pk': self.kwargs.get('page_pk')}),
+            self.kwargs['cell_reference'])
+
+
+page_list_cell_add_link = PageListCellAddLinkView.as_view()
+
+
+class PageListCellEditLinkView(UpdateView):
+    template_name = 'combo/link_cell_form.html'
+
+    def dispatch(self, request, *args, **kwargs):
+        try:
+            self.cell = CellBase.get_cell(kwargs['cell_reference'], page=kwargs['page_pk'])
+        except LinkListCell.DoesNotExist:
+            raise Http404
+        try:
+            self.object = CellBase.get_cell(kwargs['link_cell_reference'], page=kwargs['page_pk'])
+        except ObjectDoesNotExist:
+            raise Http404
+        if self.object.placeholder != self.cell.link_placeholder:
+            raise Http404
+        self.model = self.object.__class__
+        return super(PageListCellEditLinkView, self).dispatch(request, *args, **kwargs)
+
+    def get_object(self, *args, **kwargs):
+        return self.object
+
+    def get_form_class(self):
+        return self.model().get_default_form_class()
+
+    def form_valid(self, form):
+        if self.request.is_ajax():
+            self.object = form.save()
+            response = self.form_invalid(form)  # avoid redirection
+        else:
+            response = super(PageListCellEditLinkView, self).form_valid(form)
+        PageSnapshot.take(self.cell.page, request=self.request, comment=_('changed cell "%s"') % self.cell)
+        return response
+
+    def get_success_url(self):
+        return '%s#cell-%s' % (
+            reverse('combo-manager-page-view', kwargs={'pk': self.kwargs.get('page_pk')}),
+            self.kwargs['cell_reference'])
+
+
+page_list_cell_edit_link = PageListCellEditLinkView.as_view()
+
+
+class PageListCellDeleteLinkView(DeleteView):
+    template_name = 'combo/generic_confirm_delete.html'
+
+    def dispatch(self, request, *args, **kwargs):
+        try:
+            self.cell = CellBase.get_cell(kwargs['cell_reference'], page=kwargs['page_pk'])
+        except LinkListCell.DoesNotExist:
+            raise Http404
+        try:
+            self.object = CellBase.get_cell(kwargs['link_cell_reference'], page=kwargs['page_pk'])
+        except ObjectDoesNotExist:
+            raise Http404
+        if self.object.placeholder != self.cell.link_placeholder:
+            raise Http404
+        self.model = self.object.__class__
+        return super(PageListCellDeleteLinkView, self).dispatch(request, *args, **kwargs)
+
+    def get_object(self, *args, **kwargs):
+        return self.object
+
+    def delete(self, request, *args, **kwargs):
+        response = super(PageListCellDeleteLinkView, self).delete(request, *args, **kwargs)
+        PageSnapshot.take(self.cell.page, request=self.request, comment=_('changed cell "%s"') % self.cell)
+        return response
+
+    def get_success_url(self):
+        return '%s#cell-%s' % (
+            reverse('combo-manager-page-view', kwargs={'pk': self.kwargs.get('page_pk')}),
+            self.kwargs['cell_reference'])
+
+
+page_list_cell_delete_link = PageListCellDeleteLinkView.as_view()
+
+
+def link_list_order(request, page_pk, cell_reference):
+    try:
+        cell = CellBase.get_cell(cell_reference, page=page_pk)
+    except LinkListCell.DoesNotExist:
+        raise Http404
+
+    has_changes = False
+    for link in cell.get_items():
+        old_order = link.order
+        try:
+            new_order = int(request.GET.get('pos_' + str(link.pk)))
+        except TypeError:
+            continue
+        if new_order != old_order:
+            link.order = new_order
+            has_changes = True
+            link.save(update_fields=['order'])
+
+    if has_changes:
+        PageSnapshot.take(cell.page, request=request, comment=_('reordered cells'))
+
+    return HttpResponse(status=204)
diff --git a/combo/public/templates/combo/link-list-cell.html b/combo/public/templates/combo/link-list-cell.html
new file mode 100644
index 0000000..696f228
--- /dev/null
+++ b/combo/public/templates/combo/link-list-cell.html
@@ -0,0 +1,11 @@
+{% block cell-content %}
+{% spaceless %}
+{% if links %}
+<ul>
+{% for link in links %}
+  <li><a href="{{ link.url }}">{{ link.title }}</a></li>
+{% endfor %}
+</ul>
+{% endif %}
+{% endspaceless %}
+{% endblock %}
diff --git a/tests/test_cells.py b/tests/test_cells.py
index 89ddb09..a5e2f02 100644
--- a/tests/test_cells.py
+++ b/tests/test_cells.py
@@ -4,7 +4,10 @@ import os
 import pytest
 import requests
 
-from combo.data.models import Page, CellBase, TextCell, LinkCell, MenuCell, JsonCellBase, JsonCell, ConfigJsonCell
+from combo.data.models import (
+    Page, CellBase, TextCell, LinkCell, MenuCell, JsonCellBase,
+    JsonCell, ConfigJsonCell, LinkListCell
+)
 from django.conf import settings
 from django.db import connection
 from django.forms.widgets import Media
@@ -93,6 +96,7 @@ def test_text_cell_variadic_url():
     assert 'href=""' in cell.render(ctx)
     assert 'href="/plop"' in cell.render(ctx)
 
+
 def test_link_cell():
     page = Page(title='example page', slug='example-page')
     page.save()
@@ -130,6 +134,42 @@ def test_link_cell():
     assert cell.render(ctx).strip() == '<a href="http://example.net/#anchor">altertitle</a>'
 
 
+def test_link_list_cell():
+    page = Page.objects.create(title='example page', slug='example-page')
+
+    cell = LinkListCell.objects.create(order=0, page=page)
+    item = LinkCell.objects.create(
+        page=page,
+        placeholder=cell.link_placeholder,
+        title='Example Site',
+        url='http://example.net/',
+        order=0,
+    )
+
+    ctx = {}
+    assert cell.render(ctx).strip() == '<ul><li><a href="http://example.net/">Example Site</a></li></ul>'
+
+    item.title = ''
+    item.save()
+    assert cell.render(ctx).strip() == '<ul><li><a href="http://example.net/">http://example.net/</a></li></ul>'
+
+    item.link_page = page
+    item.save()
+    assert cell.render(ctx).strip() == '<ul><li><a href="/example-page/">example page</a></li></ul>'
+
+    item.title = 'altertitle'
+    item.save()
+    assert cell.render(ctx).strip() == '<ul><li><a href="/example-page/">altertitle</a></li></ul>'
+
+    item.anchor = 'anchor'
+    item.save()
+    assert cell.render(ctx).strip() == '<ul><li><a href="/example-page/#anchor">altertitle</a></li></ul>'
+
+    item.link_page = None
+    item.save()
+    assert cell.render(ctx).strip() == '<ul><li><a href="http://example.net/#anchor">altertitle</a></li></ul>'
+
+
 def test_menu_cell():
     Page.objects.all().delete()
     parent = page = Page(title='Page1', slug='page1', template_name='standard')
@@ -728,3 +768,27 @@ def test_related_cell_types_tracking():
 
     TextCell(page=page, placeholder='content', order=0, text='hello').save()
     assert set(Page.objects.get(id=page.id).related_cells['cell_types']) == set(['data_textcell', 'data_linkcell'])
+
+
+def test_link_list_cell_duplicate():
+    page = Page.objects.create(title='xxx', slug='new', template_name='standard')
+    cell = LinkListCell.objects.create(order=0, page=page)
+    item = LinkCell.objects.create(
+        page=page,
+        placeholder=cell.link_placeholder,
+        title='Example Site',
+        url='http://example.net/',
+        link_page=page,
+        order=1,
+    )
+
+    new_cell = cell.duplicate()
+    assert LinkCell.objects.count() == 2
+    assert len(new_cell.get_items()) == 1
+    new_item = new_cell.get_items()[0]
+    assert new_item.page == page
+    assert new_item.placeholder == new_cell.link_placeholder
+    assert new_item.pk != item.pk
+    assert new_item.title == item.title
+    assert new_item.url == item.url
+    assert new_item.link_page == item.link_page
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 0b9b7ae..58ad4db 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -21,7 +21,7 @@ from webtest import TestApp
 from webtest import Upload
 
 from combo.wsgi import application
-from combo.data.models import Page, CellBase, TextCell, LinkCell, ConfigJsonCell, JsonCell, PageSnapshot
+from combo.data.models import Page, CellBase, TextCell, LinkCell, ConfigJsonCell, JsonCell, PageSnapshot, LinkListCell
 from combo.apps.assets.models import Asset
 from combo.apps.family.models import FamilyInfosCell
 from combo.apps.search.models import SearchCell
@@ -1373,3 +1373,73 @@ def test_json_cell_syntax_validation(app, admin_user):
     assert 'syntax error' not in resp.text
     assert JsonCell.objects.count() == 1
     assert JsonCell.objects.first().template_string == '{{ ok }}'
+
+
+def test_add_edit_delete_list_link_item(app, admin_user):
+    Page.objects.all().delete()
+    page = Page.objects.create(title='One', slug='one', template_name='standard')
+    cell = LinkListCell.objects.create(order=0, placeholder='content', page=page)
+    app = login(app)
+    resp = app.get('/manage/pages/%s/' % page.pk)
+
+    resp = resp.click(href='.*/add-link/link$')
+    resp.forms[0]['title'] = 'Hello world'
+    resp.forms[0]['url'] = 'http://example.com'
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
+    assert LinkCell.objects.count() == 1
+    item = LinkCell.objects.get()
+    assert item.title == 'Hello world'
+    assert item.url == 'http://example.com'
+    assert item.page == page
+    assert item.placeholder == cell.link_placeholder
+
+    resp = resp.follow()
+    resp = resp.click(href='.*/link/%s/$' % item.get_reference())
+    resp.forms[0]['title'] = 'Hello world 2'
+    resp.forms[0]['url'] = 'http://example2.com'
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
+    assert LinkCell.objects.count() == 1
+    item.refresh_from_db()
+    assert item.title == 'Hello world 2'
+    assert item.url == 'http://example2.com'
+
+    resp = resp.follow()
+    resp = resp.click(href='.*/link/%s/delete' % item.get_reference())
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
+    assert LinkCell.objects.count() == 0
+
+
+def test_edit_link_list_order(app, admin_user):
+    Page.objects.all().delete()
+    page = Page.objects.create(title='One', slug='one', template_name='standard')
+    cell = LinkListCell.objects.create(order=0, page=page)
+    items = []
+    for i in range(5):
+        items.append(
+            LinkCell.objects.create(
+                page=page,
+                placeholder=cell.link_placeholder,
+                title='Foo %s' % i,
+                url='http://example.net/',
+                link_page=page,
+                order=i+1,
+            )
+        )
+
+    params = []
+    new_order = [2, 3, 1, 4, 5]
+    for i, (item, new_pos) in enumerate(zip(items, new_order)):
+        params.append(('pos_%s' % item.pk, str(new_pos)))
+
+    app = login(app)
+    resp = app.get('/manage/pages/%s/cell/%s/order?%s' % (page.pk, cell.get_reference(), urlencode(params)))
+    assert resp.status_code == 204
+    for i, item in enumerate(items):
+        item.refresh_from_db()
+        assert item.order == new_order[i]
diff --git a/tests/test_pages.py b/tests/test_pages.py
index 5f45670..2eb108f 100644
--- a/tests/test_pages.py
+++ b/tests/test_pages.py
@@ -9,7 +9,7 @@ from django.test import override_settings
 from django.test.client import RequestFactory
 from django.utils.six import StringIO
 from django.utils.timezone import now
-from combo.data.models import Page, PageSnapshot, CellBase, TextCell, LinkCell
+from combo.data.models import Page, PageSnapshot, CellBase, TextCell, LinkCell, LinkListCell
 from combo.data.management.commands.import_site import Command as ImportSiteCommand
 from combo.data.management.commands.export_site import Command as ExportSiteCommand
 from combo.manager.forms import PageVisibilityForm
@@ -182,6 +182,22 @@ def test_import_export_pages_with_links():
     cell2 = LinkCell(page=page2, title='foo', placeholder='content', link_page=page, order=1)
     cell2.save()
 
+    cell3 = LinkListCell.objects.create(page=page, placeholder='content', order=2)
+    item1 = LinkCell.objects.create(
+        page=page,
+        placeholder=cell3.link_placeholder,
+        title='Example Site',
+        url='http://example.net/',
+        order=0,
+    )
+    item2 = LinkCell.objects.create(
+        page=page,
+        placeholder=cell3.link_placeholder,
+        title='blah',
+        link_page=page2,
+        order=1,
+    )
+
     site_export = [x.get_serialized_page() for x in Page.objects.all()]
     Page.objects.all().delete()
 
@@ -189,8 +205,21 @@ def test_import_export_pages_with_links():
 
     new_page_1 = Page.objects.all().order_by('order')[0]
     new_page_2 = Page.objects.all().order_by('order')[1]
-    assert CellBase.get_cells(page_id=new_page_1.id)[0].link_page_id == new_page_2.id
-    assert CellBase.get_cells(page_id=new_page_2.id)[0].link_page_id == new_page_1.id
+    new_cells_1 = CellBase.get_cells(page_id=new_page_1.id, placeholder='content')
+    new_cells_2 = CellBase.get_cells(page_id=new_page_2.id, placeholder='content')
+    assert new_cells_1[0].link_page_id == new_page_2.id
+    assert new_cells_2[0].link_page_id == new_page_1.id
+    assert len(new_cells_1[1].get_items()) == 2
+    new_item_1 = new_cells_1[1].get_items()[0]
+    assert isinstance(new_item_1, LinkCell)
+    assert new_item_1.title == item1.title
+    assert new_item_1.url == item1.url
+    assert new_item_1.link_page is None
+    new_item_2 = new_cells_1[1].get_items()[1]
+    assert isinstance(new_item_2, LinkCell)
+    assert new_item_2.title == item2.title
+    assert new_item_2.url == ''
+    assert new_item_2.link_page == new_page_2
 
 
 def test_duplicate_page():
diff --git a/tests/test_wcs.py b/tests/test_wcs.py
index ef556e6..b270d23 100644
--- a/tests/test_wcs.py
+++ b/tests/test_wcs.py
@@ -22,7 +22,7 @@ from django.utils.six.moves.urllib import parse as urlparse
 
 import mock
 
-from combo.data.models import Page
+from combo.data.models import CellBase, LinkListCell, Page
 from combo.apps.search.engines import engines
 from combo.apps.wcs.models import (WcsFormCell, WcsCurrentFormsCell,
         WcsFormsOfCategoryCell, WcsCurrentDraftsCell, WcsCategoryCell,
@@ -901,3 +901,93 @@ def test_backoffice_submission_cell_render(context):
 
     result = cell.render(context)
     assert '/backoffice/submission/a-private-form/' in result
+
+
+@wcs_present
+def test_manager_link_list_cell_duplicate():
+    page = Page.objects.create(title='xxx', slug='new', template_name='standard')
+    cell = LinkListCell.objects.create(order=0, page=page)
+    item = WcsFormCell.objects.create(
+        page=page,
+        placeholder=cell.link_placeholder,
+        cached_title='A title',
+        cached_url='http://example.com',
+        cached_json={'foo': 'bar'},
+        order=1)
+
+    new_cell = cell.duplicate()
+    assert WcsFormCell.objects.count() == 2
+    assert len(new_cell.get_items()) == 1
+    new_item = new_cell.get_items()[0]
+    assert new_item.page == page
+    assert new_item.placeholder == new_cell.link_placeholder
+    assert new_item.pk != item.pk
+    assert new_item.cached_title == item.cached_title
+    assert new_item.cached_url == item.cached_url
+    assert new_item.cached_json == item.cached_json
+
+
+@wcs_present
+def test_manager_add_edit_delete_list_link_item(app, admin_user):
+    Page.objects.all().delete()
+    page = Page.objects.create(title='One', slug='one', template_name='standard')
+    cell = LinkListCell.objects.create(order=0, placeholder='content', page=page)
+    app = login(app)
+    resp = app.get('/manage/pages/%s/' % page.pk)
+
+    resp = resp.click(href='.*/add-link/form-link$')
+    resp.forms[0]['formdef_reference'] = 'default:form-title'
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
+    assert WcsFormCell.objects.count() == 1
+    item = WcsFormCell.objects.get()
+    assert item.formdef_reference == 'default:form-title'
+    assert item.page == page
+    assert item.placeholder == cell.link_placeholder
+
+    resp = resp.follow()
+    resp = resp.click(href='.*/link/%s/$' % item.get_reference())
+    resp.forms[0]['formdef_reference'] = 'default:a-private-form'
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
+    assert WcsFormCell.objects.count() == 1
+    item.refresh_from_db()
+    assert item.formdef_reference == 'default:a-private-form'
+
+    resp = resp.follow()
+    resp = resp.click(href='.*/link/%s/delete' % item.get_reference())
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
+    assert WcsFormCell.objects.count() == 0
+
+
+def test_import_export_pages_with_links():
+    page = Page(title=u'bar', slug='bar', order=1)
+    page.save()
+
+    cell = LinkListCell.objects.create(order=0, placeholder='content', page=page)
+    item = WcsFormCell.objects.create(
+        page=page,
+        placeholder=cell.link_placeholder,
+        cached_title='A title',
+        cached_url='http://example.com',
+        cached_json={'foo': 'bar'},
+        order=0,
+    )
+
+    site_export = [x.get_serialized_page() for x in Page.objects.all()]
+    Page.objects.all().delete()
+
+    Page.load_serialized_pages(site_export)
+
+    new_page = Page.objects.get()
+    new_cells = CellBase.get_cells(page_id=new_page.id, placeholder='content')
+    assert len(new_cells[0].get_items()) == 1
+    new_item = new_cells[0].get_items()[0]
+    assert isinstance(new_item, WcsFormCell)
+    assert new_item.cached_title == item.cached_title
+    assert new_item.cached_url == item.cached_url
+    assert new_item.cached_json == item.cached_json
-- 
2.20.1

