Add ability to create anchor link tab within rich text link

- add tests

- Update changelog & release notes
pull/5518/head
Iman Syed 2019-08-13 17:24:00 +01:00 zatwierdzone przez LB Johnston
rodzic 2de92f045c
commit 794d40b86b
20 zmienionych plików z 195 dodań i 12 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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);

Wyświetl plik

@ -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/',

Wyświetl plik

@ -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;

Wyświetl plik

@ -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,

Wyświetl plik

@ -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": "",

Wyświetl plik

@ -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/',

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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;

Wyświetl plik

@ -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();
}
},
};

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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()

Wyświetl plik

@ -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'),

Wyświetl plik

@ -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', ''),

Wyświetl plik

@ -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)

Wyświetl plik

@ -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">')