diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 62d01b1e43..edb36f9fd2 100644 --- a/CHANGELOG.txt +++ b/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) diff --git a/client/src/components/Draftail/decorators/Link.js b/client/src/components/Draftail/decorators/Link.js index 5e28ce3909..c605fda4a3 100644 --- a/client/src/components/Draftail/decorators/Link.js +++ b/client/src/components/Draftail/decorators/Link.js @@ -14,7 +14,7 @@ const MAIL_ICON = ; 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); diff --git a/client/src/components/Draftail/decorators/Link.test.js b/client/src/components/Draftail/decorators/Link.test.js index cab3ed9799..94df8a73ee 100644 --- a/client/src/components/Draftail/decorators/Link.test.js +++ b/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/', diff --git a/client/src/components/Draftail/sources/ModalWorkflowSource.js b/client/src/components/Draftail/sources/ModalWorkflowSource.js index 25cfe014c2..18c2902a26 100644 --- a/client/src/components/Draftail/sources/ModalWorkflowSource.js +++ b/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; diff --git a/client/src/components/Draftail/sources/ModalWorkflowSource.test.js b/client/src/components/Draftail/sources/ModalWorkflowSource.test.js index dee92212d1..0f559a1db8 100644 --- a/client/src/components/Draftail/sources/ModalWorkflowSource.test.js +++ b/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, diff --git a/client/src/components/Draftail/sources/__snapshots__/ModalWorkflowSource.test.js.snap b/client/src/components/Draftail/sources/__snapshots__/ModalWorkflowSource.test.js.snap index 2584f40357..3ec8f54ca3 100644 --- a/client/src/components/Draftail/sources/__snapshots__/ModalWorkflowSource.test.js.snap +++ b/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": "", diff --git a/client/tests/stubs.js b/client/tests/stubs.js index a7ad77276e..7d1bcdd5f1 100644 --- a/client/tests/stubs.js +++ b/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/', diff --git a/docs/releases/2.7.rst b/docs/releases/2.7.rst index 61884a3a16..5e34fef982 100644 --- a/docs/releases/2.7.rst +++ b/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 diff --git a/wagtail/admin/forms/choosers.py b/wagtail/admin/forms/choosers.py index 739060cf8f..a7972dcabc 100644 --- a/wagtail/admin/forms/choosers.py +++ b/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) diff --git a/wagtail/admin/rich_text/converters/html_to_contentstate.py b/wagtail/admin/rich_text/converters/html_to_contentstate.py index 115785dc3d..0c3acdbac3 100644 --- a/wagtail/admin/rich_text/converters/html_to_contentstate.py +++ b/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
 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
diff --git a/wagtail/admin/static_src/wagtailadmin/js/hallo-plugins/hallo-wagtaillink.js b/wagtail/admin/static_src/wagtailadmin/js/hallo-plugins/hallo-wagtaillink.js
index cbd0fc9210..76a8be65ba 100644
--- a/wagtail/admin/static_src/wagtailadmin/js/hallo-plugins/hallo-wagtaillink.js
+++ b/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;
diff --git a/wagtail/admin/static_src/wagtailadmin/js/page-chooser-modal.js b/wagtail/admin/static_src/wagtailadmin/js/page-chooser-modal.js
index 1a43e1a5dd..92c69c1f22 100644
--- a/wagtail/admin/static_src/wagtailadmin/js/page-chooser-modal.js
+++ b/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();
-    }
+    },
 };
diff --git a/wagtail/admin/templates/wagtailadmin/chooser/_link_types.html b/wagtail/admin/templates/wagtailadmin/chooser/_link_types.html
index a4d4e31a0c..8d7c8ab636 100644
--- a/wagtail/admin/templates/wagtailadmin/chooser/_link_types.html
+++ b/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' %}
     
 {% endif %}
diff --git a/wagtail/admin/templates/wagtailadmin/chooser/anchor_link.html b/wagtail/admin/templates/wagtailadmin/chooser/anchor_link.html
new file mode 100644
index 0000000000..c3be2c7f30
--- /dev/null
+++ b/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 %}
+
+
+ {% include 'wagtailadmin/chooser/_link_types.html' with current='anchor' %} + +
+ {% csrf_token %} +
    + {% for field in form %} + {% include "wagtailadmin/shared/field_as_li.html" %} + {% endfor %} +
  • +
+
+
diff --git a/wagtail/admin/templates/wagtailadmin/pages/_editor_js.html b/wagtail/admin/templates/wagtailadmin/pages/_editor_js.html index ad143456c1..f8b3dde0bf 100644 --- a/wagtail/admin/templates/wagtailadmin/pages/_editor_js.html +++ b/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 %}; diff --git a/wagtail/admin/tests/test_page_chooser.py b/wagtail/admin/tests/test_page_chooser.py index b8a9583d41..ee2cd1d55b 100644 --- a/wagtail/admin/tests/test_page_chooser.py +++ b/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() diff --git a/wagtail/admin/urls/__init__.py b/wagtail/admin/urls/__init__.py index c56dcc357e..4a46288fce 100644 --- a/wagtail/admin/urls/__init__.py +++ b/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'), diff --git a/wagtail/admin/views/chooser.py b/wagtail/admin/views/chooser.py index a0f299d4c6..94bfa42426 100644 --- a/wagtail/admin/views/chooser.py +++ b/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', ''), diff --git a/wagtail/core/rich_text/rewriters.py b/wagtail/core/rich_text/rewriters.py index 904eaad661..4b17f867d5 100644 --- a/wagtail/core/rich_text/rewriters.py +++ b/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) diff --git a/wagtail/core/tests/test_rich_text.py b/wagtail/core/tests/test_rich_text.py index 95b1547d8c..c3806e2fd2 100644 --- a/wagtail/core/tests/test_rich_text.py +++ b/wagtail/core/tests/test_rich_text.py @@ -108,11 +108,13 @@ class TestLinkRewriterTagReplacing(TestCase): self.assertEqual(page_type_link, '') # 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('') self.assertEqual(external_type_link, '') email_type_link = rewriter('') self.assertEqual(email_type_link, '') + anchor_type_link = rewriter('') + self.assertEqual(anchor_type_link, '') # As well as link which don't have any linktypes link_without_linktype = rewriter('') @@ -131,6 +133,7 @@ class TestLinkRewriterTagReplacing(TestCase): 'page': lambda attrs: ''.format(attrs['id']), 'external': lambda attrs: ''.format(attrs['href']), 'email': lambda attrs: ''.format(attrs['href']), + 'anchor': lambda attrs: ''.format(attrs['href']), 'custom': lambda attrs: ''.format(attrs['href']), } rewriter = LinkRewriter(rules) @@ -146,6 +149,8 @@ class TestLinkRewriterTagReplacing(TestCase): self.assertEqual(external_type_link_http, '') email_type_link = rewriter('') self.assertEqual(email_type_link, '') + anchor_type_link = rewriter('') + self.assertEqual(anchor_type_link, '') # But not the unsupported ones. link_with_no_linktype = rewriter('')