Repository: wagtail/wagtail

Issue:
🎛️ Migrate `initBlockWidget` & inline script usage to Stimulus `w-block` 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 unpacking of Telepath values to initialise StreamField blocks relies on inline scripts.

This approach is not CSP compliant and it would be good to find a better way to achieve the same goals. This change is quite small overall as Telepath's approach is solid and compliant, it's just how we 'run' the Telepath unpacking for this specific use case that will need to change.

### Describe the solution you'd like

A new Stimulus controller `BlockController` with the identifier `w-block` which would allow the unpacking and rendering of the unpacked block widget from Telepath JSON.

This would look something like the following:

```ts
  static values = { initial: {default: {}, type: Object}, ...data/error}
 # ... declare ...
  connect() {
    const output = window.telepath.unpack(this.dataValue);
    output.render(this.element, this.element.id, this.initialValue, this.errorValue);
    this.dispatch('ready', {detail: {...output}, cancelable: false });
  }
```

Finally, the controller would create a fallback global method that behaves the same as the `initBlockWidget` from `client/src/entrypoints/admin/telepath/blocks.js`. Which we may want to remove in a future release.

```ts
  /**
   * Ensure we have backwards compatibility for any usage of the original global function.
   *
   * @deprecated RemovedInWagtail70
   */
  static afterLoad() {
   // see client/src/controllers/TooltipController.ts for a similar approach.
    window.initBlockWidget = (id) => {
    // the same content as client/src/entrypoints/admin/telepath/blocks.js#L41-L63
    }
  }
```

The `BlockWidget` render_with_errors method would then be updated to look something like this;

```python
    def render_with_errors(self, name, value, attrs=None, errors=None, renderer=None):
        # ...
        # Note: We may still want to keep the data-block attribute around for a while, depending on assumptions in other code

        return format_html(
            """
                <div id="{id}" data-block data-controller="w-block" data-w-block-data-value="{block_json}" data-w-block-initial-value="{value_json}" data-w-block-error-value="{error_json}"></div>
            """,
            id=name,
            block_json=self.block_json,
            value_json=value_json,
            error_json=error_json,
        )
```

Checklist of changes to make

* New controller created with `connect` and `afterLoad` method.
* Unit tests created for new controller.
* Remove the `initBlockWidget` code from `wagtail/client/src/entrypoints/admin/telepath/blocks.js` as it will now be in the new controller's `afterLoad`.
* Update the Python in `wagtail/wagtail/blocks/base.py` to render the new HTML.
* Potentially update any Python unit tests to cover the changes.
* Add release notes considerations (can be done by core team member when merging).


### Describe alternatives you've considered

* We may not want a Telepath adapter like this, we may just want one specifically for blocks only (e.g. `w-block`).
* We may not want Stimulus and Telepath to work together like this, instead we could look at Telepath having its own set of mutation observers to see if things are in the DOM. However, this will duplicate a lot of what Stimulus provides for us.
* We may want to avoid Blocks being instantiated by Telepath unpack, however, this is unlikely and is also a huge change.

### Additional context

* This will essentially remove the large amount of inline JS used for every single block currently - helping us for CSP compliance https://github.com/wagtail/wagtail/issues/1288
* This is part of RFC 78 https://github.com/wagtail/rfcs/pull/78

Current `script` usage in [`blocks/base.py`](https://github.com/wagtail/wagtail/blob/f32c321cdd19f047d30449e493b22eff6adfe9b3/wagtail/blocks/base.py#L569-L580).

https://github.com/wagtail/wagtail/blob/f32c321cdd19f047d30449e493b22eff6adfe9b3/wagtail/blocks/base.py#L569-L580

Current [`window.initBlockWidget`](https://github.com/wagtail/wagtail/blob/f32c321cdd19f047d30449e493b22eff6adfe9b3/client/src/entrypoints/admin/telepath/blocks.js#L41-L63) implementation.

https://github.com/wagtail/wagtail/blob/f32c321cdd19f047d30449e493b22eff6adfe9b3/client/src/entrypoints/admin/telepath/blocks.js#L41-L63

### Working on this

* [X] We will want to give some of the core team a chance to review and give feedback on this approach.
* Anyone can contribute to this if they are able to work with Stimulus and create Jest 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 when possible, I would love your input on this. Let me know if you are happy with this approach or have some other ideas in mind to avoid inline scripts to attach Telepath/block rendering to elements as they appear in the DOM.
On a point of naming - Telepath is just the data format / library for converting the JSON definition passed from the server-side code in the `data-block` attribute into full-featured JS objects. Telepath itself has no opinion on what gets done with that data or when in the page lifecycle this happens - I'd consider the StreamField block rendering mechanism to be a component built _with_ Telepath, rather than functionality of Telepath itself.

Unless I'm missing something, I don't see the value of the first code example, where it's just unpacking a value and triggering a 'ready' event - since there would have to be some outside code listening to that event for it to be useful, and at that point the outside code might as well do the unpacking itself. It would be the equivalent of having a `w-json` controller that unpacks a JSON string and fires an event with the result.

With that aside, this really just becomes a question of eliminating the `<script>initBlockWidget('{id}');</script>` from BlockWidget - I don't think it generalises into something to be fixed for Telepath more widely. I'm definitely on board with using Stimulus for this - if I'm following right, the output of `render_with_errors` would then look something like:

    <div id="{id}" data-controller="w-blockwidget" data-w-blockwidget-block="{block_json}" data-w-blockwidget-value="{value_json}" data-w-blockwidget-error-value="{error_json}"></div>

and the `connect` method of the `w-blockwidget` controller would basically do what `initBlockWidget` is doing now.

As an indication of future direction - at some point (hopefully within 6.1) I'd like to extend the pattern of having Telepath adapters / JS objects corresponding to each form widget, from just within StreamField / BlockWidget to the form as a whole - see #11264 / #11266. This will allow serialising the whole form to a JSON structure, which I feel is going to be neater than than working with raw POST-data when we come to implement autosave; and reusing more code between ListBlock and InlinePanel. This might mean that we eventually move towards a setup where there's one entry point for initialising JS over the form as a whole, rather than each widget triggering that initialisation individually. As an illustration, consider what currently happens with chooser widgets: choosers that exist at the top level of the form (i.e. corresponding to a ForeignKey on the model) output a `<script>new FooChooser(id, opts)</script>` as part of their HTML rendering, but within a StreamField, the parent block will want to retain a reference to the FooChooser object for its own workings and therefore it's the parent that initiates creating the FooChooser object. Now, apply that thinking to a StreamField within an InlinePanel - the InlinePanel might well want to keep a reference to the StreamField / BlockWidget, and thus the InlinePanel is what kicks off the BlockWidget initialisation, rather than the `w-blockwidget` controller.

All of this is a roundabout way of saying that there might be scenarios in the not-too-distant future where the initialisation currently performed in `initBlockWidget` will happen somewhere else in the Telepath-based mechanism, and the Stimulus controller won't be needed in those scenarios. But that isn't a reason to hold off implementing it as a Stimulus controller now.
That's super helpful @gasman thank you. 

Yeah I've been trying to keep my head around some of the future form stuff coming down the line. If needed we can always delete this controller if it's not needed later. 

Would to be ok with `data-controller='w-block'` instead of `w-blockwidget`, or maybe the two word variant. `w-block-widget`. Benefit of the shorter name is it gets repeated a bit for each data attribute.

I'll look at updating this issue soon and see who might be up to work on it. 
Thanks @gasman  I have re-read everything again, I think your approach makes 100% sense. I have added some thoughts to the other ticket also. Not sure if that's helpful or annoying but hopefully it's OK.

I opted for the simpler `w-block` controller identifier and instead of the value for the initial data being `block` I kept it as `data`.

This way it's `data-w-block-data-value="..."` and not `data-w-block-block-value="..."` - both repeat words but the first felt a bit less error prone.

I will see if anyone is keen to pick up this issue now that it's ready to go.
should we split this into 2PRs or just one would be fine?
One will be fine 
cool i will make a PR very soon.
@kituuu - you may be keen on looking at this one. 

@the-r3aper7 - it's been a while and I know you're actively involved in some other PRs. 
I am sorry currently busy with college exams and other thing @kituuu you can continue with this PR.
Okay, I'm taking this @lb- @the-r3aper7 

Resolving patch:
diff --git a/wagtail/blocks/base.py b/wagtail/blocks/base.py
--- a/wagtail/blocks/base.py
+++ b/wagtail/blocks/base.py
@@ -564,14 +564,11 @@ def render_with_errors(self, name, value, attrs=None, errors=None, renderer=None
             error = errors.as_data()[0]
             error_json = json.dumps(get_error_json_data(error))
         else:
-            error_json = "null"
+            error_json = json.dumps(None)
 
         return format_html(
             """
-                <div id="{id}" data-block="{block_json}" data-value="{value_json}" data-error="{error_json}"></div>
-                <script>
-                    initBlockWidget('{id}');
-                </script>
+                <div id="{id}" data-block data-controller="w-block" data-w-block-data-value="{block_json}" data-w-block-arguments-value="[{value_json},{error_json}]"></div>
             """,
             id=name,
             block_json=self.block_json,


Test patch:
diff --git a/client/src/controllers/BlockController.test.js b/client/src/controllers/BlockController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/BlockController.test.js
@@ -0,0 +1,125 @@
+import { Application } from '@hotwired/stimulus';
+import { BlockController } from './BlockController';
+
+const render = jest.fn();
+const unpack = jest.fn(() => ({ render }));
+window.telepath = { unpack };
+
+describe('BlockController', () => {
+  const eventNames = ['w-block:ready'];
+
+  const events = {};
+
+  eventNames.forEach((name) => {
+    document.addEventListener(name, (event) => {
+      events[name].push(event);
+    });
+  });
+
+  let application;
+  let errors = [];
+
+  const setup = (html, { identifier = 'w-block' } = {}) => {
+    document.body.innerHTML = `<main>${html}</main>`;
+
+    application = new Application();
+
+    application.register(identifier, BlockController);
+
+    application.handleError = (error, message) => {
+      errors.push({ error, message });
+    };
+
+    application.start();
+
+    return Promise.resolve();
+  };
+
+  beforeEach(() => {
+    application?.stop();
+    document.body.innerHTML = '';
+    errors = [];
+    eventNames.forEach((name) => {
+      events[name] = [];
+    });
+    jest.clearAllMocks();
+  });
+
+  it('does nothing if block element is not found', async () => {
+    await setup('<div></div>');
+
+    expect(errors).toHaveLength(0);
+
+    expect(unpack).not.toHaveBeenCalled();
+
+    expect(events['w-block:ready']).toHaveLength(0);
+  });
+
+  it('should render block if element is controlled', async () => {
+    const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' };
+
+    await setup(
+      `<div
+        id="my-element"
+        data-controller="w-block"
+        data-w-block-data-value='${JSON.stringify(data)}'
+        >
+      </div>`,
+    );
+
+    expect(errors).toHaveLength(0);
+    expect(unpack).toHaveBeenCalledWith(data);
+    expect(render).toHaveBeenCalledWith(
+      document.getElementById('my-element'),
+      'my-element',
+    );
+    expect(events['w-block:ready']).toHaveLength(1);
+  });
+
+  it('should call the unpacked render function with provided initial & error data', async () => {
+    const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' };
+    const initialData = [{ type: 'paragraph_block', value: '...' }];
+    const errorData = { messages: ['An error...'] };
+
+    await setup(
+      `<div
+        id="my-element"
+        data-controller="w-block"
+        data-w-block-arguments-value='${JSON.stringify([initialData, errorData])}'
+        data-w-block-data-value='${JSON.stringify(data)}'
+        >
+      </div>`,
+    );
+
+    expect(errors).toHaveLength(0);
+    expect(unpack).toHaveBeenCalledWith(data);
+    expect(render).toHaveBeenCalledWith(
+      document.getElementById('my-element'),
+      'my-element',
+      initialData,
+      errorData,
+    );
+    expect(events['w-block:ready']).toHaveLength(1);
+  });
+
+  it('should throw an error if used on an element without an id', async () => {
+    await setup('<div data-controller="w-block"></div>');
+
+    expect(errors).toHaveLength(1);
+    expect(errors).toHaveProperty(
+      '0.error.message',
+      'Controlled element needs an id attribute.',
+    );
+  });
+
+  it('should throw an error if Telepath is not available in the window global', async () => {
+    delete window.telepath;
+    await setup('<div id="my-element" data-controller="w-block"></div>');
+
+    expect(errors).toHaveLength(1);
+    expect(errors).toHaveProperty(
+      '0.error.message',
+      '`window.telepath` is not available.',
+    );
+  });
+});
diff --git a/wagtail/admin/tests/pages/test_create_page.py b/wagtail/admin/tests/pages/test_create_page.py
--- a/wagtail/admin/tests/pages/test_create_page.py
+++ b/wagtail/admin/tests/pages/test_create_page.py
@@ -1494,7 +1494,13 @@ def test_inline_streamfield(self):
         self.assertEqual(response.status_code, 200)
 
         # response should include HTML declarations for streamfield child blocks
-        self.assertContains(response, '<div id="sections-__prefix__-body" data-block="')
+        self.assertContains(response, '<div id="sections-__prefix__-body" data-block')
+        soup = self.get_soup(response.content)
+        blockDiv = soup.find("div", {"data-controller": "w-block"})
+        self.assertIsNotNone(blockDiv)
+        # block div should contain this attributes
+        self.assertTrue(blockDiv.has_attr("data-w-block-arguments-value"))
+        self.assertTrue(blockDiv.has_attr("data-w-block-data-value"))
 
 
 class TestIssue2994(WagtailTestUtils, TestCase):