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 =
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' %}{% if current == 'internal' %} {% trans "Internal link" %} @@ -22,5 +22,11 @@ {% elif allow_email_link %} | {% trans "Email link" %} {% endif %} + + {% if current == 'anchor' %} + | {% trans "Anchor link" %} + {% elif allow_anchor_link %} + | {% trans "Anchor link" %} + {% endif %}
{% 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' %} + + +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('')