Repository: wagtail/wagtail

Issue:
🎛️ Migrate POST action via button pattern to a Stimulus Controller
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

We have a few implementations of JavaScript code that, when a button is clicked, create a `form` and `input` dynamically, then submit a POST request to that form.

* Lock or Unlock action `button` for locked pages
* Enable an item such as a workflow via a `button`

### Describe the solution you'd like

* Create a stimulus controller `w-action` that will replace the existing ad-hoc JS implementations
* The behaviour should be exactly the same as current state, with the exception of some refined handling of dynamic parameters (such as POST URL) and how we handle the CSRF token
* Controller should be written in TypeScript
* Ensure that the existing unit tests are created to reflect this new behaviour
* Ensure that there is a Storybook story for this component (this would be quite valuable and can easily be set up in storybook in a similar way to `w-auto-field` where we capture the `onSubmit` handler)
* We will need to document an upgrade consideration that `window.LockUnlockAction` will no longer be available and a small note (but not official documentation) about how to achieve the same behaviour with the new controller approach)

### Additional context

It may make sense to tackle one as a single PR initially though, be sure to use the CSRF token and key name.

#### Usage 1 - `_edit_js.html` (for enable action)

* `data-enable-action`
* wagtail/admin/templates/wagtailadmin/workflows/includes/_edit_js.html
* wagtail/admin/templates/wagtailadmin/workflows/edit_task.html
* wagtail/admin/templates/wagtailadmin/workflows/edit.html

##### Screenshots

| Editing disabled task | Editing disabled workflow |
|-|-|
| <img width="1577" alt="Screen Shot 2022-12-23 at 2 13 33 pm" src="https://user-images.githubusercontent.com/1396140/209269731-badc9c04-9f9b-46a9-996d-b59f23fc71fe.png"> | <img width="1580" alt="Screen Shot 2022-12-23 at 2 14 21 pm" src="https://user-images.githubusercontent.com/1396140/209269765-e6b5ff2f-2b3f-4e74-a7d0-3a7c2ce9e36a.png"> |

#### Usage 2 - `lock-unlock-action`

Needs util to pull in csrf token from `config`. See if there is a nicer way to do a POST request on the page instead of building a form.

* `data-action-lock-unlock`
* client/src/entrypoints/admin/lock-unlock-action.js
* wagtail/admin/templates/wagtailadmin/home/locked_pages.html
* wagtail/admin/templates/wagtailadmin/pages/edit.html
* wagtail/admin/templates/wagtailadmin/pages/index.html
* Supports a 'next' URL - so maybe do this one second as it's more complex

##### Screenshots

Note: There are more scenarios - just a quick set of examples.

| Locking in status side panel | Unlock in warning message | Unlock in locked pages report |
|-|-|-|
| <img width="1813" alt="Screen Shot 2022-12-23 at 2 16 26 pm" src="https://user-images.githubusercontent.com/1396140/209270029-d9491bf0-c286-423f-9939-47e089585980.png"> | <img width="1808" alt="Screen Shot 2022-12-23 at 2 16 39 pm" src="https://user-images.githubusercontent.com/1396140/209270056-9ce0d7b3-18fc-47f6-8830-c72d5993e104.png"> | <img width="1814" alt="Screen Shot 2022-12-23 at 2 17 11 pm" src="https://user-images.githubusercontent.com/1396140/209270076-f9a1a631-f9d4-4a5b-ac93-58cc9987a503.png"> |



#### See also

* https://github.com/wagtail/wagtail/pull/9530
* https://github.com/wagtail/wagtail/issues/9481
* https://github.com/wagtail/wagtail/issues/9576

Many issues introduced by including the lock button in other parts of code - confusion about which data attribute is for what 'thing' (value/controller) and also forgetting to include the JS whenever the widget is used.



Discussion:
Updated with a bunch of screenshots so we know where to find these action buttons.

Resolving patch:
diff --git a/wagtail/admin/views/generic/mixins.py b/wagtail/admin/views/generic/mixins.py
--- a/wagtail/admin/views/generic/mixins.py
+++ b/wagtail/admin/views/generic/mixins.py
@@ -433,7 +433,7 @@ def get_lock_context(self):
         if lock_message:
             if user_can_unlock:
                 lock_message = format_html(
-                    '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action-lock-unlock data-url="{}">{}</button></span>',
+                    '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
                     lock_message,
                     self.get_unlock_url(),
                     _("Unlock"),
@@ -441,7 +441,7 @@ def get_lock_context(self):
 
             if user_can_unschedule:
                 lock_message = format_html(
-                    '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action-lock-unlock data-url="{}">{}</button></span>',
+                    '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
                     lock_message,
                     reverse(
                         self.revisions_unschedule_url_name,
diff --git a/wagtail/admin/views/pages/edit.py b/wagtail/admin/views/pages/edit.py
--- a/wagtail/admin/views/pages/edit.py
+++ b/wagtail/admin/views/pages/edit.py
@@ -391,7 +391,7 @@ def get(self, request):
             if lock_message:
                 if isinstance(self.lock, BasicLock) and self.page_perms.can_unlock():
                     lock_message = format_html(
-                        '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action-lock-unlock data-url="{}">{}</button></span>',
+                        '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
                         lock_message,
                         reverse("wagtailadmin_pages:unlock", args=(self.page.id,)),
                         _("Unlock"),
@@ -402,7 +402,7 @@ def get(self, request):
                     and self.page_perms.can_unschedule()
                 ):
                     lock_message = format_html(
-                        '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action-lock-unlock data-url="{}">{}</button></span>',
+                        '{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
                         lock_message,
                         reverse(
                             "wagtailadmin_pages:revisions_unschedule",


Test patch:
diff --git a/client/src/controllers/ActionController.test.js b/client/src/controllers/ActionController.test.js
--- a/client/src/controllers/ActionController.test.js
+++ b/client/src/controllers/ActionController.test.js
@@ -16,17 +16,17 @@ describe('ActionController', () => {
     Application.start().register('w-action', ActionController);
   });
 
-  it('it should enable the workflow on click', () => {
+  it('it should enable the workflow, lock and Unlock button', () => {
     const btn = document.querySelector('[data-controller="w-action"]');
     const submitMock = jest.fn();
     window.HTMLFormElement.prototype.submit = submitMock;
 
     btn.click();
-
     const form = document.querySelector('form');
 
     expect(submitMock).toHaveBeenCalled();
     expect(form.action).toBe('https://www.github.com/');
     expect(new FormData(form).get('csrfmiddlewaretoken')).toBe('potato');
+    expect(new FormData(form).get('next')).toBe('http://localhost/');
   });
 });
diff --git a/wagtail/admin/tests/pages/test_page_locking.py b/wagtail/admin/tests/pages/test_page_locking.py
--- a/wagtail/admin/tests/pages/test_page_locking.py
+++ b/wagtail/admin/tests/pages/test_page_locking.py
@@ -154,11 +154,8 @@ def test_locked_pages_dashboard_panel(self):
         self.child_page.save()
         response = self.client.get(reverse("wagtailadmin_home"))
         self.assertContains(response, "Your locked pages")
-        # check that LockUnlockAction is present and passes a valid csrf token
-        self.assertRegex(
-            response.content.decode("utf-8"),
-            r"LockUnlockAction\(\'\w+\'\, \'\/admin\/'\)",
-        )
+        # check that Unlock button is present
+        self.assertContains(response, "Unlock")
 
     def test_unlock_post(self):
         # Lock the page
diff --git a/wagtail/snippets/tests/test_locking.py b/wagtail/snippets/tests/test_locking.py
--- a/wagtail/snippets/tests/test_locking.py
+++ b/wagtail/snippets/tests/test_locking.py
@@ -441,7 +441,7 @@ def test_edit_get_locked_by_self(self):
 
                 # Should show unlock buttons, one in the message and one in the side panel
                 self.assertTagInHTML(
-                    f'<button type="button" data-url="{unlock_url}" data-action-lock-unlock>Unlock</button>',
+                    f'<button type="button" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unlock_url}">Unlock</button>',
                     html,
                     count=2,
                     allow_extra_attrs=True,
@@ -490,7 +490,7 @@ def test_edit_get_locked_by_another_user_has_unlock_permission(self):
 
         # Should show unlock buttons, one in the message and one in the side panel
         self.assertTagInHTML(
-            f'<button type="button" data-url="{unlock_url}" data-action-lock-unlock>Unlock</button>',
+            f'<button type="button" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unlock_url}">Unlock</button>',
             html,
             count=2,
             allow_extra_attrs=True,
@@ -544,7 +544,7 @@ def test_edit_get_locked_by_another_user_no_unlock_permission(self):
 
         # Should not show unlock buttons
         self.assertTagInHTML(
-            f'<button type="button" data-url="{unlock_url}" data-action-lock-unlock>Unlock</button>',
+            f'<button type="button" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unlock_url}">Unlock</button>',
             html,
             count=0,
             allow_extra_attrs=True,
@@ -595,7 +595,7 @@ def test_edit_get_unlocked_no_lock_permission(self):
 
         # Should not show the lock button
         self.assertTagInHTML(
-            f'<button type="button" data-url="{lock_url}" data-action-lock-unlock>Lock</button>',
+            f'<button type="button" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{lock_url}">Lock</button>',
             html,
             count=0,
             allow_extra_attrs=True,
@@ -640,7 +640,7 @@ def test_edit_get_unlocked_has_lock_permission(self):
 
         # Should show the lock button
         self.assertTagInHTML(
-            f'<button type="button" data-url="{lock_url}" data-action-lock-unlock>Lock</button>',
+            f'<button type="button" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{lock_url}">Lock</button>',
             html,
             count=1,
             allow_extra_attrs=True,
diff --git a/wagtail/snippets/tests/test_snippets.py b/wagtail/snippets/tests/test_snippets.py
--- a/wagtail/snippets/tests/test_snippets.py
+++ b/wagtail/snippets/tests/test_snippets.py
@@ -3049,7 +3049,7 @@ def test_edit_get_scheduled_for_publishing_with_publish_permission(self):
             args=[self.test_snippet.pk, self.latest_revision.pk],
         )
         self.assertTagInHTML(
-            f'<button data-action-lock-unlock data-url="{unschedule_url}">Cancel scheduled publish</button>',
+            f'<button data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unschedule_url}">Cancel scheduled publish</button>',
             html,
             count=1,
             allow_extra_attrs=True,
@@ -3115,7 +3115,7 @@ def test_edit_get_scheduled_for_publishing_without_publish_permission(self):
             args=[self.test_snippet.pk, self.latest_revision.pk],
         )
         self.assertTagInHTML(
-            f'<button data-action-lock-unlock data-url="{unschedule_url}">Cancel scheduled publish</button>',
+            f'<button data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unschedule_url}">Cancel scheduled publish</button>',
             html,
             count=0,
             allow_extra_attrs=True,
@@ -3177,7 +3177,7 @@ def test_edit_post_scheduled_for_publishing(self):
             args=[self.test_snippet.pk, self.latest_revision.pk],
         )
         self.assertTagInHTML(
-            f'<button data-action-lock-unlock data-url="{unschedule_url}">Cancel scheduled publish</button>',
+            f'<button data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{unschedule_url}">Cancel scheduled publish</button>',
             html,
             count=1,
             allow_extra_attrs=True,