Repository: wagtail/wagtail

Issue:
🎛️ Migrate Tag Widget to a Stimulus Controller `w-tag`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

One of the largest blockers to CSP compliance is the overuse of inline script tags, and one of the largest uses of that approach is the taggit field.

It would be good to migrate the taggit field to a Stimulus controller, preserving the jQuery widget approach for now but just ensuring that the initialisation of the field with JS is done via Stimulus.

### Describe the solution you'd like

Create a new Stimulus Controller `TagController` that has the following specification;

* value: `url` (string, required) - the `autocomplete_url` in use today
* value: `allowSpaces` (boolean, not required, default is False
* value: `tagLimit` (number, not required, default - be careful here, we want to default to 0 but us that to put `null` into the init function)
* value: `strict` (boolean, default false) same as `autocompleteOnly` but a shorter name
* Note: We may want to support future options so add a link to the docs in the Controller code https://github.com/aehlke/tag-it/blob/master/README.markdown#options
* We will need to support backwards compatibility - this can be done in the `afterLoad` method, similar to how we are doing this for ProgressController - search for things that look like they could be the taggit field but do not have the data controller attributes.
* The current Tag widget in Python is located in `wagtail/admin/widgets/tags.py`
* The current HTML set up for the general usage of the tag widget is located in `wagtail/admin/templates/wagtailadmin/widgets/tag_widget.html`
* A good start would be to update the Django widget to use a data attributes approach only that will connect to the Stimulus Controller.
* We should be able to remove the `context["widget"]["autocomplete_url"]` & `context["widget"]["options_json"]` and replace them only with plain `attrs` (data attributes).

### Describe alternatives you've considered

* Leave as is, or go all in with finding a new taggit replacement. Hopefully this issue is a step in the right direction.

### Additional context

* Tag fields are used in a lot of places, start small and ensure that we have a solid solution to support existing code approaches used by other packages
* This will be able to get a massive amount of progress towards #7053


🎛️ Migrate Tag Widget to a Stimulus Controller `w-tag`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

One of the largest blockers to CSP compliance is the overuse of inline script tags, and one of the largest uses of that approach is the taggit field.

It would be good to migrate the taggit field to a Stimulus controller, preserving the jQuery widget approach for now but just ensuring that the initialisation of the field with JS is done via Stimulus.

### Describe the solution you'd like

Create a new Stimulus Controller `TagController` that has the following specification;

* value: `url` (string, required) - the `autocomplete_url` in use today
* value: `allowSpaces` (boolean, not required, default is False
* value: `tagLimit` (number, not required, default - be careful here, we want to default to 0 but us that to put `null` into the init function)
* value: `strict` (boolean, default false) same as `autocompleteOnly` but a shorter name
* Note: We may want to support future options so add a link to the docs in the Controller code https://github.com/aehlke/tag-it/blob/master/README.markdown#options
* We will need to support backwards compatibility - this can be done in the `afterLoad` method, similar to how we are doing this for ProgressController - search for things that look like they could be the taggit field but do not have the data controller attributes.
* The current Tag widget in Python is located in `wagtail/admin/widgets/tags.py`
* The current HTML set up for the general usage of the tag widget is located in `wagtail/admin/templates/wagtailadmin/widgets/tag_widget.html`
* A good start would be to update the Django widget to use a data attributes approach only that will connect to the Stimulus Controller.
* We should be able to remove the `context["widget"]["autocomplete_url"]` & `context["widget"]["options_json"]` and replace them only with plain `attrs` (data attributes).

### Describe alternatives you've considered

* Leave as is, or go all in with finding a new taggit replacement. Hopefully this issue is a step in the right direction.

### Additional context

* Tag fields are used in a lot of places, start small and ensure that we have a solid solution to support existing code approaches used by other packages
* This will be able to get a massive amount of progress towards #7053




Discussion:

Resolving patch:
diff --git a/wagtail/admin/widgets/tags.py b/wagtail/admin/widgets/tags.py
--- a/wagtail/admin/widgets/tags.py
+++ b/wagtail/admin/widgets/tags.py
@@ -14,6 +14,13 @@ def __init__(self, *args, **kwargs):
         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 @@ def get_context(self, name, value, attrs):
             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),


Test patch:
diff --git a/client/src/controllers/TagController.test.js b/client/src/controllers/TagController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/TagController.test.js
@@ -0,0 +1,122 @@
+import $ from 'jquery';
+
+import { Application } from '@hotwired/stimulus';
+import { TagController } from './TagController';
+
+const flushPromises = () => new Promise(setImmediate);
+
+window.$ = $;
+
+describe('TagController', () => {
+  let application;
+  let element;
+
+  const tagitMock = jest.fn(function innerFunction() {
+    element = this;
+  });
+
+  window.$.fn.tagit = tagitMock;
+
+  element = null;
+
+  beforeEach(() => {
+    element = null;
+    jest.clearAllMocks();
+  });
+
+  it('should create a global initTagField to call jQuery tagit if the element is found', async () => {
+    expect(window.initTagField).toBeUndefined();
+    expect(tagitMock).not.toHaveBeenCalled();
+
+    application = Application.start();
+    application.register('w-tag', TagController);
+
+    document.body.innerHTML = `
+    <main>
+      <input id="tag-input" type="text" value="abc" />
+    </main>
+    `;
+
+    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'));
+
+    // check the tagit util was called correctly with supplied params
+    expect(tagitMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        autocomplete: { source: '/path/to/autocomplete/' },
+        someOther: 'option',
+      }),
+    );
+
+    // 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'"`);
+  });
+
+  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');
+  });
+});
diff --git a/client/src/includes/initTagField.test.js b/client/src/includes/initTagField.test.js
deleted file mode 100644
--- a/client/src/includes/initTagField.test.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import $ from 'jquery';
-
-window.$ = $;
-
-import { initTagField } from './initTagField';
-
-describe('initTagField', () => {
-  let element;
-
-  const tagitMock = jest.fn(function tagitMockInner() {
-    element = this;
-  });
-
-  window.$.fn.tagit = tagitMock;
-
-  beforeEach(() => {
-    element = null;
-    jest.clearAllMocks();
-  });
-
-  it('should not call jQuery tagit if the element is not found', () => {
-    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();
-
-    document.body.innerHTML = `
-    <main>
-      <input id="tag-input" type="text" value="abc" />
-    </main>
-    `;
-
-    initTagField('tag-input', '/path/to/autocomplete/', {
-      someOther: 'option',
-    });
-
-    // check the jQuery instance is the correct element
-    expect(element).toContain(document.getElementById('tag-input'));
-
-    // check the tagit util was called correctly with supplied params
-    expect(tagitMock).toHaveBeenCalledWith(
-      expect.objectContaining({
-        autocomplete: { source: '/path/to/autocomplete/' },
-        someOther: 'option',
-      }),
-    );
-
-    // 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'"`);
-  });
-});
diff --git a/client/src/utils/domReady.test.js b/client/src/utils/domReady.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/utils/domReady.test.js
@@ -0,0 +1,36 @@
+import { domReady } from './domReady';
+
+jest.useFakeTimers();
+
+describe('domReady', () => {
+  it('should resolve as true if the DOM is not in loading state', () => {
+    expect(document.readyState).toEqual('complete');
+
+    return domReady().then(() => {
+      expect(document.readyState).toEqual('complete');
+    });
+  });
+
+  it('should resolve as true if the DOM loading but then completes loading', () => {
+    let trackingValue = null;
+
+    Object.defineProperty(document, 'readyState', {
+      value: 'loading',
+    });
+
+    expect(document.readyState).toEqual('loading');
+
+    setTimeout(() => {
+      trackingValue = true;
+      document.dispatchEvent(new CustomEvent('DOMContentLoaded'));
+    }, 5);
+
+    const promise = domReady();
+
+    jest.runAllTimers();
+
+    return promise.then(() => {
+      expect(trackingValue).toEqual(true);
+    });
+  });
+});
diff --git a/wagtail/admin/tests/test_edit_handlers.py b/wagtail/admin/tests/test_edit_handlers.py
--- a/wagtail/admin/tests/test_edit_handlers.py
+++ b/wagtail/admin/tests/test_edit_handlers.py
@@ -12,7 +12,7 @@
 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 @@ def test_tag_widget_is_passed_tag_model(self):
             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")
diff --git a/wagtail/admin/tests/test_widgets.py b/wagtail/admin/tests/test_widgets.py
--- a/wagtail/admin/tests/test_widgets.py
+++ b/wagtail/admin/tests/test_widgets.py
@@ -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 @@ def test_render_js_init_with_format_from_settings(self):
 
 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 []
-
-    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 []
+        """
+        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
+
+        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 @@ def test_render_js_init_basic(self):
 
     @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 @@ def test_render_js_init_no_spaces_allowed(self):
 
     @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 @@ def test_render_js_init_with_tag_limit(self):
 
     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 @@ def test_tags_help_text_spaces_allowed(self):
         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 @@ def test_tags_help_text_no_spaces_allowed(self):
         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,
         )