Repository: wagtail/wagtail

Issue:
🎛️ Migrate `draftail.initEditor` inline script usage to leverage Stimulus `w-init` controller
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

Our current approach for bootstrapping the Draftail editor (non-StreamField usage) relies on inline scripts.

https://github.com/wagtail/wagtail/blob/e03c41250776612468226597a077ba1fc2d9b865/wagtail/admin/templates/wagtailadmin/widgets/draftail_rich_text_area.html#L1

This approach is not CSP compliant and it would be good to find a better way to achieve the same goals.

### Describe the solution you'd like

We further enhance the existing Stimulus controller `InitController` that allows arbitrary events to be dispatches on the target element.

We will need a way to support arbitrary event `detail` being passed to the init events that are dispatched.

This would be very similar to the solution implemented for the comments initialisation, except that this may be initialised multiple times (so no need for `'once': true`). See https://github.com/wagtail/wagtail/issues/11287 & https://github.com/wagtail/wagtail/pull/11294

https://github.com/wagtail/wagtail/blob/e03c41250776612468226597a077ba1fc2d9b865/client/src/entrypoints/admin/comments.js#L341-L350

#### Part 1 - `InitController` add support for `detail` value

In the [`client/src/controllers/InitController.ts`](https://github.com/wagtail/wagtail/blob/main/client/src/controllers/InitController.ts) file.

* Add a new `detail` [Stimulus value](https://stimulus.hotwired.dev/reference/values) to the controller, this would be used with `data-w-init-detail-value='{"some": "json"}'`.
* Read out the detail value object in the `ready` method, something like `const detail = { ...(this.detailValue || {}) }; // Coerce to object`
* When dispatching both the `ready` event and the custom events, pass the `detail` object to those events.
* Add the removal of this attribute and other attributes in `remove` so that the initial data is not lying around in the DOM, example code below.
* Must have - unit tests added for this new functionality.
* Must have - update JSDoc for controller (including new example), methods and the `declare detailValue: Record<string, unknown>;` statement so that the code is well documented.
* Nice to have - add the ability for the `ready` event to have it's default prevented, returning early to stop the other events & removal.
* Nice to have - re-order the values to be in alpha order, in the `static values = {...` and the `declare` statements.

```ts
  remove() {
    const element = this.element;

    (this.constructor as typeof InitController).classes.forEach((key) => {
      element.removeAttribute(`data-${this.identifier}-${key}-class`);
    });

    Object.keys((this.constructor as typeof InitController).values).forEach(
      (key) => {
        element.removeAttribute(`data-${this.identifier}-${key}-value`);
      },
    );
   /// ... existing stuff 
```

#### Part 2 - Add support for an event listener to instantiate Draftail

In [`client/src/entrypoints/admin/draftail.js`](https://github.com/wagtail/wagtail/blob/main/client/src/entrypoints/admin/draftail.js) we will want to add the event listener to wrap the `initDraftail` function call.

Something like this.

```js
/**
 * Initialize a Draftail editor on a given element when the w-draftail:init event is fired.
 */
document.addEventListener('w-draftail:init', ({ detail = {}, target }) => {
  const id = target.id;
  if (!id) {
    throw new Error('w-draftail:init event must have a target with an id');
  }
  window.draftail.initEditor(`#${id}`, detail, document.currentScript);
});
```

#### Part 3 - Update the Django widget to use the new attributes

See [`wagtail/admin/rich_text/editors/draftail/__init__.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/rich_text/editors/draftail/__init__.py) and [`wagtail/admin/templates/wagtailadmin/widgets/draftail_rich_text_area.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/widgets/draftail_rich_text_area.html).

* Update the template to ONLY render the hidden field, `{% include 'django/forms/widgets/hidden.html' %}` and remove the `<script...` tags. We want to keep this template just in case other developers have customised this template for their own code.
* We will want to update the approach in this Widget to set the Stimulus data attributes and replace the way we are passing options, instead we will pass this as `w-init-detail-value`.
* Python unit tests may need to be updated to reflect the way this new behaviour works.

```
class DraftailRichTextArea(widgets.HiddenInput):
    template_name = "wagtailadmin/widgets/draftail_rich_text_area.html"
    # ...

    def __init__(self, *args, **kwargs):
        # ...
        default_attrs = {
            "data-draftail-input": True,
            # add the Stimulus attributes to the `default_attrs`
            "data-controller": "w-init",
            "data-w-init-event-value": "w-draftail:init",
        }
        attrs = kwargs.get("attrs")
        # ...

    # ... other methods

    def get_context(self, name, value, attrs):
        context = super().get_context(name, value, attrs)
        context["widget"]["attrs"]["data-w-init-detail-value"] = json.dumps(self.options) # update this line
        return context
```


### Describe alternatives you've considered

* We already have a `data-draftail-input` and may want to leverage that, or maybe change that, but this would require building out a new mutation observer OR changing the documentation. It's probably best to just leave this attribute usage as is and avoid the added code churn. We can revisit this later if needed.
* Maybe a discrete controller specific to Draftail, however, the init controller is so close to doing exactly what we need it makes sense to add one additional value to this instead.

### Additional context

* As noted above, this is very similar to the approach in See https://github.com/wagtail/wagtail/issues/11287 & https://github.com/wagtail/wagtail/pull/11294
* This issue is similar in goals to https://github.com/wagtail/wagtail/issues/11597 but taking an intentionally different approach to just leverage an existing controller.

### Working on this

* Anyone can contribute to this if they are able to work with Stimulus and create Jest tests, plus feel confident in modifying Python tests.
* View our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you’re ready to start.



Discussion:
@gasman  or @thibaudcolas - just wanted to see if you had any input or thoughts on this proposed approach.

I also wonder if we should rework the requirements on https://github.com/wagtail/wagtail/issues/11644 to be very similar, just add a `initBlock` event listener. Not trying to abstract to much but just thinking of other ways to tackle the same problems.

Hopefully with this issue and the block issue we will be inching closer to being able to make Wagtail CSP compliant.
Hi @lb- 
I'd like to give this one a go.
Go for it, have a read through and feel free to ask any questions on this issue. 

@Chiemezuo 
For sure!
I'll ping you on slack in a few hours.

Update: Done with Part 1 of this.

Resolving patch:
diff --git a/wagtail/admin/rich_text/editors/draftail/__init__.py b/wagtail/admin/rich_text/editors/draftail/__init__.py
--- a/wagtail/admin/rich_text/editors/draftail/__init__.py
+++ b/wagtail/admin/rich_text/editors/draftail/__init__.py
@@ -62,7 +62,11 @@ def __init__(self, *args, **kwargs):
 
         self.converter = ContentstateConverter(self.features)
 
-        default_attrs = {"data-draftail-input": True}
+        default_attrs = {
+            "data-draftail-input": True,
+            "data-controller": "w-init",
+            "data-w-init-event-value": "w-draftail:init",
+        }
         attrs = kwargs.get("attrs")
         if attrs:
             default_attrs.update(attrs)
@@ -82,8 +86,9 @@ def format_value(self, value):
 
     def get_context(self, name, value, attrs):
         context = super().get_context(name, value, attrs)
-        context["widget"]["options_json"] = json.dumps(
-            self.options, cls=LazyStringEncoder
+        context["widget"]["attrs"]["data-w-init-detail-value"] = json.dumps(
+            self.options,
+            cls=LazyStringEncoder,
         )
         return context
 


Test patch:
diff --git a/client/src/controllers/InitController.test.js b/client/src/controllers/InitController.test.js
--- a/client/src/controllers/InitController.test.js
+++ b/client/src/controllers/InitController.test.js
@@ -51,6 +51,7 @@ describe('InitController', () => {
 
       expect(handleEvent).toHaveBeenCalledTimes(1);
       expect(testDiv.getAttribute('data-controller')).toBeNull();
+      expect({ ...testDiv.dataset }).toEqual({});
     });
   });
 
@@ -147,5 +148,77 @@ describe('InitController', () => {
         'other-custom:event',
       ]);
     });
+
+    it('should support the ability to block additional events and classes removal', async () => {
+      jest.clearAllMocks();
+
+      expect(handleEvent).not.toHaveBeenCalled();
+
+      document.addEventListener(
+        'w-init:ready',
+        (event) => {
+          event.preventDefault();
+        },
+        { once: true },
+      );
+
+      const article = document.createElement('article');
+      article.id = 'article';
+      article.innerHTML = `<p data-controller="w-init" data-w-init-event-value="custom:event">CONTENT</p>`;
+
+      document.body.append(article);
+
+      await jest.runAllTimersAsync();
+
+      // only once - the w-init, no other events should fire
+      expect(handleEvent).toHaveBeenCalledTimes(1);
+      expect(handleEvent).toHaveBeenLastCalledWith(
+        expect.objectContaining({ type: 'w-init:ready' }),
+      );
+    });
+  });
+
+  describe('when using detail for the dispatched events', () => {
+    const handleEvent = jest.fn();
+
+    document.addEventListener('w-init:ready', handleEvent);
+    document.addEventListener('my-custom:event', handleEvent);
+
+    const detail = { someMessage: 'some value' };
+
+    beforeAll(() => {
+      jest.clearAllMocks();
+
+      application?.stop();
+
+      document.body.innerHTML = `
+        <article
+          id="test"
+          class="test-detail"
+          data-controller="w-init"
+          data-w-init-detail-value='${JSON.stringify(detail)}'
+          data-w-init-event-value='my-custom:event'
+        >
+          Test body
+        </article>
+      `;
+
+      application = Application.start();
+    });
+
+    it('should dispatch event with a detail', async () => {
+      application.register('w-init', InitController);
+      await Promise.resolve(); // no delay, just wait for the next tick
+
+      expect(handleEvent).toHaveBeenCalledTimes(2);
+
+      const [[firstEvent], [secondEvent]] = handleEvent.mock.calls;
+
+      expect(firstEvent.type).toEqual('w-init:ready');
+      expect(firstEvent.detail).toEqual(detail);
+
+      expect(secondEvent.type).toEqual('my-custom:event');
+      expect(secondEvent.detail).toEqual(detail);
+    });
   });
 });
diff --git a/client/src/entrypoints/admin/draftail.test.js b/client/src/entrypoints/admin/draftail.test.js
--- a/client/src/entrypoints/admin/draftail.test.js
+++ b/client/src/entrypoints/admin/draftail.test.js
@@ -47,3 +47,65 @@ describe('draftail', () => {
     ).toEqual(['DOCUMENT', 'LINK', 'IMAGE', 'EMBED', 'undefined']);
   });
 });
+
+describe('Calling initEditor via event dispatching', () => {
+  const initEditor = window.draftail.initEditor;
+
+  beforeAll(() => {
+    /* eslint-disable no-console */
+    // mock console.error to ensure it does not bubble to the logs
+    jest.spyOn(console, 'error').mockImplementation(() => {});
+    jest.spyOn(window.draftail, 'initEditor').mockImplementation(() => {});
+  });
+
+  beforeEach(() => {
+    jest.resetAllMocks();
+  });
+
+  it.only('should support creating a new editor with event dispatching', async () => {
+    expect(window.draftail.initEditor).not.toHaveBeenCalled();
+
+    document.body.innerHTML = '<main><input id="editor"></main>';
+
+    document.getElementById('editor').dispatchEvent(
+      new CustomEvent('w-draftail:init', {
+        bubbles: true,
+        cancelable: false,
+        detail: { some: 'detail' },
+      }),
+    );
+
+    expect(console.error).toHaveBeenCalledTimes(0);
+    expect(window.draftail.initEditor).toHaveBeenCalledTimes(1);
+    expect(window.draftail.initEditor).toHaveBeenLastCalledWith(
+      '#editor',
+      { some: 'detail' },
+      null,
+    );
+  });
+
+  it('should not call initEditor & show an error in the console if the event has been dispatched incorrectly', async () => {
+    expect(window.draftail.initEditor).not.toHaveBeenCalled();
+
+    document.dispatchEvent(
+      new CustomEvent('w-draftail:init', {
+        bubbles: true,
+        cancelable: false,
+        detail: { some: 'detail' },
+      }),
+    );
+
+    expect(console.error).toHaveBeenCalledTimes(1);
+    expect(console.error).toHaveBeenCalledWith(
+      '`w-draftail:init` event must have a target with an id.',
+    );
+
+    expect(window.draftail.initEditor).not.toHaveBeenCalled();
+  });
+
+  afterAll(() => {
+    console.error.mockRestore();
+    window.draftail.initEditor.mockRestore();
+    /* eslint-enable no-console */
+  });
+});
diff --git a/wagtail/admin/tests/test_rich_text.py b/wagtail/admin/tests/test_rich_text.py
--- a/wagtail/admin/tests/test_rich_text.py
+++ b/wagtail/admin/tests/test_rich_text.py
@@ -4,7 +4,6 @@
 from django.test import SimpleTestCase, TestCase
 from django.test.utils import override_settings
 from django.urls import reverse, reverse_lazy
-from django.utils.html import format_html
 
 from wagtail.admin.rich_text import DraftailRichTextArea, get_rich_text_editor_widget
 from wagtail.admin.rich_text.converters.editor_html import PageLinkHandler
@@ -110,7 +109,9 @@ def test_default_editor_in_rich_text_field(self):
         self.assertEqual(response.status_code, 200)
 
         # Check that draftail (default editor) initialisation is applied
-        self.assertContains(response, "window.draftail.initEditor('#id_body',")
+        # Check that data-controller and data-w-init-event-value were added after initialization
+        self.assertContains(response, 'data-controller="w-init"')
+        self.assertContains(response, 'data-w-init-event-value="w-draftail:init"')
 
         # check that media for draftail is being imported
         self.assertContains(response, "wagtailadmin/js/draftail.js")
@@ -355,10 +356,20 @@ def test_settings_features_option_on_rich_text_field(self):
                 args=("tests", "defaultrichtextfieldpage", self.root_page.id),
             )
         )
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, '"type": "header-two"')
-        self.assertContains(response, '"type": "IMAGE"')
-        self.assertNotContains(response, '"type": "ordered-list-item"')
+        soup = self.get_soup(response.content)
+        input = soup.find(
+            "input",
+            {
+                "data-draftail-input": "",
+                "data-controller": "w-init",
+                "data-w-init-event-value": "w-draftail:init",
+            },
+        )
+        data = input["data-w-init-detail-value"]
+
+        self.assertIn('"type": "header-two"', data)
+        self.assertIn('"type": "IMAGE"', data)
+        self.assertNotIn('"type": "ordered-list-item"', data)
 
     @unittest.expectedFailure  # TODO(telepath)
     def test_features_option_on_rich_text_block(self):
@@ -402,16 +413,28 @@ def test_additional_features_should_not_be_included_by_default(self):
 
         self.assertEqual(response.status_code, 200)
         # default ones are there
-        self.assertContains(response, '"type": "header-two"')
-        self.assertContains(response, '"type": "LINK"')
-        self.assertContains(response, '"type": "ITALIC"')
+
+        soup = self.get_soup(response.content)
+        input = soup.find(
+            "input",
+            {
+                "data-draftail-input": "",
+                "data-controller": "w-init",
+                "data-w-init-event-value": "w-draftail:init",
+            },
+        )
+        data = input["data-w-init-detail-value"]
+
+        self.assertIn('"type": "header-two"', data)
+        self.assertIn('"type": "LINK"', data)
+        self.assertIn('"type": "ITALIC"', data)
 
         # not the additional ones.
-        self.assertNotContains(response, '"type": "CODE"')
-        self.assertNotContains(response, '"type": "blockquote"')
-        self.assertNotContains(response, '"type": "SUPERSCRIPT"')
-        self.assertNotContains(response, '"type": "SUBSCRIPT"')
-        self.assertNotContains(response, '"type": "STRIKETHROUGH"')
+        self.assertNotIn('"type": "CODE"', data)
+        self.assertNotIn('"type": "blockquote"', data)
+        self.assertNotIn('"type": "SUPERSCRIPT"', data)
+        self.assertNotIn('"type": "SUBSCRIPT"', data)
+        self.assertNotIn('"type": "STRIKETHROUGH"', data)
 
     @override_settings(
         WAGTAILADMIN_RICH_TEXT_EDITORS={
@@ -439,17 +462,29 @@ def test_additional_features_included(self):
         )
 
         self.assertEqual(response.status_code, 200)
+
+        soup = self.get_soup(response.content)
+        input = soup.find(
+            "input",
+            {
+                "data-draftail-input": "",
+                "data-controller": "w-init",
+                "data-w-init-event-value": "w-draftail:init",
+            },
+        )
+
+        data = input["data-w-init-detail-value"]
         # Added features are there
-        self.assertContains(response, '"type": "header-two"')
-        self.assertContains(response, '"type": "CODE"')
-        self.assertContains(response, '"type": "blockquote"')
-        self.assertContains(response, '"type": "SUPERSCRIPT"')
-        self.assertContains(response, '"type": "SUBSCRIPT"')
-        self.assertContains(response, '"type": "STRIKETHROUGH"')
+        self.assertIn('"type": "header-two"', data)
+        self.assertIn('"type": "CODE"', data)
+        self.assertIn('"type": "blockquote"', data)
+        self.assertIn('"type": "SUPERSCRIPT"', data)
+        self.assertIn('"type": "SUBSCRIPT"', data)
+        self.assertIn('"type": "STRIKETHROUGH"', data)
 
         # But not the unprovided default ones.
-        self.assertNotContains(response, '"type": "LINK"')
-        self.assertNotContains(response, '"type": "ITALIC"')
+        self.assertNotIn('"type": "LINK"', data)
+        self.assertNotIn('"type": "ITALIC"', data)
 
 
 class TestPageLinkHandler(WagtailTestUtils, TestCase):
@@ -545,51 +580,45 @@ def test_lazy_chooser_urls_resolved_correctly(self):
             )
         )
 
-        self.assertContains(
-            response, '"chooserUrls": {"imageChooser": "/admin/images/chooser/"}'
-        )
-        self.assertContains(
-            response, '"chooserUrls": {"embedsChooser": "/admin/embeds/chooser/"}'
-        )
-        self.assertContains(
-            response, '"chooserUrls": {"documentChooser": "/admin/documents/chooser/"}'
-        )
-
-        self.assertContains(
-            response,
-            '"chooserUrls": {"pageChooser": "/admin/choose-page/", "externalLinkChooser": "/admin/choose-external-link/", "emailLinkChooser": "/admin/choose-email-link/", "phoneLinkChooser": "/admin/choose-phone-link/", "anchorLinkChooser": "/admin/choose-anchor-link/"}',
+        soup = self.get_soup(response.content)
+        input = soup.find(
+            "input",
+            {
+                "data-draftail-input": "",
+                "data-controller": "w-init",
+                "data-w-init-event-value": "w-draftail:init",
+            },
         )
 
-    def test_lazy_urls_resolution(self):
-        widget = DraftailRichTextArea()
+        data = input["data-w-init-detail-value"]
 
-        pageChooser = format_html(
-            "<p>this link_Chooser link : <a href={page_chooser}>{page_chooser}</a></p>",
-            page_chooser=reverse_lazy("wagtailadmin_choose_page"),
+        self.assertIn(
+            '"chooserUrls": {"imageChooser": "/admin/images/chooser/"}',
+            data,
         )
-
-        imageChooser = format_html(
-            "<p>this image_chooser link : <a href={image_chooser}>{image_chooser}</a></p>",
-            image_chooser=reverse_lazy("wagtailimages_chooser:choose"),
+        self.assertIn(
+            '"chooserUrls": {"embedsChooser": "/admin/embeds/chooser/"}',
+            data,
         )
-
-        embedsChooser = format_html(
-            "<p>this embeds_chooser link: <a href={embeds_chooser}>{embeds_chooser}</a></p>",
-            embeds_chooser=reverse_lazy("wagtailembeds:chooser"),
+        self.assertIn(
+            '"chooserUrls": {"documentChooser": "/admin/documents/chooser/"}',
+            data,
         )
 
-        documentChooser = format_html(
-            "<p>this document_chooser link: <a href={document_chooser}>{document_chooser}</a></p>",
-            document_chooser=reverse_lazy("wagtaildocs_chooser:choose"),
+        self.assertIn(
+            '"chooserUrls": {"pageChooser": "/admin/choose-page/", "externalLinkChooser": "/admin/choose-external-link/", "emailLinkChooser": "/admin/choose-email-link/", "phoneLinkChooser": "/admin/choose-phone-link/", "anchorLinkChooser": "/admin/choose-anchor-link/"}',
+            data,
         )
 
-        rendered_html_link = widget.render("test_chooserUrls", pageChooser, {})
-        rendered_html_image = widget.render("test_chooserUrls", imageChooser, {})
-        rendered_html_embeds = widget.render("test_chooserUrls", embedsChooser, {})
-        rendered_html_document = widget.render("test_chooserUrls", documentChooser, {})
+    def test_lazy_urls_resolution(self):
+        """
+        Check that the lazy URLs have been resolved correctly in the rendered widget HTML data attributes.
+        """
+
+        widget = DraftailRichTextArea()
+        html = widget.render("test_chooserUrls", "", {})
 
-        # Assert that the lazy URLs has been resolved correctly
-        self.assertIn("/admin/choose-page/", rendered_html_link)
-        self.assertIn("/admin/images/chooser/", rendered_html_image)
-        self.assertIn("/admin/embeds/chooser/", rendered_html_embeds)
-        self.assertIn("/admin/documents/chooser/", rendered_html_document)
+        self.assertIn("/admin/choose-page/", html)
+        self.assertIn("/admin/images/chooser/", html)
+        self.assertIn("/admin/embeds/chooser/", html)
+        self.assertIn("/admin/documents/chooser/", html)