Browse Source

Add ability to create anchor link tab within rich text link

- add tests

- Update changelog & release notes
Iman Syed 5 years ago
parent
commit
794d40b86b

+ 1 - 0
CHANGELOG.txt

@@ -7,6 +7,7 @@ Changelog
  * Added `construct_page_listing_buttons` hook (Michael van Tellingen)
  * Added more detailed documentation and troubleshooting for installing OpenCV for feature detection (Daniele Procida)
  * Move and refactor upgrade notification JS (Jonny Scholes)
+ * Add ability to insert internal anchor links/links with fragment identifiers in Draftail (rich text) fields (Iman Syed)
  * Remove need for Elasticsearch `update_all_types` workaround, upgrade minimum release to 6.4.0 or above (Jonathan Liuti)
  * Fix: Added line breaks to long filenames on multiple image / document uploader (Kevin Howbrook)
  * Fix: Added https support for Scribd oEmbed provider (Rodrigo)

+ 4 - 1
client/src/components/Draftail/decorators/Link.js

@@ -14,7 +14,7 @@ const MAIL_ICON = <Icon name="mail" />;
 const getEmailAddress = mailto => mailto.replace('mailto:', '').split('?')[0];
 const getDomainName = url => url.replace(/(^\w+:|^)\/\//, '').split('/')[0];
 
-// Determines how to display the link based on its type: page, mail, or external.
+// Determines how to display the link based on its type: page, mail, anchor or external.
 export const getLinkAttributes = (data) => {
   const url = data.url || null;
   let icon;
@@ -29,6 +29,9 @@ export const getLinkAttributes = (data) => {
   } else if (url.startsWith('mailto:')) {
     icon = MAIL_ICON;
     label = getEmailAddress(url);
+  } else if (url.startsWith('#')) {
+    icon = LINK_ICON;
+    label = url;
   } else {
     icon = LINK_ICON;
     label = getDomainName(url);

+ 7 - 0
client/src/components/Draftail/decorators/Link.test.js

@@ -56,6 +56,13 @@ describe('Link', () => {
       });
     });
 
+    it('anchor', () => {
+      expect(getLinkAttributes({ url: '#testanchor' })).toMatchObject({
+        url: '#testanchor',
+        label: '#testanchor',
+      });
+    });
+
     it('external', () => {
       expect(getLinkAttributes({ url: 'http://www.ex.com/' })).toMatchObject({
         url: 'http://www.ex.com/',

+ 4 - 0
client/src/components/Draftail/sources/ModalWorkflowSource.js

@@ -42,6 +42,7 @@ export const getChooserConfig = (entityType, entity, selectedText) => {
       page_type: 'wagtailcore.page',
       allow_external_link: true,
       allow_email_link: true,
+      allow_anchor_link: true,
       link_text: selectedText,
     };
 
@@ -57,6 +58,9 @@ export const getChooserConfig = (entityType, entity, selectedText) => {
       } else if (data.url.startsWith('mailto:')) {
         url = global.chooserUrls.emailLinkChooser;
         urlParams.link_url = data.url.replace('mailto:', '');
+      } else if (data.url.startsWith('#')) {
+        url = global.chooserUrls.anchorLinkChooser;
+        urlParams.link_url = data.url.replace('#', '');
       } else {
         url = global.chooserUrls.externalLinkChooser;
         urlParams.link_url = data.url;

+ 8 - 0
client/src/components/Draftail/sources/ModalWorkflowSource.test.js

@@ -144,6 +144,14 @@ describe('ModalWorkflowSource', () => {
         })).toMatchSnapshot();
       });
 
+      it('anchor', () => {
+        expect(filterEntityData({ type: 'LINK' }, {
+          prefer_this_title_as_link_text: false,
+          title: 'testanchor',
+          url: '#testanchor',
+        })).toMatchSnapshot();
+      });
+
       it('external', () => {
         expect(filterEntityData({ type: 'LINK' }, {
           prefer_this_title_as_link_text: false,

+ 11 - 0
client/src/components/Draftail/sources/__snapshots__/ModalWorkflowSource.test.js.snap

@@ -28,6 +28,12 @@ Object {
 }
 `;
 
+exports[`ModalWorkflowSource #filterEntityData LINK anchor 1`] = `
+Object {
+  "url": "#testanchor",
+}
+`;
+
 exports[`ModalWorkflowSource #filterEntityData LINK external 1`] = `
 Object {
   "url": "https://www.example.com/",
@@ -55,6 +61,7 @@ Object {
   },
   "url": "/admin/choose-external-link/",
   "urlParams": Object {
+    "allow_anchor_link": true,
     "allow_email_link": true,
     "allow_external_link": true,
     "link_text": "",
@@ -71,6 +78,7 @@ Object {
   },
   "url": "/admin/choose-email-link/",
   "urlParams": Object {
+    "allow_anchor_link": true,
     "allow_email_link": true,
     "allow_external_link": true,
     "link_text": "",
@@ -87,6 +95,7 @@ Object {
   },
   "url": "/admin/choose-page/",
   "urlParams": Object {
+    "allow_anchor_link": true,
     "allow_email_link": true,
     "allow_external_link": true,
     "link_text": "",
@@ -102,6 +111,7 @@ Object {
   },
   "url": "/admin/choose-page/1/",
   "urlParams": Object {
+    "allow_anchor_link": true,
     "allow_email_link": true,
     "allow_external_link": true,
     "link_text": "",
@@ -117,6 +127,7 @@ Object {
   },
   "url": "/admin/choose-page/",
   "urlParams": Object {
+    "allow_anchor_link": true,
     "allow_email_link": true,
     "allow_external_link": true,
     "link_text": "",

+ 1 - 0
client/tests/stubs.js

@@ -55,6 +55,7 @@ global.wagtail = {};
 global.chooserUrls = {
   documentChooser: '/admin/documents/chooser/',
   emailLinkChooser: '/admin/choose-email-link/',
+  anchorLinkChooser: '/admin/choose-anchor-link',
   embedsChooser: '/admin/embeds/chooser/',
   externalLinkChooser: '/admin/choose-external-link/',
   imageChooser: '/admin/images/chooser/',

+ 1 - 0
docs/releases/2.7.rst

@@ -22,6 +22,7 @@ Other features
  * Added more detailed documentation and troubleshooting for installing OpenCV for feature detection (Daniele Procida)
  * Move and refactor upgrade notification JS (Jonny Scholes)
  * Remove need for Elasticsearch ``update_all_types`` workaround, upgrade minimum release to 6.4.0 or above (Jonathan Liuti)
+ * Add ability to insert internal anchor links/links with fragment identifiers in Draftail (rich text) fields (Iman Syed)
 
 
 Bug fixes

+ 6 - 1
wagtail/admin/forms/choosers.py

@@ -27,7 +27,12 @@ class URLOrAbsolutePathField(forms.URLField):
 
 
 class ExternalLinkChooserForm(forms.Form):
-    url = URLOrAbsolutePathField(required=True, label=ugettext_lazy("URL"))
+    url = URLOrAbsolutePathField(required=True, label=ugettext_lazy(""))
+    link_text = forms.CharField(required=False)
+
+
+class AnchorLinkChooserForm(forms.Form):
+    url = forms.CharField(required=True, label=ugettext_lazy("#"))
     link_text = forms.CharField(required=False)
 
 

+ 2 - 2
wagtail/admin/rich_text/converters/html_to_contentstate.py

@@ -315,7 +315,8 @@ class HtmlToContentStateHandler(HTMLParser):
             element_handler.handle_endtag(name, self.state, self.contentstate)
 
     def handle_data(self, content):
-        # normalise whitespace sequences to a single space
+        # normalise whitespace sequences to a single space unless whitespace is contained in <pre> tag,
+        # in which case, leave it alone
         # This is in line with https://www.w3.org/TR/html4/struct/text.html#h-9.1
         content = re.sub(WHITESPACE_RE, ' ', content)
 
@@ -341,7 +342,6 @@ class HtmlToContentStateHandler(HTMLParser):
                 content = content.lstrip()
             elif self.state.leading_whitespace == FORCE_WHITESPACE and not content.startswith(' '):
                 content = ' ' + content
-
             if content.endswith(' '):
                 # don't output trailing whitespace yet, because we want to discard it if the end
                 # of the block follows. Instead, we'll set leading_whitespace = force so that

+ 6 - 1
wagtail/admin/static_src/wagtailadmin/js/hallo-plugins/hallo-wagtaillink.js

@@ -37,7 +37,8 @@
                     url = window.chooserUrls.pageChooser;
                     urlParams = {
                         'allow_external_link': true,
-                        'allow_email_link': true
+                        'allow_email_link': true,
+                        'allow_anchor_link': true,
                     };
 
                     enclosingLink = getEnclosingLink();
@@ -56,6 +57,10 @@
                             url = window.chooserUrls.emailLinkChooser;
                             href = href.replace('mailto:', '');
                             urlParams['link_url'] = href;
+                        } else if (href.startsWith('#')) {
+                            url = window.chooserUrls.anchorLinkChooser;
+                            href = href.replace('#', '');
+                            urlParams['link_url'] = href;
                         } else if (!linkType) {  /* external link */
                             url = window.chooserUrls.externalLinkChooser;
                             urlParams['link_url'] = href;

+ 13 - 1
wagtail/admin/static_src/wagtailadmin/js/page-chooser-modal.js

@@ -110,6 +110,18 @@ PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
         */
         $('#id_q', modal.body).trigger('focus');
     },
+
+    'anchor_link': function(modal, jsonData) {
+        $('p.link-types a', modal.body).on('click', function() {
+            modal.loadUrl(this.href);
+            return false;
+        });
+
+        $('form', modal.body).on('submit', function() {
+            modal.postForm(this.action, $(this).serialize());
+            return false;
+        });
+    },
     'email_link': function(modal, jsonData) {
         $('p.link-types a', modal.body).on('click', function() {
             modal.loadUrl(this.href);
@@ -135,5 +147,5 @@ PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
     'external_link_chosen': function(modal, jsonData) {
         modal.respond('pageChosen', jsonData['result']);
         modal.close();
-    }
+    },
 };

+ 7 - 1
wagtail/admin/templates/wagtailadmin/chooser/_link_types.html

@@ -1,5 +1,5 @@
 {% load i18n wagtailadmin_tags %}
-{% if allow_external_link or allow_email_link or current == 'external' or current == 'email' %}
+{% if allow_external_link or allow_email_link or allow_anchor_link or current == 'external' or current == 'email' or current == 'anchor' %}
     <p class="link-types">
         {% if current == 'internal' %}
             <b>{% trans "Internal link" %}</b>
@@ -22,5 +22,11 @@
         {% elif allow_email_link %}
             | <a href="{% url 'wagtailadmin_choose_page_email_link' %}{% querystring p=None parent_page_id=parent_page_id %}">{% trans "Email link" %}</a>
         {% endif %}
+
+        {% if current == 'anchor' %}
+            | <b>{% trans "Anchor link" %}</b>
+        {% elif allow_anchor_link %}
+            | <a href="{% url 'wagtailadmin_choose_page_anchor_link' %}{% querystring p=None parent_page_id=parent_page_id %}">{% trans "Anchor link" %}</a>
+        {% endif %}
     </p>
 {% endif %}

+ 17 - 0
wagtail/admin/templates/wagtailadmin/chooser/anchor_link.html

@@ -0,0 +1,17 @@
+{% load i18n wagtailadmin_tags %}
+{% trans "Add an anchor link" as anchor_str %}
+{% include "wagtailadmin/shared/header.html" with title=anchor_str %}
+
+<div class="nice-padding">
+    {% include 'wagtailadmin/chooser/_link_types.html' with current='anchor' %}
+
+    <form action="{% url 'wagtailadmin_choose_page_anchor_link' %}{% querystring %}" method="post" novalidate>
+        {% csrf_token %}
+        <ul class="fields">
+            {% for field in form %}
+                {% include "wagtailadmin/shared/field_as_li.html" %}
+            {% endfor %}
+            <li><input type="submit" value="{% trans 'Insert anchor' %}"  class="button" /></li>
+        </ul>
+    </form>
+</div>

+ 2 - 1
wagtail/admin/templates/wagtailadmin/pages/_editor_js.html

@@ -9,7 +9,8 @@
     window.chooserUrls = {
         'pageChooser': '{% url "wagtailadmin_choose_page" %}',
         'externalLinkChooser': '{% url "wagtailadmin_choose_page_external_link" %}',
-        'emailLinkChooser': '{% url "wagtailadmin_choose_page_email_link" %}'
+        'emailLinkChooser': '{% url "wagtailadmin_choose_page_email_link" %}',
+        'anchorLinkChooser': '{% url "wagtailadmin_choose_page_anchor_link" %}',
     };
     window.unicodeSlugsEnabled = {% if unicode_slugs_enabled %}true{% else %}false{% endif %};
 </script>

+ 60 - 0
wagtail/admin/tests/test_page_chooser.py

@@ -572,6 +572,66 @@ class TestChooserExternalLink(TestCase, WagtailTestUtils):
         self.assertEqual(response_json['result']['title'], "admin")
 
 
+class TestChooserAnchorLink(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.login()
+
+    def get(self, params={}):
+        return self.client.get(reverse('wagtailadmin_choose_page_anchor_link'), params)
+
+    def post(self, post_data={}, url_params={}):
+        url = reverse('wagtailadmin_choose_page_anchor_link')
+        if url_params:
+            url += '?' + urlencode(url_params)
+        return self.client.post(url, post_data)
+
+    def test_simple(self):
+        response = self.get()
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'wagtailadmin/chooser/anchor_link.html')
+
+    def test_prepopulated_form(self):
+        response = self.get({'link_text': 'Example Anchor Text', 'link_url': 'exampleanchor'})
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, 'Example Anchor Text')
+        self.assertContains(response, 'exampleanchor')
+
+    def test_create_link(self):
+        response = self.post({'anchor-link-chooser-url': 'exampleanchor', 'anchor-link-chooser-link_text': 'Example Anchor Text'})
+        result = json.loads(response.content.decode())['result']
+        self.assertEqual(result['url'], "#exampleanchor")
+        self.assertEqual(result['title'], "Example Anchor Text")  # When link text is given, it is used
+        self.assertEqual(result['prefer_this_title_as_link_text'], True)
+
+    def test_create_link_without_text(self):
+        response = self.post({'anchor-link-chooser-url': 'exampleanchor'})
+        result = json.loads(response.content.decode())['result']
+        self.assertEqual(result['url'], "#exampleanchor")
+        self.assertEqual(result['title'], "exampleanchor")  # When no link text is given, it uses anchor
+        self.assertEqual(result['prefer_this_title_as_link_text'], False)
+
+    def test_notice_changes_to_link_text(self):
+        response = self.post(
+            {'anchor-link-chooser-url': 'exampleanchor2', 'email-link-chooser-link_text': 'Example Text'},  # POST data
+            {'link_url': 'exampleanchor2', 'link_text': 'Example Text'}  # GET params - initial data
+        )
+        result = json.loads(response.content.decode())['result']
+        self.assertEqual(result['url'], "#exampleanchor2")
+        self.assertEqual(result['title'], "exampleanchor2")
+        # no change to link text, so prefer the existing link/selection content where available
+        self.assertEqual(result['prefer_this_title_as_link_text'], True)
+
+        response = self.post(
+            {'anchor-link-chooser-url': 'exampleanchor2', 'anchor-link-chooser-link_text': 'Example Anchor Test 2.1'},  # POST data
+            {'link_url': 'exampleanchor', 'link_text': 'Example Anchor Text'}  # GET params - initial data
+        )
+        result = json.loads(response.content.decode())['result']
+        self.assertEqual(result['url'], "#exampleanchor2")
+        self.assertEqual(result['title'], "Example Anchor Test 2.1")
+        # link text has changed, so tell the caller to use it
+        self.assertEqual(result['prefer_this_title_as_link_text'], True)
+
+
 class TestChooserEmailLink(TestCase, WagtailTestUtils):
     def setUp(self):
         self.login()

+ 1 - 0
wagtail/admin/urls/__init__.py

@@ -37,6 +37,7 @@ urlpatterns = [
     url(r'^choose-page/search/$', chooser.search, name='wagtailadmin_choose_page_search'),
     url(r'^choose-external-link/$', chooser.external_link, name='wagtailadmin_choose_page_external_link'),
     url(r'^choose-email-link/$', chooser.email_link, name='wagtailadmin_choose_page_email_link'),
+    url(r'^choose-anchor-link/$', chooser.anchor_link, name='wagtailadmin_choose_page_anchor_link'),
 
     url(r'^tag-autocomplete/$', tags.autocomplete, name='wagtailadmin_tag_autocomplete'),
 

+ 35 - 2
wagtail/admin/views/chooser.py

@@ -2,7 +2,8 @@ from django.core.paginator import Paginator
 from django.http import Http404
 from django.shortcuts import get_object_or_404, render
 
-from wagtail.admin.forms.choosers import EmailLinkChooserForm, ExternalLinkChooserForm
+from wagtail.admin.forms.choosers import (
+    AnchorLinkChooserForm, EmailLinkChooserForm, ExternalLinkChooserForm)
 from wagtail.admin.forms.search import SearchForm
 from wagtail.admin.modal_workflow import render_modal_workflow
 from wagtail.core import hooks
@@ -12,13 +13,14 @@ from wagtail.core.utils import resolve_model_string
 
 def shared_context(request, extra_context=None):
     context = {
-        # parent_page ID is passed as a GET parameter on the external_link and email_link views
+        # parent_page ID is passed as a GET parameter on the external_link, anchor_link and mail_link views
         # so that it's remembered when browsing from 'Internal link' to another link type
         # and back again. On the 'browse' / 'internal link' view this will be overridden to be
         # sourced from the standard URL path parameter instead.
         'parent_page_id': request.GET.get('parent_page_id'),
         'allow_external_link': request.GET.get('allow_external_link'),
         'allow_email_link': request.GET.get('allow_email_link'),
+        'allow_anchor_link': request.GET.get('allow_anchor_link'),
     }
     if extra_context:
         context.update(extra_context)
@@ -222,6 +224,37 @@ def external_link(request):
     )
 
 
+def anchor_link(request):
+    initial_data = {
+        'link_text': request.GET.get('link_text', ''),
+        'url': request.GET.get('link_url', ''),
+    }
+
+    if request.method == 'POST':
+        form = AnchorLinkChooserForm(request.POST, initial=initial_data, prefix='anchor-link-chooser')
+
+        if form.is_valid():
+            result = {
+                'url': '#' + form.cleaned_data['url'],
+                'title': form.cleaned_data['link_text'].strip() or form.cleaned_data['url'],
+                'prefer_this_title_as_link_text': ('link_text' in form.changed_data),
+            }
+            return render_modal_workflow(
+                request, None, None,
+                None, json_data={'step': 'external_link_chosen', 'result': result}
+            )
+    else:
+        form = AnchorLinkChooserForm(initial=initial_data, prefix='anchor-link-chooser')
+
+    return render_modal_workflow(
+        request,
+        'wagtailadmin/chooser/anchor_link.html', None,
+        shared_context(request, {
+            'form': form,
+        }), json_data={'step': 'anchor_link'}
+    )
+
+
 def email_link(request):
     initial_data = {
         'link_text': request.GET.get('link_text', ''),

+ 3 - 1
wagtail/core/rich_text/rewriters.py

@@ -66,6 +66,8 @@ class LinkRewriter:
                     link_type = 'external'
                 elif href.startswith('mailto:'):
                     link_type = 'email'
+                elif href.startswith('#'):
+                    link_type = 'anchor'
 
             if not link_type:
                 # otherwise return ordinary links without a linktype unchanged
@@ -74,7 +76,7 @@ class LinkRewriter:
         try:
             rule = self.link_rules[link_type]
         except KeyError:
-            if link_type in ['email', 'external']:
+            if link_type in ['email', 'external', 'anchor']:
                 # If no rule is registered for supported types
                 # return ordinary links without a linktype unchanged
                 return match.group(0)

+ 6 - 1
wagtail/core/tests/test_rich_text.py

@@ -108,11 +108,13 @@ class TestLinkRewriterTagReplacing(TestCase):
         self.assertEqual(page_type_link, '<a href="/article/3">')
 
         # but it should also be able to handle other supported
-        # link types (email, external) even if no rules is provided
+        # link types (email, external, anchor) even if no rules is provided
         external_type_link = rewriter('<a href="https://wagtail.io/">')
         self.assertEqual(external_type_link, '<a href="https://wagtail.io/">')
         email_type_link = rewriter('<a href="mailto:test@wagtail.io">')
         self.assertEqual(email_type_link, '<a href="mailto:test@wagtail.io">')
+        anchor_type_link = rewriter('<a href="#test">')
+        self.assertEqual(anchor_type_link, '<a href="#test">')
 
         # As well as link which don't have any linktypes
         link_without_linktype = rewriter('<a data-link="https://wagtail.io">')
@@ -131,6 +133,7 @@ class TestLinkRewriterTagReplacing(TestCase):
             'page': lambda attrs: '<a href="/article/{}">'.format(attrs['id']),
             'external': lambda attrs: '<a rel="nofollow" href="{}">'.format(attrs['href']),
             'email': lambda attrs: '<a data-email="true" href="{}">'.format(attrs['href']),
+            'anchor': lambda attrs: '<a data-anchor="true" href="{}">'.format(attrs['href']),
             'custom': lambda attrs: '<a data-phone="true" href="{}">'.format(attrs['href']),
         }
         rewriter = LinkRewriter(rules)
@@ -146,6 +149,8 @@ class TestLinkRewriterTagReplacing(TestCase):
         self.assertEqual(external_type_link_http, '<a rel="nofollow" href="http://wagtail.io/">')
         email_type_link = rewriter('<a href="mailto:test@wagtail.io">')
         self.assertEqual(email_type_link, '<a data-email="true" href="mailto:test@wagtail.io">')
+        anchor_type_link = rewriter('<a href="#test">')
+        self.assertEqual(anchor_type_link, '<a data-anchor="true" href="#test">')
 
         # But not the unsupported ones.
         link_with_no_linktype = rewriter('<a href="tel:+4917640206387">')