0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-25 05:02:57 +01:00

Migrate Tagit init JS to TagController

- Closes #10100
This commit is contained in:
LB Johnston 2023-02-16 23:33:41 +10:00 committed by Sage Abdullah
parent 82ca711f16
commit 150e988f4d
No known key found for this signature in database
GPG Key ID: EB1A33CC51CC0217
18 changed files with 248 additions and 153 deletions

View File

@ -1,33 +1,35 @@
import $ from 'jquery';
import { Application } from '@hotwired/stimulus';
import { TagController } from './TagController';
const flushPromises = () => new Promise(setImmediate);
window.$ = $;
import { initTagField } from './TagController';
describe('initTagField', () => {
describe('TagController', () => {
let application;
let element;
const tagitMock = jest.fn(function tagitMockInner() {
const tagitMock = jest.fn(function innerFunction() {
element = this;
});
window.$.fn.tagit = tagitMock;
element = null;
beforeEach(() => {
element = null;
jest.clearAllMocks();
});
it('should not call jQuery tagit if the element is not found', () => {
it('should create a global initTagField to call jQuery tagit if the element is found', async () => {
expect(window.initTagField).toBeUndefined();
expect(tagitMock).not.toHaveBeenCalled();
initTagField('not-present');
expect(tagitMock).not.toHaveBeenCalled();
});
it('should call jQuery tagit if the element is found', () => {
expect(tagitMock).not.toHaveBeenCalled();
application = Application.start();
application.register('w-tag', TagController);
document.body.innerHTML = `
<main>
@ -35,10 +37,12 @@ describe('initTagField', () => {
</main>
`;
initTagField('tag-input', '/path/to/autocomplete/', {
window.initTagField('tag-input', '/path/to/autocomplete/', {
someOther: 'option',
});
await flushPromises();
// check the jQuery instance is the correct element
expect(element).toContain(document.getElementById('tag-input'));
@ -59,4 +63,60 @@ describe('initTagField', () => {
expect(preprocessTag('"flat white"')).toEqual(`"flat white"`);
expect(preprocessTag("'long black'")).toEqual(`"'long black'"`);
});
it('should not call jQuery tagit if the element is not found', async () => {
expect(tagitMock).not.toHaveBeenCalled();
window.initTagField('not-present');
await flushPromises();
expect(tagitMock).not.toHaveBeenCalled();
});
it('should attach the jQuery tagit to the controlled element', async () => {
document.body.innerHTML = `
<form id="form">
<input
id="id_tags"
type="text"
name="tags"
data-controller="w-tag"
data-action="example:event->w-tag#clear"
data-w-tag-options-value="{&quot;allowSpaces&quot;:true,&quot;tagLimit&quot;:10}"
data-w-tag-url-value="/admin/tag-autocomplete/"
>
</form>`;
expect(tagitMock).not.toHaveBeenCalled();
await flushPromises();
expect(tagitMock).toHaveBeenCalledWith({
allowSpaces: true,
autocomplete: { source: '/admin/tag-autocomplete/' },
preprocessTag: expect.any(Function),
tagLimit: 10,
});
expect(element[0]).toEqual(document.getElementById('id_tags'));
// check the supplied preprocessTag function
const [{ preprocessTag }] = tagitMock.mock.calls[0];
expect(preprocessTag).toBeInstanceOf(Function);
expect(preprocessTag()).toEqual();
expect(preprocessTag('"flat white"')).toEqual(`"flat white"`);
expect(preprocessTag("'long black'")).toEqual(`"'long black'"`);
expect(preprocessTag('caffe latte')).toEqual(`"caffe latte"`);
// check the custom clear behaviour
document
.getElementById('id_tags')
.dispatchEvent(new CustomEvent('example:event'));
await flushPromises();
expect(tagitMock).toHaveBeenCalledWith('removeAll');
});
});

View File

@ -1,42 +1,93 @@
import $ from 'jquery';
import { Controller } from '@hotwired/stimulus';
import type { Application } from '@hotwired/stimulus';
import { domReady } from '../utils/domReady';
declare global {
interface JQuery {
tagit(...args): void;
tagit: (options: Record<string, any> | string) => void;
}
interface Window {
initTagField: (
id: string,
url: string,
options?: Record<string, any>,
) => void;
}
}
/**
* Initialises the tag fields using the jQuery tagit widget
* Attach the jQuery tagit UI to the controlled element.
*
* @param id - element id to initialise against
* @param source - auto complete URL source
* @param options - Other options passed to jQuery tagit
* See https://github.com/aehlke/tag-it
*
* @example
* <input id="id_tags" type="text" name="tags" data-controller="w-tag" data-w-tag-url-value="/admin/tag-autocomplete/" />
*/
const initTagField = (
id: string,
source: string,
options: Record<string, any>,
): void => {
const tagFieldElement = document.getElementById(id);
if (!tagFieldElement) return;
const finalOptions = {
autocomplete: { source },
preprocessTag(val: any) {
// Double quote a tag if it contains a space
// and if it isn't already quoted.
if (val && val[0] !== '"' && val.indexOf(' ') > -1) {
return '"' + val + '"';
}
return val;
},
...options,
export class TagController extends Controller {
static values = {
options: { default: {}, type: Object },
url: String,
};
$('#' + id).tagit(finalOptions);
};
declare optionsValue: any;
declare urlValue: any;
tagit?: JQuery<HTMLElement>;
export { initTagField };
/**
* Prepare a global function that preserves the previous approach to
* registering a tagit field. This will be removed in a future release.
*
* @deprecated RemovedInWagtail60
*/
static afterLoad(
identifier: string,
{ schema: { controllerAttribute } }: Application,
) {
window.initTagField = (id, url, options = {}): void => {
domReady().then(() => {
const tagFieldElement = document.getElementById(id);
if (!tagFieldElement || !url) return;
Object.entries({ options: JSON.stringify(options), url }).forEach(
([name, value]) => {
tagFieldElement.setAttribute(
`data-${identifier}-${name}-value`,
value,
);
},
);
tagFieldElement.setAttribute(controllerAttribute, identifier);
});
};
}
connect() {
const preprocessTag = this.cleanTag.bind(this);
$(this.element).tagit({
autocomplete: { source: this.urlValue },
preprocessTag,
...this.optionsValue,
});
}
/**
* Double quote a tag if it contains a space
* and if it isn't already quoted.
*/
cleanTag(val: string) {
return val && val[0] !== '"' && val.indexOf(' ') > -1 ? `"${val}"` : val;
}
/**
* Method to clear all the tags that are set.
*/
clear() {
$(this.element).tagit('removeAll');
}
}

View File

@ -13,6 +13,7 @@ import { SkipLinkController } from './SkipLinkController';
import { SlugController } from './SlugController';
import { SubmitController } from './SubmitController';
import { SyncController } from './SyncController';
import { TagController } from './TagController';
import { UpgradeController } from './UpgradeController';
/**
@ -32,5 +33,6 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: SlugController, identifier: 'w-slug' },
{ controllerConstructor: SubmitController, identifier: 'w-submit' },
{ controllerConstructor: SyncController, identifier: 'w-sync' },
{ controllerConstructor: TagController, identifier: 'w-tag' },
{ controllerConstructor: UpgradeController, identifier: 'w-upgrade' },
];

View File

@ -3,7 +3,6 @@ import $ from 'jquery';
import { coreControllerDefinitions } from '../../controllers';
import { escapeHtml } from '../../utils/text';
import { initStimulus } from '../../includes/initStimulus';
import { initTagField } from '../../includes/initTagField';
import { initTooltips } from '../../includes/initTooltips';
/** initialise Wagtail Stimulus application with core controller definitions */
@ -11,8 +10,6 @@ window.Stimulus = initStimulus({ definitions: coreControllerDefinitions });
window.escapeHtml = escapeHtml;
window.initTagField = initTagField;
/*
* Enables a "dirty form check", prompting the user if they are navigating away
* from a page with unsaved changes, as well as optionally controlling other

View File

@ -1,16 +1,14 @@
/**
* Returns a promise that resolves once the DOM is ready for interaction.
*/
const domReady = async () =>
new Promise<void>((resolve) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => resolve(), {
once: true,
passive: true,
});
} else {
resolve();
}
const domReady = async () => {
if (document.readyState !== 'loading') return;
await new Promise<void>((resolve) => {
document.addEventListener('DOMContentLoaded', () => resolve(), {
once: true,
passive: true,
});
});
};
export { domReady };

View File

@ -148,3 +148,36 @@ The ordering for "Object permissions" and "Other permissions" now follows a pred
This will be different to the previous ordering which never intentionally implemented.
This default ordering is now `["content_type__app_label", "content_type__model", "codename"]`, which can now be customised [](customising_group_views_permissions_order).
### Tag (Tagit) field usage now relies on data attributes
The `AdminTagWidget` widget has now been migrated to a Stimulus controller, if using this widget in Python, no changes are needed to adopt the new approach.
If the widget is being instantiated in JavaScript or HTML with the global util `window.initTagField`, this undocumented util should be replaced with the new `data-*` attributes approach. Additionally, any direct usage of the jQuery widget in JavaScript (e.g. `$('#my-element).tagit()`) should be removed.
The global util will be removed in a future release. It is recommended that the documented `AdminTagWidget` be used. However, if you need to use the JavaScript approach you can do this with the following example.
**Old syntax**
```html
<input id="id_tags" type="text" value="popular, technology" hidden />
<script>
window.initTagField('id_tags', 'path/to/url', { autocompleteOnly: true });
</script>
```
**New syntax**
```html
<input
id="id_tags"
type="text"
value="popular, technology"
hidden
data-controller="w-tag"
data-w-tag-options-value='{"autocompleteOnly": true}'
data-w-tag-url-value="/path/to/url"
/>
```
Note: The `data-w-tag-options-value` is a JSON object serialised into string. Django's HTML escaping will handle it automatically when you use the `AdminTagWidget`, but if you are manually writing the attributes, be sure to use quotation marks correctly.

View File

@ -1,9 +1,2 @@
{% include 'django/forms/widgets/text.html' %}
<p class="help">{{ widget.help_text }}</p>
<script>
initTagField(
"{{ widget.attrs.id|escapejs }}",
"{{ widget.autocomplete_url|escapejs }}",
{{ widget.options_json|safe }}
);
</script>

View File

@ -12,7 +12,7 @@ from django.core import checks
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from django.utils.html import json_script
from django.utils.html import escape, json_script
from freezegun import freeze_time
from wagtail.admin.forms import WagtailAdminModelForm, WagtailAdminPageForm
@ -220,11 +220,14 @@ class TestGetFormForModel(TestCase):
fields=["title", "slug", "tags"],
)
form_html = RestaurantPageForm().as_p()
self.assertIn("/admin/tag\\u002Dautocomplete/tests/restauranttag/", form_html)
self.assertIn(
'data-w-tag-url-value="/admin/tag-autocomplete/tests/restauranttag/"',
form_html,
)
# widget should pick up the free_tagging=False attribute on the tag model
# and set itself to autocomplete only
self.assertIn('"autocompleteOnly": true', form_html)
self.assertIn(escape('"autocompleteOnly": true'), form_html)
# Free tagging should also be disabled at the form field validation level
RestaurantTag.objects.create(name="Italian", slug="italian")

View File

@ -1,8 +1,11 @@
import json
import re
from html import unescape
from django import forms
from django.test import TestCase
from django.test.utils import override_settings
from django.utils.html import escape
from wagtail.admin import widgets
from wagtail.admin.forms.tags import TagField
@ -387,30 +390,32 @@ class TestAdminDateTimeInput(TestCase):
class TestAdminTagWidget(TestCase):
def get_js_init_params(self, html):
"""Returns a list of the params passed in to initTagField from the supplied HTML"""
# example - ["test_id", "/admin/tag-autocomplete/", {'allowSpaces': True}]
start = "initTagField("
end = ");"
items_after_init = html.split(start)[1]
if items_after_init:
params_raw = items_after_init.split(end)[0]
if params_raw:
# stuff parameter string into an array so that we can unpack it as JSON
return json.loads("[%s]" % params_raw)
return []
"""
Returns a list of the key parts of data needed for the w-tag controlled element
An id for the element with the 'w-tag' controller, the autocomplete url & tag options
def get_help_text_html_element(self, html):
"""Return a help text html element with content as string"""
start = """<input type="text" name="tags">"""
end = "<script>"
items_after_input_tag = html.split(start)[1]
if items_after_input_tag:
help_text_element = items_after_input_tag.split(end)[0].strip()
return help_text_element
return []
example element <input data-controller="w-tag" id="test_id" data-w-tag-url-value="/admin/tag-autocomplete/" data-w-tag-options-value="{...encoded json opts}" />
example result - ["test_id", "/admin/tag-autocomplete/", {'allowSpaces': True}]
"""
element_id = re.search(
r'data-controller=\"w-tag\" id=\"((?:\\.|[^"\\])*)\"\s+', html
).group(1)
autocomplete_url = re.search(
r'data-w-tag-url-value=\"((?:\\.|[^"\\])*)"', html
).group(1)
options = re.search(
r'data-w-tag-options-value=\"((?:\\.|[^"\\])*)"', html
).group(1)
return [
element_id,
autocomplete_url,
json.loads(unescape(options)),
]
def test_render_js_init_basic(self):
"""Checks that the 'initTagField' is correctly added to the inline script for tag widgets"""
"""Checks that the 'w-tag' controller attributes are correctly added to the tag widgets"""
widget = widgets.AdminTagWidget()
html = widget.render("tags", None, attrs={"id": "alpha"})
@ -427,7 +432,7 @@ class TestAdminTagWidget(TestCase):
@override_settings(TAG_SPACES_ALLOWED=False)
def test_render_js_init_no_spaces_allowed(self):
"""Checks that the 'initTagField' includes the correct value based on TAG_SPACES_ALLOWED in settings"""
"""Checks that the 'w-tag' controller attributes are correctly added to the tag widgets based on TAG_SPACES_ALLOWED in settings"""
widget = widgets.AdminTagWidget()
html = widget.render("tags", None, attrs={"id": "alpha"})
@ -444,7 +449,8 @@ class TestAdminTagWidget(TestCase):
@override_settings(TAG_LIMIT=5)
def test_render_js_init_with_tag_limit(self):
"""Checks that the 'initTagField' includes the correct value based on TAG_LIMIT in settings"""
"""Checks that the 'w-tag' controller attributes are correctly added to the tag widget using options based on TAG_LIMIT in settings"""
widget = widgets.AdminTagWidget()
html = widget.render("tags", None, attrs={"id": "alpha"})
@ -461,7 +467,8 @@ class TestAdminTagWidget(TestCase):
def test_render_js_init_with_tag_model(self):
"""
Checks that 'initTagField' is passed the correct autocomplete URL for the custom model,
Checks that the 'w-tag' controller attributes are correctly added to the tag widget using
the correct autocomplete URL for the custom model,
and sets autocompleteOnly according to that model's free_tagging attribute
"""
widget = widgets.AdminTagWidget(tag_model=RestaurantTag)
@ -517,16 +524,15 @@ class TestAdminTagWidget(TestCase):
help_text = widget.get_context(None, None, {})["widget"]["help_text"]
html = widget.render("tags", None, {})
help_text_html_element = self.get_help_text_html_element(html)
self.assertEqual(
help_text,
'Multi-word tags with spaces will automatically be enclosed in double quotes (").',
)
self.assertHTMLEqual(
help_text_html_element,
"""<p class="help">%s</p>""" % help_text,
self.assertIn(
"""<p class="help">%s</p>""" % escape(help_text),
html,
)
@override_settings(TAG_SPACES_ALLOWED=False)
@ -536,15 +542,14 @@ class TestAdminTagWidget(TestCase):
help_text = widget.get_context(None, None, {})["widget"]["help_text"]
html = widget.render("tags", None, {})
help_text_html_element = self.get_help_text_html_element(html)
self.assertEqual(
help_text, "Tags can only consist of a single word, no spaces allowed."
)
self.assertHTMLEqual(
help_text_html_element,
"""<p class="help">%s</p>""" % help_text,
self.assertIn(
"""<p class="help">%s</p>""" % escape(help_text),
html,
)

View File

@ -14,6 +14,13 @@ class AdminTagWidget(TagWidget):
self.tag_model = kwargs.pop("tag_model", Tag)
# free_tagging = None means defer to the tag model's setting
self.free_tagging = kwargs.pop("free_tagging", None)
default_attrs = {"data-controller": "w-tag"}
attrs = kwargs.get("attrs")
if attrs:
default_attrs.update(attrs)
kwargs["attrs"] = default_attrs
super().__init__(*args, **kwargs)
def get_context(self, name, value, attrs):
@ -41,8 +48,8 @@ class AdminTagWidget(TagWidget):
help_text = _("Tags can only consist of a single word, no spaces allowed.")
context["widget"]["help_text"] = help_text
context["widget"]["autocomplete_url"] = autocomplete_url
context["widget"]["options_json"] = json.dumps(
context["widget"]["attrs"]["data-w-tag-url-value"] = autocomplete_url
context["widget"]["attrs"]["data-w-tag-options-value"] = json.dumps(
{
"allowSpaces": getattr(settings, "TAG_SPACES_ALLOWED", True),
"tagLimit": getattr(settings, "TAG_LIMIT", None),

View File

@ -174,9 +174,6 @@ $(function () {
});
} else {
form.replaceWith(data.form);
// run tagit enhancement on new form
$('.tag_field input', form).tagit(window.tagit_opts);
}
});
});

View File

@ -3,18 +3,6 @@
{% load wagtailimages_tags wagtailadmin_tags %}
{% block titletag %}{% blocktrans trimmed count counter=items|length %}Add tags to 1 document {% plural %}Add tags to {{ counter }} documents{% endblocktrans %}{% endblock %}
{% block extra_js %}
{{ block.super }}
{% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
<script>
$(function() {
$('#id_tags').tagit({
autocomplete: {source: "{{ autocomplete_url|addslashes }}"}
});
});
</script>
{% endblock %}
{% block header %}
{% trans "Add tags to documents" as add_str %}
{% include "wagtailadmin/shared/header.html" with title=add_str icon="doc-full-inverse" %}

View File

@ -36,9 +36,6 @@
$titleField.val(data.title);
}
);
$('#id_tags').tagit({
autocomplete: {source: "{{ autocomplete_url|addslashes }}"}
});
});
</script>
{% endblock %}

View File

@ -7,15 +7,6 @@
{{ block.super }}
{{ form.media.js }}
{% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
<script>
$(function() {
$('#id_tags').tagit({
autocomplete: {source: "{{ autocomplete_url|addslashes }}"}
});
});
</script>
{% endblock %}
{% block extra_css %}

View File

@ -214,9 +214,6 @@ $(function () {
});
} else {
form.replaceWith(data.form);
// run tagit enhancement on new form
$('.tag_field input', form).tagit(window.tagit_opts);
}
});
});

View File

@ -3,18 +3,6 @@
{% load wagtailimages_tags wagtailadmin_tags %}
{% block titletag %}{% blocktrans trimmed count counter=items|length %}Add tags to 1 image {% plural %}Add tags to {{ counter }} images{% endblocktrans %}{% endblock %}
{% block extra_js %}
{{ block.super }}
{% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
<script>
$(function() {
$('#id_tags').tagit({
autocomplete: {source: "{{ autocomplete_url|addslashes }}"}
});
});
</script>
{% endblock %}
{% block header %}
{% trans "Add tags to images" as add_str %}
{% include "wagtailadmin/shared/header.html" with title=add_str icon="doc-full-inverse" %}

View File

@ -36,9 +36,6 @@
$titleField.val(data.title);
}
);
$('#id_tags').tagit({
autocomplete: {source: "{{ autocomplete_url|addslashes }}"}
});
});
</script>
{% endblock %}

View File

@ -12,15 +12,6 @@
{{ form.media.js }}
{% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
<script>
$(function() {
$('#id_tags').tagit({
autocomplete: {source: "{{ autocomplete_url|addslashes }}"}
});
});
</script>
<!-- Focal point chooser -->
<script src="{% versioned_static 'wagtailadmin/js/vendor/jquery.ba-throttle-debounce.min.js' %}"></script>
<script src="{% versioned_static 'wagtailimages/js/vendor/jquery.Jcrop.min.js' %}"></script>