Repository: wagtail/wagtail

Issue:
🎛️ Migrate Publishing fields resets to use Stimulus controller `w-action`
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

We currently have a 10 line JS file that ensures some fields get reset when you close the publishing dialog. The overhead for this means we have to include this file in our Webpack bundle as a separate asset instead of leveraging the Stimulus building blocks we already have.

This ad-hoc JS would make sense to be part of our existing `ActionController` (`w-action`) and should be able to be supported with only a small addition to that existing controller.

Migrating this over will also mean that we have more compliant (for CSP) implementation as we wont have to worry about getting this script to load with a separate `nonce` (if we go in that direction).

### Describe the solution you'd like

#### Current implementation

1. [`client/src/entrypoints/admin/schedule-publishing.js`](https://github.com/wagtail/wagtail/blob/953c980976231d6d2e1d26a58595710dcf10cc71/client/src/entrypoints/admin/schedule-publishing.js) - Contains the JavaScript implementation for the resetting of the fields.
2. Note that it uses a built in DOM API [`defaultvalue`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement)
3. [wagtail/admin/panels/publishing_panel.py](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/panels/publishing_panel.py#L60) loads the script via `def media(self):`
4. Webpack config `client/webpack.config.js` has the `schedule-publishing` that builds this single file into a standalone asset.


<img width="1008" alt="Screenshot 2023-10-11 at 8 59 44 pm" src="https://github.com/wagtail/wagtail/assets/1396140/efdfdc6b-76ac-4fc4-a0f3-d0e99a3e520c">


#### Proposed implementation

##### 1. Enhance [`ActionController`](https://github.com/wagtail/wagtail/blob/main/client/src/controllers/ActionController.ts)

A new method `reset` can be added to this controller, something like this should do the trick.

- method: `reset`
- It should accept a `value` being provided by the event detail or Stimulus action params
- It should default this new value to the Input's `defaultvalue` if not supplied.
- It should not attempt to change the input, if there is no difference.
- It should dispatch a `change` event, but only if changed.

```ts
  /**
   * Reset the field to a supplied or the field's initial value (default).
   * Only update if the value to change to is different from the current value.
   */
  reset(
    event: CustomEvent<{ value?: string }> & { params?: { value?: string } },
  ) {
    const target = this.element;
    const currentValue = target.value;

    const { value: newValue = '' } = {
      value: target instanceof HTMLInputElement ? target.defaultValue : '',
      ...event?.params,
      ...event?.detail,
    };

    if (currentValue === newValue) return;

    target.value = newValue;
    this.dispatch('change', {
      bubbles: true,
      cancelable: false,
      target,
    });
  }
```

From here, we can use this in any input as follows.

```html
<input value="the default" type="text" data-controller="w-action" data-action="some-event@document->w-action#reset" />
```

Whenever `some-event` is fired, this field will reset itself to it's initial (default) value.

##### 2. Update [DialogController](https://github.com/wagtail/wagtail/blob/main/client/src/controllers/DialogController.ts) to support notify targets

The goal here is to have a way for the dialog to dispatch an event 'internally' to a nominated target, this way the elements inside do not have to listen to global events.

```diff
export class DialogController extends Controller<HTMLElement> {
  # ... static values
  static targets = ['body', 'notify'];

  # ... declare
+  /** Optional targets that will be dispatched events for key dialog events. */
+  declare readonly notifyTargets: HTMLElement[];

  connect() {
    this.dialog = new A11yDialog(this.element);
    const detail = { body: this.bodyTarget, dialog: this.dialog };
    const isFloating = this.themeValue === FLOATING;
    this.dialog
      .on('show', () => {
        if (!isFloating) document.documentElement.style.overflowY = 'hidden';
-        this.dispatch('shown', { detail });
+        this.dispatch('shown', { detail, cancelable: false });
+        this.notifyTargets.forEach((target) => {
+          this.dispatch('shown', { target, bubbles: false, cancelable: false });
+        });
      })
      .on('hide', () => {
        if (!isFloating) document.documentElement.style.overflowY = '';
-        this.dispatch('hidden', { detail });
+        this.dispatch('hidden', { detail, cancelable: false });
+        this.notifyTargets.forEach((target) => {
+          this.dispatch('hidden', {
+            target,
+            bubbles: false,
+            cancelable: false,
+          });
        });
      });

    this.dispatch('ready', { detail, cancelable: false });
+    this.notifyTargets.forEach((target) => {
+      this.dispatch('ready', { target, bubbles: false, cancelable: false });
+    });
    return this.dialog;
  }
  # .. other methods
}
```


##### 3. Add the attributes to the two date fields in [`wagtail/admin/panels/publishing_panel.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/panels/publishing_panel.py)

Now that we have the controller, we can pass the data attributes we need to the two fields. Additionally, we have a way to ensure that the event for only this specific dialog 'close' event can be dispatched to the right element via the `data-w-dialog-target="notify"`.

This is quite straight foward thanks to Django's `attrs` on widgets approach.

```diff
class PublishingPanel(MultiFieldPanel):
    def __init__(self, **kwargs):
        js_overlay_parent_selector = "#schedule-publishing-dialog"
        updated_kwargs = {
            "children": [
                FieldPanel(
                    "go_live_at",
                    widget=AdminDateTimeInput(
                        js_overlay_parent_selector=js_overlay_parent_selector,
+                        attrs={
+                            "data-controller": "w-action",
+                            "data-action": "w-dialog:hidden->w-action#reset",
+                            "data-w-dialog-target": "notify",
+                        },
                    ),
                ),
                FieldPanel(
                    "expire_at",
                    widget=AdminDateTimeInput(
                        js_overlay_parent_selector=js_overlay_parent_selector,
+                        attrs={
+                            "data-controller": "w-action",
+                            "data-action": "w-dialog:hidden->w-action#reset",
+                            "data-w-dialog-target": "notify",
+                        },
                    ),
                ),
            ],
            "classname": "publishing",
        }
        updated_kwargs.update(kwargs)
        super().__init__(**updated_kwargs)
```


##### 4. Remove the existing JS file

* Remove the Media property on PublishingPanel.BoundPanel so that the JS does not get loaded
* Remove the schedule-publishing entry block in client/webpack.config.js
* Delete client/src/entrypoints/admin/schedule-publishing.js


```diff
class PublishingPanel(MultiFieldPanel):
    # ...
    class BoundPanel(MultiFieldPanel.BoundPanel):
        # ...
-        @property
-        def media(self):
-            return super().media + Media(
-                js=[versioned_static("wagtailadmin/js/schedule-publishing.js")],
-            )
```


##### 5. The real work (tests)

All this should be functional, the behaviour should be the same as before (closing the publishing modal and re-opening should reset the fields back to their previous values).

However, we will need to ensure that there are solid unit tests for the JS behaviour introduced to ActionController in `client/src/controllers/ActionController.test.js`.

Additionally, we should ensure that the Python tests check that the data attributes get added so these do not get missed in the future. Maybe a simple check in `wagtail/admin/tests/test_edit_handlers.py` that the right data attributes exist on one of the fields will be fine. See the test `test_publishing_panel_shown_by_default`.

### Describe alternatives you've considered

- Leave code as is.
- Implement a more 1:1 replacement of this behaviour where only the ONE dialog closing will trigger this behaviour, this will be more complex but could be done with some dialog template tag enhancements. See https://github.com/wagtail/wagtail/issues/11030 for how we could set this up.
  - Update: Opted for a simpler approach (see proposed solution, step 2) where we allow dialogs to dispatch events internally also.

### Additional context

- This is part of a larger goal to migrate to Stimulus and support CSP compliance.
- See previous PRs that built out this ActionController for reference #9844 #10040 #10212


🎛️ Migrate Publishing fields resets to use Stimulus controller `w-action`
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

We currently have a 10 line JS file that ensures some fields get reset when you close the publishing dialog. The overhead for this means we have to include this file in our Webpack bundle as a separate asset instead of leveraging the Stimulus building blocks we already have.

This ad-hoc JS would make sense to be part of our existing `ActionController` (`w-action`) and should be able to be supported with only a small addition to that existing controller.

Migrating this over will also mean that we have more compliant (for CSP) implementation as we wont have to worry about getting this script to load with a separate `nonce` (if we go in that direction).

### Describe the solution you'd like

#### Current implementation

1. [`client/src/entrypoints/admin/schedule-publishing.js`](https://github.com/wagtail/wagtail/blob/953c980976231d6d2e1d26a58595710dcf10cc71/client/src/entrypoints/admin/schedule-publishing.js) - Contains the JavaScript implementation for the resetting of the fields.
2. Note that it uses a built in DOM API [`defaultvalue`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement)
3. [wagtail/admin/panels/publishing_panel.py](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/panels/publishing_panel.py#L60) loads the script via `def media(self):`
4. Webpack config `client/webpack.config.js` has the `schedule-publishing` that builds this single file into a standalone asset.


<img width="1008" alt="Screenshot 2023-10-11 at 8 59 44 pm" src="https://github.com/wagtail/wagtail/assets/1396140/efdfdc6b-76ac-4fc4-a0f3-d0e99a3e520c">


#### Proposed implementation

##### 1. Enhance [`ActionController`](https://github.com/wagtail/wagtail/blob/main/client/src/controllers/ActionController.ts)

A new method `reset` can be added to this controller, something like this should do the trick.

- method: `reset`
- It should accept a `value` being provided by the event detail or Stimulus action params
- It should default this new value to the Input's `defaultvalue` if not supplied.
- It should not attempt to change the input, if there is no difference.
- It should dispatch a `change` event, but only if changed.

```ts
  /**
   * Reset the field to a supplied or the field's initial value (default).
   * Only update if the value to change to is different from the current value.
   */
  reset(
    event: CustomEvent<{ value?: string }> & { params?: { value?: string } },
  ) {
    const target = this.element;
    const currentValue = target.value;

    const { value: newValue = '' } = {
      value: target instanceof HTMLInputElement ? target.defaultValue : '',
      ...event?.params,
      ...event?.detail,
    };

    if (currentValue === newValue) return;

    target.value = newValue;
    this.dispatch('change', {
      bubbles: true,
      cancelable: false,
      target,
    });
  }
```

From here, we can use this in any input as follows.

```html
<input value="the default" type="text" data-controller="w-action" data-action="some-event@document->w-action#reset" />
```

Whenever `some-event` is fired, this field will reset itself to it's initial (default) value.

##### 2. Update [DialogController](https://github.com/wagtail/wagtail/blob/main/client/src/controllers/DialogController.ts) to support notify targets

The goal here is to have a way for the dialog to dispatch an event 'internally' to a nominated target, this way the elements inside do not have to listen to global events.

```diff
export class DialogController extends Controller<HTMLElement> {
  # ... static values
  static targets = ['body', 'notify'];

  # ... declare
+  /** Optional targets that will be dispatched events for key dialog events. */
+  declare readonly notifyTargets: HTMLElement[];

  connect() {
    this.dialog = new A11yDialog(this.element);
    const detail = { body: this.bodyTarget, dialog: this.dialog };
    const isFloating = this.themeValue === FLOATING;
    this.dialog
      .on('show', () => {
        if (!isFloating) document.documentElement.style.overflowY = 'hidden';
-        this.dispatch('shown', { detail });
+        this.dispatch('shown', { detail, cancelable: false });
+        this.notifyTargets.forEach((target) => {
+          this.dispatch('shown', { target, bubbles: false, cancelable: false });
+        });
      })
      .on('hide', () => {
        if (!isFloating) document.documentElement.style.overflowY = '';
-        this.dispatch('hidden', { detail });
+        this.dispatch('hidden', { detail, cancelable: false });
+        this.notifyTargets.forEach((target) => {
+          this.dispatch('hidden', {
+            target,
+            bubbles: false,
+            cancelable: false,
+          });
        });
      });

    this.dispatch('ready', { detail, cancelable: false });
+    this.notifyTargets.forEach((target) => {
+      this.dispatch('ready', { target, bubbles: false, cancelable: false });
+    });
    return this.dialog;
  }
  # .. other methods
}
```


##### 3. Add the attributes to the two date fields in [`wagtail/admin/panels/publishing_panel.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/panels/publishing_panel.py)

Now that we have the controller, we can pass the data attributes we need to the two fields. Additionally, we have a way to ensure that the event for only this specific dialog 'close' event can be dispatched to the right element via the `data-w-dialog-target="notify"`.

This is quite straight foward thanks to Django's `attrs` on widgets approach.

```diff
class PublishingPanel(MultiFieldPanel):
    def __init__(self, **kwargs):
        js_overlay_parent_selector = "#schedule-publishing-dialog"
        updated_kwargs = {
            "children": [
                FieldPanel(
                    "go_live_at",
                    widget=AdminDateTimeInput(
                        js_overlay_parent_selector=js_overlay_parent_selector,
+                        attrs={
+                            "data-controller": "w-action",
+                            "data-action": "w-dialog:hidden->w-action#reset",
+                            "data-w-dialog-target": "notify",
+                        },
                    ),
                ),
                FieldPanel(
                    "expire_at",
                    widget=AdminDateTimeInput(
                        js_overlay_parent_selector=js_overlay_parent_selector,
+                        attrs={
+                            "data-controller": "w-action",
+                            "data-action": "w-dialog:hidden->w-action#reset",
+                            "data-w-dialog-target": "notify",
+                        },
                    ),
                ),
            ],
            "classname": "publishing",
        }
        updated_kwargs.update(kwargs)
        super().__init__(**updated_kwargs)
```


##### 4. Remove the existing JS file

* Remove the Media property on PublishingPanel.BoundPanel so that the JS does not get loaded
* Remove the schedule-publishing entry block in client/webpack.config.js
* Delete client/src/entrypoints/admin/schedule-publishing.js


```diff
class PublishingPanel(MultiFieldPanel):
    # ...
    class BoundPanel(MultiFieldPanel.BoundPanel):
        # ...
-        @property
-        def media(self):
-            return super().media + Media(
-                js=[versioned_static("wagtailadmin/js/schedule-publishing.js")],
-            )
```


##### 5. The real work (tests)

All this should be functional, the behaviour should be the same as before (closing the publishing modal and re-opening should reset the fields back to their previous values).

However, we will need to ensure that there are solid unit tests for the JS behaviour introduced to ActionController in `client/src/controllers/ActionController.test.js`.

Additionally, we should ensure that the Python tests check that the data attributes get added so these do not get missed in the future. Maybe a simple check in `wagtail/admin/tests/test_edit_handlers.py` that the right data attributes exist on one of the fields will be fine. See the test `test_publishing_panel_shown_by_default`.

### Describe alternatives you've considered

- Leave code as is.
- Implement a more 1:1 replacement of this behaviour where only the ONE dialog closing will trigger this behaviour, this will be more complex but could be done with some dialog template tag enhancements. See https://github.com/wagtail/wagtail/issues/11030 for how we could set this up.
  - Update: Opted for a simpler approach (see proposed solution, step 2) where we allow dialogs to dispatch events internally also.

### Additional context

- This is part of a larger goal to migrate to Stimulus and support CSP compliance.
- See previous PRs that built out this ActionController for reference #9844 #10040 #10212




Discussion:
This is not a good first issue but someone who wants to do a second or third issue with some TypeScript/Python mix of code plus a heavy focus on Jest unit tests could give this a solid go.

@lb- I could give it a shot and see how far I go with it if that's okay by you. I might ask a lot of questions though. 😅 
Go for it @Chiemezuo - maybe start with a PR just just solves the change to the ActionController. This could be a really useful standalone PR and avoids you needing to solve the whole thing in one go. 

Just a reminder to pull down the latest `main` Branch and then make a new branch off that. It'll avoid merge conflicts later. 
@lb- So, the PR should just be for changing the ActionController file (step 1)?
And that I should do steps 2, 3 and 4 in a different PR?
Up to you @Chiemezuo - but each PR would need to cover the unit tests for introduced behaviour. 
Alright @lb- I'll take them one at a time.
@Chiemezuo  I have updated the proposed implementation to include some feedback from https://github.com/wagtail/wagtail/issues/11030

Have a read when you get a min and see what you think.
It looks pretty good to me.
@Chiemezuo just commenting back to your question on the w-init issue. 

It might be best to focus on this one first, leave the w-init one to someone else or wait until this is done. 

Once the dialog controller change is done, the rest of this can probably just be one PR. Maybe start reading through the rest and testing the suggestes code changes locally. 
Not a problem at all. I'll start prepping for steps 3 and 4.
This is not a good first issue but someone who wants to do a second or third issue with some TypeScript/Python mix of code plus a heavy focus on Jest unit tests could give this a solid go.

@lb- I could give it a shot and see how far I go with it if that's okay by you. I might ask a lot of questions though. 😅 
Go for it @Chiemezuo - maybe start with a PR just just solves the change to the ActionController. This could be a really useful standalone PR and avoids you needing to solve the whole thing in one go. 

Just a reminder to pull down the latest `main` Branch and then make a new branch off that. It'll avoid merge conflicts later. 
@lb- So, the PR should just be for changing the ActionController file (step 1)?
And that I should do steps 2, 3 and 4 in a different PR?
Up to you @Chiemezuo - but each PR would need to cover the unit tests for introduced behaviour. 
Alright @lb- I'll take them one at a time.
@Chiemezuo  I have updated the proposed implementation to include some feedback from https://github.com/wagtail/wagtail/issues/11030

Have a read when you get a min and see what you think.
It looks pretty good to me.
@Chiemezuo just commenting back to your question on the w-init issue. 

It might be best to focus on this one first, leave the w-init one to someone else or wait until this is done. 

Once the dialog controller change is done, the rest of this can probably just be one PR. Maybe start reading through the rest and testing the suggestes code changes locally. 
Not a problem at all. I'll start prepping for steps 3 and 4.

Resolving patch:
diff --git a/wagtail/admin/panels/publishing_panel.py b/wagtail/admin/panels/publishing_panel.py
--- a/wagtail/admin/panels/publishing_panel.py
+++ b/wagtail/admin/panels/publishing_panel.py
@@ -1,6 +1,3 @@
-from django.forms import Media
-
-from wagtail.admin.staticfiles import versioned_static
 from wagtail.admin.widgets.datetime import AdminDateTimeInput
 from wagtail.models import Page
 
@@ -20,12 +17,22 @@ def __init__(self, **kwargs):
                     "go_live_at",
                     widget=AdminDateTimeInput(
                         js_overlay_parent_selector=js_overlay_parent_selector,
+                        attrs={
+                            "data-controller": "w-action",
+                            "data-action": "w-dialog:hidden->w-action#reset",
+                            "data-w-dialog-target": "notify",
+                        },
                     ),
                 ),
                 FieldPanel(
                     "expire_at",
                     widget=AdminDateTimeInput(
                         js_overlay_parent_selector=js_overlay_parent_selector,
+                        attrs={
+                            "data-controller": "w-action",
+                            "data-action": "w-dialog:hidden->w-action#reset",
+                            "data-w-dialog-target": "notify",
+                        },
                     ),
                 ),
             ],
@@ -53,9 +60,3 @@ def get_context_data(self, parent_context=None):
 
         def show_panel_furniture(self):
             return False
-
-        @property
-        def media(self):
-            return super().media + Media(
-                js=[versioned_static("wagtailadmin/js/schedule-publishing.js")],
-            )


Test patch:
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
@@ -1987,6 +1987,17 @@ def test_publishing_panel_shown_by_default(self):
         form = form_class()
         self.assertTrue(form.show_schedule_publishing_toggle)
 
+        # Get the "expire_at" input field from the form
+        expire_at_input = form.fields["expire_at"].widget
+        data_controller = expire_at_input.attrs.get("data-controller", None)
+        data_action = expire_at_input.attrs.get("data-action", None)
+        data_w_dialog_target = expire_at_input.attrs.get("data-w-dialog-target", None)
+
+        # Check that suitable data attributes for resetting the fields on dialog close are added
+        self.assertEqual(data_controller, "w-action")
+        self.assertEqual(data_action, "w-dialog:hidden->w-action#reset")
+        self.assertEqual(data_w_dialog_target, "notify")
+
     def test_form(self):
         """
         Check that the form has the scheduled publishing fields