Repository: wagtail/wagtail

Issue:
🎛️ Migrate `autosize` jQuery UI to a Stimulus controller `w-auto-size`
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

At the moment we have an ad-hoc jQuery UI implementation of the textarea auto resize functionality, this also uses inline `scripts` to instantiate to the HTML elements when they are loaded in the DOM.

We have other JavaScript that checks for things like changed tabs and dynamic HTML injected via Telepath to manage all the ways this HTML could be added and ensure the jQuery UI behaviour is re-run.

It would be great to have this implementation migrated to Stimulus so that it can easily be attached to dynamically injected HTML content and be a more flexible and testable solution. This would also likely remove a large amount of ad-hoc code along with inline scripts in the process.

<img width="1144" alt="Screenshot 2023-02-28 at 8 20 23 pm" src="https://user-images.githubusercontent.com/1396140/221825627-27d30f62-3375-4712-9720-2e090a4a49f6.png">


### Describe the solution you'd like

#### Current implementation

[`wagtail/admin/widgets/auto_height_text.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/widgets/auto_height_text.py)

[`wagtail/admin/templates/wagtailadmin/widgets/auto_height_text_input.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/widgets/auto_height_text_input.html)

[`client/src/entrypoints/admin/core.js`](https://github.com/wagtail/wagtail/blob/main/client/src/entrypoints/admin/core.js#L205)

[`client/src/entrypoints/admin/telepath/widgets.js`](https://github.com/wagtail/wagtail/blob/main/client/src/entrypoints/admin/telepath/widgets.js#L131)

#### Proposed implementation

Install the `npm` package [`autosize`](https://www.npmjs.com/package/autosize), as this is the underlying library that powers the existing jQuery widget but is much more recently updated.

Create a new Stimulus Controller `w-auto-size` / `AutoSizeController` that will use the `autosize` library on `connect`. It will attach the util to the `this.element` and set it to something like `this.autosize` so that when `disconnect` is called, it can `destroy` the `autosize` instance.

The actual controller will be very simple, only a connect/disconnect method and some basic unit tests.

The larger areas of change will be to ensure that all current usage aligns with the data attributes.

- Update `wagtail/admin/widgets/auto_height_text.py` to no longer need the `template_name` but instead set the `data-controller` attribute on the `attrs`.
- Remove `wagtail/admin/templates/wagtailadmin/widgets/auto_height_text_input.html` completely.
- Remove `wagtailadmin/js/vendor/jquery.autosize.js` from `wagtail/admin/templates/wagtailadmin/admin_base.html`
- Remove the code relating to `resizeTextAreas` in `client/src/controllers/AutoSizeController.ts`. **Important:** Re-validate #8442
- We should no longer need any Telepath adapter and tests for `AdminAutoHeightTextInput` in `client/src/entrypoints/admin/telepath/widgets.js` as this is now 100% handled by Stimulus.

#### Validation check-list

* [ ] Simple HTML on load
* [ ] Non-primary tab with the widget, switch back & forth between tabs - #8442
* [ ] Blocks using the textarea
* [ ] Browsers
* [ ] Empty content on load
* [ ] Large (10+ lines) content on load

### Describe alternatives you've considered

- We should replace this code, it is a solid example of the Stimulus code being easier to reason about, reduces a lot of extra JS and complexity while also being more secure.

### Additional context

- This is part of a larger goal to migrate to Stimulus
- Stimulus documentation - https://stimulus.hotwired.dev/handbook/introduction



Discussion:
@gasman  - if we use Stimulus for the `AdminAutoHeightTextInput` - am I right in thinking that if all JS init behaviour is tied to the single `data-controller` attribute, we actually will not need the Telepath adapter at all.

I have read through the code and all it appears to be doing is calling `window.autosize($('#' + id));` and from what I understand, normal HTML widgets render fine as blocks without needing a specific Telepath adapter. It should just use the `Widget` base class and that handles the basic HTML rendering as is.

I did a quick smoke test locally and it seemed fine without the Telepath widget but wanted to see if any other considerations should be added to this issue.

@lb- Yes, I think you're right. The places where telepath adapters for widgets come into play are:

* Dynamically creating new instances of the widget, including initialising any JS behaviour. Since Stimulus gives you the JS behaviour "for free" upon inserting the HTML fragment - and inserting the HTML is handled by the base Widget adapter - a dedicated adapter isn't required for this.
* Getting a handle to an existing widget on the page, that we can use to work with that element, primarily getting/setting its value. (This is more of a planned future addition - currently it's only implemented on chooser widgets.) Since getting/setting the value of an auto-height text input is no different from a standard HTML one, again no dedicated code is required for this.

Hypothetically, if we needed to ensure that the element height is updated when programmatically changing its content, and for some reason it wasn't possible to make that work when writing directly to its `value` attribute, then there might be a need to provide an adapter that handles that as part of a dedicated `setState` method. But we're well into "future what-if scenarios" territory there :-)
Thanks Matt. That helps me understand. It may even be possible in the future to get the Telepath instance to automatically grab the Stimulus instance for those kinds of behaviours. There is a nifty application method `getControllerForElementAndIdentifier` that allows for this. 

Because it's just a JS class, it would be possible to expose those setters/getters from Stimulus when needed. 

Anyway - future what ifs, but great to understand more. 
I'm labelling this as a good first issue, it will require reading up on Stimulus and knowing how to install a node package but mostly it's quite straightforward. 

A friendly reminder to please go through the new contributors guide if you are looking at making your first contribution ever. 


https://github.com/wagtail/wagtail/issues/10070
@lb- @gasman Is there any test case needed to be implemented in order to go through the implementation checklist?
@suyash5053 do you mean unit tests or manual testing?

For unit tests - just a validation that the autosize function gets called is sufficient, happy for this to even be wrapped in a mock. 

For manual - be sure you understand where this field is used and what it is, so you know how to make it appear when using bakery demo. 
@gasman @lb- I've read this issue thoroughly and I understood that the code needs to be converted into the functionality of Stimulus to make the code clean.

Can you please attach the documentation link so that I can read about Stimulus.

> @gasman @lb- I've read this issue thoroughly and I understood that the code needs to be converted into the functionality of Stimulus to make the code clean.
> 
> Can you please attach the documentation link so that I can read about Stimulus.

https://stimulus.hotwired.dev/handbook/introduction
there you go, @AceHunterr 
thanks

Hello @gasman @lb-  I am Sudeep Ranjan Sahoo and I was working on this issue and have resolved it by creating an AutoSizeController that uses autosize on connect and have updated the other files which includes  auto_height_text.py removed the auto_height_text_input.html file and the script from the admin_base.html.I have also written a unit test case.
Here is my pr https://github.com/wagtail/wagtail/pull/10190 
Please review it.
![Screenshot from 2023-03-06 14-50-25](https://user-images.githubusercontent.com/104081457/223078718-63341cdf-d8fe-45eb-9301-4865be7e2ab2.png)

Thanks @srs-sudeep - no need to mention people though, usually those interested in issues will be watching the issue. 

I'll flag that PR for review 
Thanks!
@srs-sudeep it's a good start but still needs a fair bit of work before it's ready for a proper review. 

Also note that a few others above have already indicated that they've started working on this. @AceHunterr or @suyash5053 - fee free to continue on this if you have already started. 

Others may already have a PR coming so just a reminder to keep an eye out for comments as you may be duplicating your efforts.
Thank you for your feedback @lb- I will consider all your feedbacks and take a look into my code.


Resolving patch:
diff --git a/wagtail/admin/widgets/auto_height_text.py b/wagtail/admin/widgets/auto_height_text.py
--- a/wagtail/admin/widgets/auto_height_text.py
+++ b/wagtail/admin/widgets/auto_height_text.py
@@ -1,15 +1,13 @@
 from django.forms import widgets
 
-from wagtail.telepath import register
-from wagtail.widget_adapters import WidgetAdapter
-
 
 class AdminAutoHeightTextInput(widgets.Textarea):
-    template_name = "wagtailadmin/widgets/auto_height_text_input.html"
-
     def __init__(self, attrs=None):
-        # Use more appropriate rows default, given autoheight will alter this anyway
-        default_attrs = {"rows": "1"}
+        # Use more appropriate rows default, given autosize will alter this anyway
+        default_attrs = {
+            "rows": 1,
+            "data-controller": "w-autosize",
+        }
         if attrs:
             default_attrs.update(attrs)
 
@@ -20,10 +18,3 @@ def __init__(self, attrs=None):
             default_attrs["class"] = "w-field__autosize"
 
         super().__init__(default_attrs)
-
-
-class AdminAutoHeightTextInputAdapter(WidgetAdapter):
-    js_constructor = "wagtail.widgets.AdminAutoHeightTextInput"
-
-
-register(AdminAutoHeightTextInputAdapter(), AdminAutoHeightTextInput)


Test patch:
diff --git a/client/src/controllers/AutosizeController.test.js b/client/src/controllers/AutosizeController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/AutosizeController.test.js
@@ -0,0 +1,119 @@
+import { Application } from '@hotwired/stimulus';
+import autosize from 'autosize';
+import { AutosizeController } from './AutosizeController';
+
+jest.mock('autosize');
+jest.useFakeTimers();
+
+describe('AutosizeController', () => {
+  let application;
+  const resizeObserverMockObserve = jest.fn();
+  const resizeObserverMockUnobserve = jest.fn();
+  const resizeObserverMockDisconnect = jest.fn();
+
+  const ResizeObserverMock = jest.fn().mockImplementation(() => ({
+    observe: resizeObserverMockObserve,
+    unobserve: resizeObserverMockUnobserve,
+    disconnect: resizeObserverMockDisconnect,
+  }));
+
+  global.ResizeObserver = ResizeObserverMock;
+
+  describe('basic behaviour', () => {
+    beforeAll(() => {
+      document.body.innerHTML = `
+      <textarea
+        data-controller="w-autosize"
+        id="text"
+      ></textarea>
+      `;
+    });
+
+    afterEach(() => {
+      jest.clearAllMocks();
+    });
+
+    afterAll(() => {
+      application.stop();
+    });
+
+    it('calls autosize when connected', async () => {
+      expect(autosize).not.toHaveBeenCalled();
+      expect(ResizeObserverMock).not.toHaveBeenCalled();
+      expect(resizeObserverMockObserve).not.toHaveBeenCalled();
+
+      application = Application.start();
+      application.register('w-autosize', AutosizeController);
+
+      // await next tick
+      await Promise.resolve();
+
+      const textarea = document.getElementById('text');
+
+      expect(autosize).toHaveBeenCalledWith(textarea);
+      expect(ResizeObserverMock).toHaveBeenCalledWith(expect.any(Function));
+      expect(resizeObserverMockObserve).toHaveBeenCalledWith(textarea);
+    });
+
+    it('cleans up on disconnect', async () => {
+      expect(autosize.destroy).not.toHaveBeenCalled();
+      expect(resizeObserverMockUnobserve).not.toHaveBeenCalled();
+
+      const textarea = document.getElementById('text');
+
+      textarea.remove();
+
+      await Promise.resolve();
+
+      expect(autosize.destroy).toHaveBeenCalledWith(textarea);
+      expect(resizeObserverMockDisconnect).toHaveBeenCalled();
+    });
+  });
+
+  describe('using actions to dispatch methods', () => {
+    beforeAll(() => {
+      document.body.innerHTML = `
+      <textarea
+        id="text"
+        data-controller="w-autosize"
+        data-action="some:event->w-autosize#resize"
+      ></textarea>`;
+
+      application = Application.start();
+      application.register('w-autosize', AutosizeController);
+    });
+
+    afterEach(() => {
+      jest.clearAllMocks();
+    });
+
+    afterAll(() => {
+      application.stop();
+    });
+
+    it('calls autosize update from resize method', async () => {
+      // await next tick
+      await Promise.resolve();
+
+      expect(autosize.update).not.toHaveBeenCalled();
+
+      const textarea = document.getElementById('text');
+
+      textarea.dispatchEvent(new CustomEvent('some:event'));
+      jest.runAllTimers(); // resize is debounced
+
+      expect(autosize.update).toHaveBeenCalledWith(textarea);
+
+      // fire multiple events - confirm that the function is debounced
+
+      expect(autosize.update).toHaveBeenCalledTimes(1);
+      textarea.dispatchEvent(new CustomEvent('some:event'));
+      textarea.dispatchEvent(new CustomEvent('some:event'));
+      textarea.dispatchEvent(new CustomEvent('some:event'));
+      textarea.dispatchEvent(new CustomEvent('some:event'));
+      jest.runAllTimers(); // resize is debounced
+
+      expect(autosize.update).toHaveBeenCalledTimes(2);
+    });
+  });
+});
diff --git a/client/src/entrypoints/admin/telepath/widgets.test.js b/client/src/entrypoints/admin/telepath/widgets.test.js
--- a/client/src/entrypoints/admin/telepath/widgets.test.js
+++ b/client/src/entrypoints/admin/telepath/widgets.test.js
@@ -211,64 +211,6 @@ describe('telepath: wagtail.widgets.Select', () => {
   });
 });
 
-describe('telepath: wagtail.widgets.AdminAutoHeightTextInput', () => {
-  let boundWidget;
-
-  beforeEach(() => {
-    window.autosize = jest.fn();
-
-    // Create a placeholder to render the widget
-    document.body.innerHTML = '<div id="placeholder"></div>';
-
-    // Unpack and render a textarea using the AdminAutoHeightTextInput widget
-    const widgetDef = window.telepath.unpack({
-      _type: 'wagtail.widgets.AdminAutoHeightTextInput',
-      _args: [
-        '<textarea name="__NAME__" cols="40" rows="1" id="__ID__"></textarea>',
-        '__ID__',
-      ],
-    });
-    boundWidget = widgetDef.render(
-      document.getElementById('placeholder'),
-      'the-name',
-      'the-id',
-      'The Value',
-    );
-  });
-
-  test('it renders correctly', () => {
-    expect(document.body.innerHTML).toBe(
-      '<textarea name="the-name" cols="40" rows="1" id="the-id"></textarea>',
-    );
-    expect(document.querySelector('textarea').value).toBe('The Value');
-  });
-
-  test('window.autosize was called', () => {
-    expect(window.autosize.mock.calls.length).toBe(1);
-    expect(window.autosize.mock.calls[0][0].get(0)).toBe(
-      document.querySelector('textarea'),
-    );
-  });
-
-  test('getValue() returns the current value', () => {
-    expect(boundWidget.getValue()).toBe('The Value');
-  });
-
-  test('getState() returns the current state', () => {
-    expect(boundWidget.getState()).toBe('The Value');
-  });
-
-  test('setState() changes the current state', () => {
-    boundWidget.setState('The new Value');
-    expect(document.querySelector('textarea').value).toBe('The new Value');
-  });
-
-  test('focus() focuses the text input', () => {
-    boundWidget.focus();
-    expect(document.activeElement).toBe(document.querySelector('textarea'));
-  });
-});
-
 describe('telepath: wagtail.widgets.DraftailRichTextArea', () => {
   let boundWidget;
   let inputElement;