Repository: wagtail/wagtail

Issue:
🎛️ Migrate initDismissibles to a Stimulus controller `w-dismissible`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

We have a TypeScript implementation that when a button is clicked, it removes the parent element from the page, i.e dismiss it.

### Describe the solution you'd like

- Create a stimulus controller `w-dismissible` that will replace the TypeScript implementation
- The behaviour should be exactly the same as current state but using Stimulus data attributes for the behaviour & classes declaration
- The Controller should be written in TypeScript
- Ensure that the existing unit tests are created to reflect this new behaviour
- We will need to document an upgrade consideration that the previous data-attribute approach will no longer work

### Additional context

#### Current implementation

- [`client/src/includes/initDismissibles.ts`](https://github.com/wagtail/wagtail/blob/main/client/src/includes/initDismissibles.ts)
- [`client/src/includes/initDismissibles.test.js`](https://github.com/wagtail/wagtail/blob/main/client/src/includes/initDismissibles.test.js)
- [`wagtail/admin/templates/wagtailadmin/home/whats_new_in_wagtail_version.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/home/whats_new_in_wagtail_version.html)
- [`wagtail/admin/menu.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/menu.py#L47)

#### Current usage in `What's new in wagtail`.

```html
<section
  class="w-whats-new w-dismissible"
  aria-labelledby="whats-new-heading"
  data-wagtail-dismissible-id="{{ dismissible_id }}"
>
  <div class="w-whats-new__banner">
    <button
      class="w-whats-new__dismiss"
      type="button"
      data-wagtail-dismissible-toggle
    >
      {% icon name="circle-xmark" %}
      <span class="w-sr-only">{% trans "Close" %}</span>
    </button>
  </div>
</section>
```

#### Potential usage

```html
<section
  class="w-whats-new w-dismissible"
  aria-labelledby="whats-new-heading"
  data-controller="w-dismissible"
  data-w-dismissible-dismissed-class="w-dismissible--dismissed"
  data-w-dismissible-id-value="{{ dismissible_id }}"
>
  <div class="w-whats-new__banner">
    <button
      class="w-whats-new__dismiss"
      type="button"
      data-action="w-dismiss#dismissible"
    >
      {% icon name="circle-xmark" %}
      <span class="w-sr-only">{% trans "Close" %}</span>
    </button>
  </div>
</section>
```

Current usage in `help menu in sidebar widget

```python
class DismissibleMenuItemMixin:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.attrs["data-wagtail-dismissible-id"] = self.name

    def render_component(self, request):
        profile = getattr(request.user, "wagtail_userprofile", None)

        # Menu item instances are cached, so make sure the existence of the
        # data-wagtail-dismissed attribute is correct for the user
       if profile and profile.dismissibles.get(self.name):
            self.attrs["data-wagtail-dismissed"] = ""
        else:
           self.attrs.pop("data-wagtail-dismissed", None)

        return super().render_component(request)

```

### Resulting html

```html
<button
  data-wagtail-dismissible-id="help"
  class="sidebar-menu-item__link"
  aria-haspopup="menu"
  aria-expanded="false"
  type="button"
>
  <svg class="icon icon-help icon--menuitem" aria-hidden="true">
    <use href="#icon-help"></use>
  </svg>
  <span class="menuitem-label">Help</span>
  <svg
    class="icon icon-arrow-right sidebar-sub-menu-trigger-icon"
    aria-hidden="true"
  >
    <use href="#icon-arrow-right"></use>
  </svg>
</button>
```

> ### Potential html

```html
<button
  class="sidebar-menu-item__link "
  aria-haspopup="menu"
  data-controller="w-dismissible"
  data-action="w-dismissible#dismissible"
  data-w-dismissible-dismissed-class="w-dismissible--dismissed"
  data-w-dismissible-id-value="help"
>
  <svg class="icon icon-help icon--menuitem" aria-hidden="true">
    <use href="#icon-help"></use>
  </svg>
  <span class="menuitem-label">Help</span>
  <svg
    class="icon icon-arrow-right sidebar-sub-menu-trigger-icon"
    aria-hidden="true"
  >
    <use href="#icon-arrow-right"></use>
  </svg>
</button>
```

### Usage in Wagtail admin

![Image](https://user-images.githubusercontent.com/38161296/221683918-a244ee96-82ae-4b3e-813e-3114badfb9d1.png)

### Usage in help menu in sidebar

![Image](https://user-images.githubusercontent.com/38161296/221686113-da94e127-cc68-4423-b71c-0557349f3d5c.png)

### Additional helpful content

- https://github.com/wagtail/wagtail/pull/9344

🎛️ Migrate initDismissibles to a Stimulus controller `w-dismissible`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

We have a TypeScript implementation that when a button is clicked, it removes the parent element from the page, i.e dismiss it.

### Describe the solution you'd like

- Create a stimulus controller `w-dismissible` that will replace the TypeScript implementation
- The behaviour should be exactly the same as current state but using Stimulus data attributes for the behaviour & classes declaration
- The Controller should be written in TypeScript
- Ensure that the existing unit tests are created to reflect this new behaviour
- We will need to document an upgrade consideration that the previous data-attribute approach will no longer work

### Additional context

#### Current implementation

- [`client/src/includes/initDismissibles.ts`](https://github.com/wagtail/wagtail/blob/main/client/src/includes/initDismissibles.ts)
- [`client/src/includes/initDismissibles.test.js`](https://github.com/wagtail/wagtail/blob/main/client/src/includes/initDismissibles.test.js)
- [`wagtail/admin/templates/wagtailadmin/home/whats_new_in_wagtail_version.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/home/whats_new_in_wagtail_version.html)
- [`wagtail/admin/menu.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/menu.py#L47)

#### Current usage in `What's new in wagtail`.

```html
<section
  class="w-whats-new w-dismissible"
  aria-labelledby="whats-new-heading"
  data-wagtail-dismissible-id="{{ dismissible_id }}"
>
  <div class="w-whats-new__banner">
    <button
      class="w-whats-new__dismiss"
      type="button"
      data-wagtail-dismissible-toggle
    >
      {% icon name="circle-xmark" %}
      <span class="w-sr-only">{% trans "Close" %}</span>
    </button>
  </div>
</section>
```

#### Potential usage

```html
<section
  class="w-whats-new w-dismissible"
  aria-labelledby="whats-new-heading"
  data-controller="w-dismissible"
  data-w-dismissible-dismissed-class="w-dismissible--dismissed"
  data-w-dismissible-id-value="{{ dismissible_id }}"
>
  <div class="w-whats-new__banner">
    <button
      class="w-whats-new__dismiss"
      type="button"
      data-action="w-dismiss#dismissible"
    >
      {% icon name="circle-xmark" %}
      <span class="w-sr-only">{% trans "Close" %}</span>
    </button>
  </div>
</section>
```

Current usage in `help menu in sidebar widget

```python
class DismissibleMenuItemMixin:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.attrs["data-wagtail-dismissible-id"] = self.name

    def render_component(self, request):
        profile = getattr(request.user, "wagtail_userprofile", None)

        # Menu item instances are cached, so make sure the existence of the
        # data-wagtail-dismissed attribute is correct for the user
       if profile and profile.dismissibles.get(self.name):
            self.attrs["data-wagtail-dismissed"] = ""
        else:
           self.attrs.pop("data-wagtail-dismissed", None)

        return super().render_component(request)

```

### Resulting html

```html
<button
  data-wagtail-dismissible-id="help"
  class="sidebar-menu-item__link"
  aria-haspopup="menu"
  aria-expanded="false"
  type="button"
>
  <svg class="icon icon-help icon--menuitem" aria-hidden="true">
    <use href="#icon-help"></use>
  </svg>
  <span class="menuitem-label">Help</span>
  <svg
    class="icon icon-arrow-right sidebar-sub-menu-trigger-icon"
    aria-hidden="true"
  >
    <use href="#icon-arrow-right"></use>
  </svg>
</button>
```

> ### Potential html

```html
<button
  class="sidebar-menu-item__link "
  aria-haspopup="menu"
  data-controller="w-dismissible"
  data-action="w-dismissible#dismissible"
  data-w-dismissible-dismissed-class="w-dismissible--dismissed"
  data-w-dismissible-id-value="help"
>
  <svg class="icon icon-help icon--menuitem" aria-hidden="true">
    <use href="#icon-help"></use>
  </svg>
  <span class="menuitem-label">Help</span>
  <svg
    class="icon icon-arrow-right sidebar-sub-menu-trigger-icon"
    aria-hidden="true"
  >
    <use href="#icon-arrow-right"></use>
  </svg>
</button>
```

### Usage in Wagtail admin

![Image](https://user-images.githubusercontent.com/38161296/221683918-a244ee96-82ae-4b3e-813e-3114badfb9d1.png)

### Usage in help menu in sidebar

![Image](https://user-images.githubusercontent.com/38161296/221686113-da94e127-cc68-4423-b71c-0557349f3d5c.png)

### Additional helpful content

- https://github.com/wagtail/wagtail/pull/9344



Discussion:
Thanks @Lovelyfin00  I have reformatted this a bit as it looked like everything was indented, really appreciate you raising this.
> Thanks @Lovelyfin00 I have reformatted this a bit as it looked like everything was indented, really appreciate you raising this.

Great thanks!!
Thanks for writing this up!

I'm in favour of refactoring the dismissibles that are rendered with Django templates to use Stimulus. However, I would be a bit wary about the changes to the menu item. That component is rendered using React, and as such, the local dismissible state is handled separately using the state management that applies for that component (in this case a Redux state). This means that the `data-controller` attribute should likely not be applied to dismissible menu items.

That said, I assume `data-wagtail-dismissible-id` will be renamed to something else that's more in-line with our Stimulus conventions. When that happens, we should also update the attribute name for the dismissible menu items and update the React component's code accordingly, while still keeping the state management in React.

I'd like to note that we need to keep the name consistent and clear across the attributes, though. In the "Potential HTML" I see:

- `data-controller="w-dismiss"`
- `data-action="w-dismiss#dismissible"`
- `data-w-dismiss-dismissed-class`
- `data-w-dismissible-id-value`

Instead of `w-dismiss`, I'd suggest naming it `w-dismissible`. The reason is that "dismiss" is the action, while "dismissible" is the element, as outlined in:
- #9351

And to be more concise, can the `data-w-dismissible-id-value` be just `data-w-dismissible-id`?

With those changes, the potential HTML attributes would be:

- `data-controller="w-dismissible"`
- `data-action="w-dismissible#dismiss"`
- `data-w-dismissible-dismissed-class`
- `data-w-dismissible-id`

which I think look great 🙂 

Lastly, I'd kindly ask anyone who takes up on this issue to also add a comment to the above discussion (#9351) explaining the changes that have been made to the initial implementation once the PR has been merged.
@laymonage  great feedback, thanks for this. I have been digging into the nuance of the sidebar menu/sub-menu dismissible behaviour and agree that this does not really make sense in the Stimulus controller only as it has to 'update' when the parent menu item is clicked and also manage things like counts.

However, I would like to suggest we avoid exporting `updateDismissibles` from the controller and instead approach this a bit differently, here are some ideas;

1. **Util file** - create a 'util' that just has this `updateDismissibles` code in a different file, both the controller and the React code import this util and use to update in their isolated behaviours. In this case, the two implementations are quite disconnected and as noted, we would not make the link items a controlled element at all. React dismissibles would **not** be controlled elements, i.e. not have the data-controller.
3. **DOM Events** - The Stimulus controller is attached to any dismissible items, even those in React, and exposes an event listener for doing the updates (either just 'one' added in `afterLoad`) or per instance. Then the React code will dispatch a DOM event when any updates need to occur. The benefit here is the code and behaviour is all attached in one file and we are using DOM events to drive behaviour. React dismissibles **would** be controlled elements, i.e. have the data-controller. Downside is that we would have no way to to a `then` on the promise.
4. **Static property** - Make the `updateDismissibles` available as a static method on the controller, this is kind of a middle ground, where the connected code is all in one file but if other code needs to 'reach in' and grab the function, they can do that easily. React dismissibles would **not** be controlled elements, i.e. not have the data-controller.
5. **Leverage data attributes**. The constraint I see here is the desire to present a 'count' (state) on the parent menu items, this is not really something that we want to pull out of React as noted. If we did not have this we could actually just have React update the data attribute to in turn tell the Stimulus controller to update its state which is pretty epic. I think that may come in handy in the future but will not help us here. We could also make the 'parent' element controlled element, then when React determines the update needs to happen, it simply toggles a data attribute, this would happen in [`ActionMenuItem.tsx`](https://github.com/wagtail/wagtail/blob/main/client/src/components/Sidebar/menu/ActionMenuItem.tsx#L58), it's probably a larger change though and pushes more knowledge of the data attributes further down the React tree, I think this would be good to consider but probably too much effort than it's worth.

As for naming, that 100% makes sense, I suggested the shorter name mostly as a convenience, but agree we should align with the original naming justification.

However, the attribute for the id would instead be `data-w-dismissible-id-value='whats-new-in-wagtail-5.0'` (with the `value` at the end), same for whether the item is dismissed `data-w-dismissible-dismissed-value` (note: does not need to be `='true'` as per current state, just needs the word `value` at the end.

Let me know if this all makes sense and if there is any preference on the way we keep the `updateDismissibles`, I guess option 3 above is the simplest way to preserve current behaviour but also keep code together. It just means that React code is importing a Stimulus controller file.

I will then update this issue accordingly.
One thing I'd love to see if we can avoid is putting the admin API path for the dismiss update url into a global constant. 

Instead with option 2 above, this url can exist on the Dom element as a data attribute.

We would still keep the csrf token in the window global but it gives us a path forward for avoiding adding more and more URL paths globally. However, I understand that may not be something that we think matters. 

See. https://github.com/wagtail/wagtail/issues/9771
Idea. We go with putting the URL on the element, but keep the updateDismissibles function importable. Then the React code can call it with the provided URL as it's already reading the data attributes as is to determine if it's been dismissed. 

It would be pretty close to current state and gives us a lot more flexibility for future usage of the dismissible items. 
Hi @lb- 
I'm going to start working on this today.
Currently, is there a way I can get back the `what's new in wagtail` banner if I mistakenly click on the dismissible button?
Hey @Lovelyfin00, I usually change this method:

https://github.com/wagtail/wagtail/blob/e219968e60723815746ca2c6983b8a4152e13a7e/wagtail/admin/views/dismissibles.py#L10-L14

to something like

```py
    def get(self, request, *args, **kwargs):
        # The UserProfile may not exist for the user, in which case return an empty object
        profile = getattr(request.user, "wagtail_userprofile", None)
        dismissibles = profile.dismissibles if profile else {}
        # Empty dismissibles state for testing purposes
        # Note that the response will show the state before it's cleared
        profile.dismissibles = {}
        profile.save(update_fields=["dismissibles"])
        return JsonResponse(dismissibles)
```

Then access `/admin/dismissibles/` from the browser. It'll show you the current state before it's cleared. To verify, if you load it again, it should show an empty JSON object, and the admin will show all the dismissibles as if they haven't been dismissed.
> Hey @Lovelyfin00, I usually change this method:
> 
> https://github.com/wagtail/wagtail/blob/e219968e60723815746ca2c6983b8a4152e13a7e/wagtail/admin/views/dismissibles.py#L10-L14
> 
> to something like
> 
> ```python
>     def get(self, request, *args, **kwargs):
>         # The UserProfile may not exist for the user, in which case return an empty object
>         profile = getattr(request.user, "wagtail_userprofile", None)
>         dismissibles = profile.dismissibles if profile else {}
>         # Empty dismissibles state for testing purposes
>         # Note that the response will show the state before it's cleared
>         profile.dismissibles = {}
>         profile.save(update_fields=["dismissibles"])
>         return JsonResponse(dismissibles)
> ```
> 
> Then access `/admin/dismissibles/` from the browser. It'll show you the current state before it's cleared. To verify, if you load it again, it should show an empty JSON object, and the admin will show all the dismissibles as if they haven't been dismissed.

Nice 
This makes sense
So you're overriding and updating the current `dismissible` attribute in the user profile 
Thanks, Sage!
Awesome. This is way better than the approach I took - resetting the user each time I tested the feature. Thanks Sage. 
> 3\. **Static property** - Make the `updateDismissibles` available as a static method on the controller, this is kind of a middle ground, where the connected code is all in one file but if other code needs to 'reach in' and grab the function, they can do that easily. React dismissibles would **not** be controlled elements, i.e. not have the data-controller.

Hey @lb- 
I'm confused on this
I know that I can't access the updateDismissible method in the component using `this.getControllerForElementAndIdentifier` or `this.controllerForElement()`. How then can i acess the method?

### EDIT
Fixed
Thanks @Lovelyfin00  I have reformatted this a bit as it looked like everything was indented, really appreciate you raising this.
> Thanks @Lovelyfin00 I have reformatted this a bit as it looked like everything was indented, really appreciate you raising this.

Great thanks!!
Thanks for writing this up!

I'm in favour of refactoring the dismissibles that are rendered with Django templates to use Stimulus. However, I would be a bit wary about the changes to the menu item. That component is rendered using React, and as such, the local dismissible state is handled separately using the state management that applies for that component (in this case a Redux state). This means that the `data-controller` attribute should likely not be applied to dismissible menu items.

That said, I assume `data-wagtail-dismissible-id` will be renamed to something else that's more in-line with our Stimulus conventions. When that happens, we should also update the attribute name for the dismissible menu items and update the React component's code accordingly, while still keeping the state management in React.

I'd like to note that we need to keep the name consistent and clear across the attributes, though. In the "Potential HTML" I see:

- `data-controller="w-dismiss"`
- `data-action="w-dismiss#dismissible"`
- `data-w-dismiss-dismissed-class`
- `data-w-dismissible-id-value`

Instead of `w-dismiss`, I'd suggest naming it `w-dismissible`. The reason is that "dismiss" is the action, while "dismissible" is the element, as outlined in:
- #9351

And to be more concise, can the `data-w-dismissible-id-value` be just `data-w-dismissible-id`?

With those changes, the potential HTML attributes would be:

- `data-controller="w-dismissible"`
- `data-action="w-dismissible#dismiss"`
- `data-w-dismissible-dismissed-class`
- `data-w-dismissible-id`

which I think look great 🙂 

Lastly, I'd kindly ask anyone who takes up on this issue to also add a comment to the above discussion (#9351) explaining the changes that have been made to the initial implementation once the PR has been merged.
@laymonage  great feedback, thanks for this. I have been digging into the nuance of the sidebar menu/sub-menu dismissible behaviour and agree that this does not really make sense in the Stimulus controller only as it has to 'update' when the parent menu item is clicked and also manage things like counts.

However, I would like to suggest we avoid exporting `updateDismissibles` from the controller and instead approach this a bit differently, here are some ideas;

1. **Util file** - create a 'util' that just has this `updateDismissibles` code in a different file, both the controller and the React code import this util and use to update in their isolated behaviours. In this case, the two implementations are quite disconnected and as noted, we would not make the link items a controlled element at all. React dismissibles would **not** be controlled elements, i.e. not have the data-controller.
3. **DOM Events** - The Stimulus controller is attached to any dismissible items, even those in React, and exposes an event listener for doing the updates (either just 'one' added in `afterLoad`) or per instance. Then the React code will dispatch a DOM event when any updates need to occur. The benefit here is the code and behaviour is all attached in one file and we are using DOM events to drive behaviour. React dismissibles **would** be controlled elements, i.e. have the data-controller. Downside is that we would have no way to to a `then` on the promise.
4. **Static property** - Make the `updateDismissibles` available as a static method on the controller, this is kind of a middle ground, where the connected code is all in one file but if other code needs to 'reach in' and grab the function, they can do that easily. React dismissibles would **not** be controlled elements, i.e. not have the data-controller.
5. **Leverage data attributes**. The constraint I see here is the desire to present a 'count' (state) on the parent menu items, this is not really something that we want to pull out of React as noted. If we did not have this we could actually just have React update the data attribute to in turn tell the Stimulus controller to update its state which is pretty epic. I think that may come in handy in the future but will not help us here. We could also make the 'parent' element controlled element, then when React determines the update needs to happen, it simply toggles a data attribute, this would happen in [`ActionMenuItem.tsx`](https://github.com/wagtail/wagtail/blob/main/client/src/components/Sidebar/menu/ActionMenuItem.tsx#L58), it's probably a larger change though and pushes more knowledge of the data attributes further down the React tree, I think this would be good to consider but probably too much effort than it's worth.

As for naming, that 100% makes sense, I suggested the shorter name mostly as a convenience, but agree we should align with the original naming justification.

However, the attribute for the id would instead be `data-w-dismissible-id-value='whats-new-in-wagtail-5.0'` (with the `value` at the end), same for whether the item is dismissed `data-w-dismissible-dismissed-value` (note: does not need to be `='true'` as per current state, just needs the word `value` at the end.

Let me know if this all makes sense and if there is any preference on the way we keep the `updateDismissibles`, I guess option 3 above is the simplest way to preserve current behaviour but also keep code together. It just means that React code is importing a Stimulus controller file.

I will then update this issue accordingly.
One thing I'd love to see if we can avoid is putting the admin API path for the dismiss update url into a global constant. 

Instead with option 2 above, this url can exist on the Dom element as a data attribute.

We would still keep the csrf token in the window global but it gives us a path forward for avoiding adding more and more URL paths globally. However, I understand that may not be something that we think matters. 

See. https://github.com/wagtail/wagtail/issues/9771
Idea. We go with putting the URL on the element, but keep the updateDismissibles function importable. Then the React code can call it with the provided URL as it's already reading the data attributes as is to determine if it's been dismissed. 

It would be pretty close to current state and gives us a lot more flexibility for future usage of the dismissible items. 
Hi @lb- 
I'm going to start working on this today.
Currently, is there a way I can get back the `what's new in wagtail` banner if I mistakenly click on the dismissible button?
Hey @Lovelyfin00, I usually change this method:

https://github.com/wagtail/wagtail/blob/e219968e60723815746ca2c6983b8a4152e13a7e/wagtail/admin/views/dismissibles.py#L10-L14

to something like

```py
    def get(self, request, *args, **kwargs):
        # The UserProfile may not exist for the user, in which case return an empty object
        profile = getattr(request.user, "wagtail_userprofile", None)
        dismissibles = profile.dismissibles if profile else {}
        # Empty dismissibles state for testing purposes
        # Note that the response will show the state before it's cleared
        profile.dismissibles = {}
        profile.save(update_fields=["dismissibles"])
        return JsonResponse(dismissibles)
```

Then access `/admin/dismissibles/` from the browser. It'll show you the current state before it's cleared. To verify, if you load it again, it should show an empty JSON object, and the admin will show all the dismissibles as if they haven't been dismissed.
> Hey @Lovelyfin00, I usually change this method:
> 
> https://github.com/wagtail/wagtail/blob/e219968e60723815746ca2c6983b8a4152e13a7e/wagtail/admin/views/dismissibles.py#L10-L14
> 
> to something like
> 
> ```python
>     def get(self, request, *args, **kwargs):
>         # The UserProfile may not exist for the user, in which case return an empty object
>         profile = getattr(request.user, "wagtail_userprofile", None)
>         dismissibles = profile.dismissibles if profile else {}
>         # Empty dismissibles state for testing purposes
>         # Note that the response will show the state before it's cleared
>         profile.dismissibles = {}
>         profile.save(update_fields=["dismissibles"])
>         return JsonResponse(dismissibles)
> ```
> 
> Then access `/admin/dismissibles/` from the browser. It'll show you the current state before it's cleared. To verify, if you load it again, it should show an empty JSON object, and the admin will show all the dismissibles as if they haven't been dismissed.

Nice 
This makes sense
So you're overriding and updating the current `dismissible` attribute in the user profile 
Thanks, Sage!
Awesome. This is way better than the approach I took - resetting the user each time I tested the feature. Thanks Sage. 
> 3\. **Static property** - Make the `updateDismissibles` available as a static method on the controller, this is kind of a middle ground, where the connected code is all in one file but if other code needs to 'reach in' and grab the function, they can do that easily. React dismissibles would **not** be controlled elements, i.e. not have the data-controller.

Hey @lb- 
I'm confused on this
I know that I can't access the updateDismissible method in the component using `this.getControllerForElementAndIdentifier` or `this.controllerForElement()`. How then can i acess the method?

### EDIT
Fixed

Resolving patch:
diff --git a/wagtail/admin/menu.py b/wagtail/admin/menu.py
--- a/wagtail/admin/menu.py
+++ b/wagtail/admin/menu.py
@@ -44,17 +44,19 @@ def render_component(self, request):
 class DismissibleMenuItemMixin:
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.attrs["data-wagtail-dismissible-id"] = self.name
+        self.attrs["data-controller"] = "w-dismissible"
+        self.attrs["data-w-dismissible-dismissed-class"] = "w-dismissible--dismissed"
+        self.attrs["data-w-dismissible-id-value"] = self.name
 
     def render_component(self, request):
         profile = getattr(request.user, "wagtail_userprofile", None)
 
         # Menu item instances are cached, so make sure the existence of the
-        # data-wagtail-dismissed attribute is correct for the user
+        # data-w-dismissible-dismissed-value attribute is correct for the user
         if profile and profile.dismissibles.get(self.name):
-            self.attrs["data-wagtail-dismissed"] = ""
+            self.attrs["data-w-dismissible-dismissed-value"] = "true"
         else:
-            self.attrs.pop("data-wagtail-dismissed", None)
+            self.attrs.pop("data-w-dismissible-dismissed-value", None)
 
         return super().render_component(request)
 


Test patch:
diff --git a/client/src/controllers/DismissibleController.test.js b/client/src/controllers/DismissibleController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/DismissibleController.test.js
@@ -0,0 +1,103 @@
+import { Application } from '@hotwired/stimulus';
+import {
+  DismissibleController,
+  updateDismissibles,
+} from './DismissibleController';
+
+jest.mock('../config/wagtailConfig.js', () => ({
+  WAGTAIL_CONFIG: {
+    ADMIN_URLS: { DISMISSIBLES: '/admin/dismissibles/' },
+    CSRF_HEADER_NAME: 'X-CSRFToken',
+    CSRF_TOKEN: 'test-token',
+  },
+}));
+
+describe('DismissibleController', () => {
+  let application;
+  const data = { whats_new_in_wagtail_version_4: true };
+
+  beforeEach(() => {
+    application?.stop();
+
+    document.body.innerHTML = `
+    <section
+      id="main-content"
+      data-controller="w-dismissible"
+      data-w-dismissible-dismissed-class="w-dismissible--dismissed"
+      data-w-dismissible-id-value="whats_new_in_wagtail_version_4"
+      data-w-dismissible-target="content"
+    >
+      <button type="button" data-action="w-dismissible#toggle">X</button>
+    </section>
+    `;
+
+    application = Application.start();
+    application.register('w-dismissible', DismissibleController);
+  });
+
+  it("should add a 'dismissed' class and attribute when the dismiss button is clicked", () => {
+    const button = document.querySelector('button');
+    const mainContent = document.querySelector('#main-content');
+
+    expect(mainContent.classList).toHaveLength(0);
+    expect(
+      mainContent.getAttribute('data-w-dismissible-dismissed-value'),
+    ).toBeFalsy();
+    expect(mainContent.classList).not.toContain('w-dismissible--dismissed');
+
+    button.click();
+
+    expect(mainContent.classList).toContain('w-dismissible--dismissed');
+    expect(mainContent.getAttribute('data-w-dismissible-dismissed-value')).toBe(
+      'true',
+    );
+  });
+
+  it('should update the dismissible ids when the dismiss button is clicked', async () => {
+    expect.assertions(1);
+
+    const button = document.querySelector('button');
+
+    button.click();
+
+    await expect(global.fetch).toHaveBeenCalledWith('/admin/dismissibles/', {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        'X-CSRFToken': 'test-token',
+      },
+      body: JSON.stringify(data),
+      mode: 'same-origin',
+    });
+  });
+});
+
+describe('updateDismissibles', () => {
+  const data = { 'test-id-1': true };
+  document.body.innerHTML = `<div data-wagtail-dismissible-id='test-id-1' data-wagtail-dismissible-toggle></div>`;
+
+  it('should update the dismissible ids', async () => {
+    expect.assertions(1);
+
+    updateDismissibles(data);
+
+    await expect(global.fetch).toHaveBeenCalledWith('/admin/dismissibles/', {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        'X-CSRFToken': 'test-token',
+      },
+      body: JSON.stringify(data),
+      mode: 'same-origin',
+    });
+  });
+
+  it('should not update the dismissible ids', async () => {
+    expect.assertions(1);
+
+    updateDismissibles(data);
+
+    global.fetch.mockRejectedValueOnce(new Error('error'));
+    await expect(updateDismissibles(data)).rejects.toThrowError('error');
+  });
+});
diff --git a/client/src/includes/initDismissibles.test.js b/client/src/includes/initDismissibles.test.js
deleted file mode 100644
--- a/client/src/includes/initDismissibles.test.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import { initDismissibles, updateDismissibles } from './initDismissibles';
-
-jest.mock('../config/wagtailConfig.js', () => ({
-  WAGTAIL_CONFIG: {
-    ADMIN_URLS: { DISMISSIBLES: '/admin/dismissibles/' },
-    CSRF_HEADER_NAME: 'X-CSRFToken',
-    CSRF_TOKEN: 'test-token',
-  },
-}));
-
-describe('initDismissibles', () => {
-  it('should not error if there are no dismissibles', () => {
-    document.body.innerHTML = '<div>CONTENT</div>';
-
-    initDismissibles();
-
-    expect(document.body.innerHTML).toContain('CONTENT');
-  });
-
-  describe('should initialise dismissibles', () => {
-    it('for data-wagtail-dismissible-id and data-wagtail-dismissible-toggle attribute in parent and child', () => {
-      document.body.innerHTML = `
-      <article>
-        <div data-wagtail-dismissible-id='test-id-1'>
-          <div data-wagtail-dismissible-toggle></div>
-        </div>
-        <div data-wagtail-dismissible-id='test-id-2'>
-          <div data-wagtail-dismissible-toggle></div>
-        </div>
-        <div data-wagtail-dismissible-id='test-id-3 data-wagtail-dismissible-toggle></div>
-      </article>`;
-
-      initDismissibles();
-
-      // check the classes are initially empty
-      expect(
-        document.querySelector('[data-wagtail-dismissible-id]').classList,
-      ).toHaveLength(0);
-
-      // click all buttons
-      document
-        .querySelectorAll('[data-wagtail-dismissible-toggle]')
-        .forEach((item) => {
-          item.click();
-        });
-
-      // check the classes are updated and data attribute removed
-      expect(
-        [...document.querySelectorAll('[data-wagtail-dismissible-id]')].every(
-          (item) =>
-            item.classList.contains('w-dismissible--dismissed') &&
-            item.getAttribute('data-wagtail-dismissed') === '',
-        ),
-      ).toBe(true);
-    });
-  });
-});
-
-describe('updateDismissibles', () => {
-  const data = { 'test-id-1': true };
-  document.body.innerHTML = `<div data-wagtail-dismissible-id='test-id-1' data-wagtail-dismissible-toggle></div>`;
-
-  it('should update the dismissible ids', async () => {
-    expect.assertions(1);
-
-    updateDismissibles(data);
-
-    await expect(global.fetch).toHaveBeenCalledWith('/admin/dismissibles/', {
-      method: 'PATCH',
-      headers: {
-        'Content-Type': 'application/json',
-        'X-CSRFToken': 'test-token',
-      },
-      body: JSON.stringify(data),
-      mode: 'same-origin',
-    });
-  });
-
-  it('should not update the dismissible ids', async () => {
-    expect.assertions(1);
-
-    updateDismissibles(data);
-
-    global.fetch.mockRejectedValueOnce(new Error('error'));
-    await expect(updateDismissibles(data)).rejects.toThrowError('error');
-  });
-});
diff --git a/wagtail/admin/tests/test_menu.py b/wagtail/admin/tests/test_menu.py
--- a/wagtail/admin/tests/test_menu.py
+++ b/wagtail/admin/tests/test_menu.py
@@ -153,8 +153,13 @@ def test_dismissible_initial(self):
         self.assertEqual(
             rendered[0].attrs,
             # Should not be dismissed
-            {"data-wagtail-dismissible-id": "dismissible-submenu-menu-item"},
+            {
+                "data-controller": "w-dismissible",
+                "data-w-dismissible-dismissed-class": "w-dismissible--dismissed",
+                "data-w-dismissible-id-value": "dismissible-submenu-menu-item",
+            },
         )
+
         self.assertListEqual(
             rendered[0].menu_items,
             [
@@ -163,7 +168,11 @@ def test_dismissible_initial(self):
                     "Pages",
                     "/pages/",
                     # Should not be dismissed
-                    attrs={"data-wagtail-dismissible-id": "dismissible-menu-item"},
+                    attrs={
+                        "data-controller": "w-dismissible",
+                        "data-w-dismissible-dismissed-class": "w-dismissible--dismissed",
+                        "data-w-dismissible-id-value": "dismissible-menu-item",
+                    },
                 ),
             ],
         )
@@ -211,9 +220,11 @@ def test_dismissible_dismissed(self):
         self.assertEqual(
             rendered[0].attrs,
             {
-                "data-wagtail-dismissible-id": "dismissible-submenu-menu-item",
+                "data-controller": "w-dismissible",
+                "data-w-dismissible-dismissed-class": "w-dismissible--dismissed",
+                "data-w-dismissible-id-value": "dismissible-submenu-menu-item",
                 # Should be dismissed
-                "data-wagtail-dismissed": "",
+                "data-w-dismissible-dismissed-value": "true",
             },
         )
         self.assertListEqual(
@@ -225,8 +236,10 @@ def test_dismissible_dismissed(self):
                     "/pages/",
                     # Should be dismissed
                     attrs={
-                        "data-wagtail-dismissible-id": "dismissible-menu-item",
-                        "data-wagtail-dismissed": "",
+                        "data-controller": "w-dismissible",
+                        "data-w-dismissible-dismissed-class": "w-dismissible--dismissed",
+                        "data-w-dismissible-id-value": "dismissible-menu-item",
+                        "data-w-dismissible-dismissed-value": "true",
                     },
                 ),
             ],
@@ -271,7 +284,11 @@ def test_dismissible_no_userprofile(self):
         self.assertEqual(rendered[0].label, "My dismissible submenu")
         self.assertEqual(
             rendered[0].attrs,
-            {"data-wagtail-dismissible-id": "dismissible-submenu-menu-item"},
+            {
+                "data-controller": "w-dismissible",
+                "data-w-dismissible-dismissed-class": "w-dismissible--dismissed",
+                "data-w-dismissible-id-value": "dismissible-submenu-menu-item",
+            },
         )
         self.assertListEqual(
             rendered[0].menu_items,
@@ -280,7 +297,11 @@ def test_dismissible_no_userprofile(self):
                     "dismissible-menu-item",
                     "Pages",
                     "/pages/",
-                    attrs={"data-wagtail-dismissible-id": "dismissible-menu-item"},
+                    attrs={
+                        "data-controller": "w-dismissible",
+                        "data-w-dismissible-dismissed-class": "w-dismissible--dismissed",
+                        "data-w-dismissible-id-value": "dismissible-menu-item",
+                    },
                 ),
             ],
         )
diff --git a/wagtail/admin/tests/test_whats_new.py b/wagtail/admin/tests/test_whats_new.py
--- a/wagtail/admin/tests/test_whats_new.py
+++ b/wagtail/admin/tests/test_whats_new.py
@@ -29,10 +29,13 @@ def test_get_whats_new_banner_setting_false(self):
 
     def test_render_html_user_initial(self):
         result = self.panel.render_html(self.get_parent_context())
-        self.assertIn(
-            f'<section class="w-whats-new w-dismissible" aria-labelledby="whats-new-heading" data-wagtail-dismissible-id="{self.dismissible_id}">',
-            result,
-        )
+        expected_data_attrs = [
+            'data-controller="w-dismissible"',
+            'data-w-dismissible-dismissed-class="w-dismissible--dismissed"',
+            f'data-w-dismissible-id-value="{self.dismissible_id}"',
+        ]
+        for data_attr in expected_data_attrs:
+            self.assertIn(data_attr, result)
         self.assertIn("Things in Wagtail 4 have changed!", result)
 
     @override_settings(WAGTAIL_ENABLE_WHATS_NEW_BANNER=False)
@@ -44,10 +47,13 @@ def test_render_html_user_no_profile(self):
         self.profile.delete()
         self.user.refresh_from_db()
         result = self.panel.render_html(self.get_parent_context())
-        self.assertIn(
-            f'<section class="w-whats-new w-dismissible" aria-labelledby="whats-new-heading" data-wagtail-dismissible-id="{self.dismissible_id}">',
-            result,
-        )
+        expected_data_attrs = [
+            'data-controller="w-dismissible"',
+            'data-w-dismissible-dismissed-class="w-dismissible--dismissed"',
+            f'data-w-dismissible-id-value="{self.dismissible_id}"',
+        ]
+        for data_attr in expected_data_attrs:
+            self.assertIn(data_attr, result)
         self.assertIn("Things in Wagtail 4 have changed!", result)
 
     def test_render_html_user_dismissed(self):
@@ -70,29 +76,41 @@ def get(self):
 
     def test_get_enabled_initial(self):
         response = self.get()
-        self.assertContains(
-            response,
-            f'<section class="w-whats-new w-dismissible" aria-labelledby="whats-new-heading" data-wagtail-dismissible-id="{self.dismissible_id}">',
-        )
+        html_content = response.content.decode("utf-8")
+        expected_data_attrs = [
+            'data-controller="w-dismissible"',
+            'data-w-dismissible-dismissed-class="w-dismissible--dismissed"',
+            f'data-w-dismissible-id-value="{self.dismissible_id}"',
+        ]
+        for data_attr in expected_data_attrs:
+            self.assertIn(data_attr, html_content)
         self.assertContains(response, "Things in Wagtail 4 have changed!")
 
     @override_settings(WAGTAIL_ENABLE_WHATS_NEW_BANNER=False)
     def test_get_disabled_initial(self):
         response = self.get()
-        self.assertNotContains(
-            response,
-            f'<section class="w-whats-new w-dismissible" aria-labelledby="whats-new-heading" data-wagtail-dismissible-id="{self.dismissible_id}">',
-        )
+        html_content = response.content.decode("utf-8")
+        expected_data_attrs = [
+            'data-controller="w-dismissible"',
+            'data-w-dismissible-dismissed-class="w-dismissible--dismissed"',
+            f'data-w-dismissible-id-value="{self.dismissible_id}"',
+        ]
+        for data_attr in expected_data_attrs:
+            self.assertNotIn(data_attr, html_content)
         self.assertNotContains(response, "Things in Wagtail 4 have changed!")
 
     def test_render_html_user_no_profile(self):
         self.profile.delete()
         self.user.refresh_from_db()
         response = self.get()
-        self.assertContains(
-            response,
-            f'<section class="w-whats-new w-dismissible" aria-labelledby="whats-new-heading" data-wagtail-dismissible-id="{self.dismissible_id}">',
-        )
+        html_content = response.content.decode("utf-8")
+        expected_data_attrs = [
+            'data-controller="w-dismissible"',
+            'data-w-dismissible-dismissed-class="w-dismissible--dismissed"',
+            f'data-w-dismissible-id-value="{self.dismissible_id}"',
+        ]
+        for data_attr in expected_data_attrs:
+            self.assertIn(data_attr, html_content)
         self.assertContains(response, "Things in Wagtail 4 have changed!")
 
     def test_get_enabled_dismissed(self):
@@ -100,8 +118,12 @@ def test_get_enabled_dismissed(self):
         self.profile.save(update_fields=["dismissibles"])
 
         response = self.get()
-        self.assertNotContains(
-            response,
-            f'<section class="w-whats-new w-dismissible" aria-labelledby="whats-new-heading" data-wagtail-dismissible-id="{self.dismissible_id}">',
-        )
+        html_content = response.content.decode("utf-8")
+        expected_data_attrs = [
+            'data-controller="w-dismissible"',
+            'data-w-dismissible-dismissed-class="w-dismissible--dismissed"',
+            f'data-w-dismissible-id-value="{self.dismissible_id}"',
+        ]
+        for data_attr in expected_data_attrs:
+            self.assertNotIn(data_attr, html_content)
         self.assertNotContains(response, "Things in Wagtail 4 have changed!")