Repository: wagtail/wagtail

Issue:
🎛️ Migrate `initSlugCleaning` to a Stimulus Controller `w-slug`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

We have an implementation of ad-hoc JS to ensure that when a field is blurred, it will slugify the contents. It would be good for this behaviour to adopt the Stimulus controller approach so that it is more reusable and testable.

Additionally, the copy page view has a slug field for the copy and this does not use the auto-slugify behaviour, it would be good for this to adopt the same logic.

Finally, we are setting a global `unicodeSlugsEnabled` which would be good to avoid if possible as it pollutes the `window` global.

### Screenshots of behaviour

<img width="966" alt="Screenshot 2023-02-15 at 9 45 12 pm" src="https://user-images.githubusercontent.com/1396140/219018861-11dc8638-69c4-46d2-8f4b-94a1f94e5277.png">

<img width="970" alt="Screenshot 2023-02-15 at 9 45 19 pm" src="https://user-images.githubusercontent.com/1396140/219018880-0a988878-968e-46c5-9e99-f4a1e1c4a215.png">


### Describe the solution you'd like

#### Part 1 - Create a `w-slug` Stimulus controller

This controller should be written in TypeScript and have a baseline of unit tests, it should have the following capabilities.

* The controller should have the type of `HTMLInputElement`
* value: allowUnicodeSlugs (boolean) default false
* method: `slugify` which will call the util `cleanForSlug()` from `client/src/utils/text.ts` - with the second param `useURLify` always false, and passing in the option `unicodeSlugsEnabled` based on the controller's value
* Unit test coverage should use the `blur` event to check that the content is slugified and check the difference in behaviour with the unicode option

This should mean we can replace and remove the current `initSlugCleaning` code, but not yet the `initSlugAutoPopulate` code.

https://github.com/wagtail/wagtail/blob/55483b5424b1df707afbf38ca2298dba5095ec00/client/src/entrypoints/admin/page-editor.js#L33-L41

We can also then remove the calling of `initSlugCleaning` in that same file.

#### Part 2 - Create a new `SlugInput` widget

The goal here is to create a shared widget that sets up the correct Stimulus data attributes.

Create a file `wagtail/admin/widgets/slug.py` and within it a new widget `SlugInput`, it should set up the following data attributes (roughly). You can use https://github.com/wagtail/wagtail/blob/main/wagtail/admin/widgets/auto_height_text.py as a good starting point of a widget that mostly just adds different attributes.

```
"data-controller": "w-slug",
"data-action": "blur->w-slug#slugify",
"data-w-slug-allow-unicode-slugs-value": getattr(settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True)
```

See Django's docs on the base TextInput here https://docs.djangoproject.com/en/4.1/ref/forms/widgets/#django.forms.TextInput

#### Part 3 - Adopt the `SlugInput` widget on the Page form & the copy page form

Adopt in `wagtail/admin/panels/page_utils.py`

```python
    cls.promote_panels = [
        MultiFieldPanel(
            [
                FieldPanel( "slug", widget=SlugInput), # import from the file created above
                FieldPanel("seo_title"),
                FieldPanel("search_description"),
            ],
```

We can also add the widget usage in `wagtail/admin/forms/pages.py`, with something like this...

```python
        self.fields["new_slug"] = forms.SlugField(
            initial=self.page.slug,
            label=_("New slug"),
            allow_unicode=allow_unicode,
            widget=SlugInput,
#...
````

### Part 4 - clean up

We will need to add something to the release notes that the undocumented `initSlugCleaning` will no longer be supported. We may want to document the `w-slug` usage in the release notes but we do NOT want to provide a fall-back (e.g. using `afterLoad) this time.

Ideally it would be nice to trim whitespace while we are at it, the existing behaviour does not do this but it would make sense.

### Describe alternatives you've considered

* Leave as is, but this means we cannot move away from page-editor.js used everywhere.

### Additional context

* This is part of a larger goal to migrate to Stimulus.
* This is also part of a larger goal to avoid `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` being used everywhere and minimise global usage in JS See #2936 & #9771

🎛️ Migrate `initSlugCleaning` to a Stimulus Controller `w-slug`
> ℹ️ **Part of the [Stimulus  🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

We have an implementation of ad-hoc JS to ensure that when a field is blurred, it will slugify the contents. It would be good for this behaviour to adopt the Stimulus controller approach so that it is more reusable and testable.

Additionally, the copy page view has a slug field for the copy and this does not use the auto-slugify behaviour, it would be good for this to adopt the same logic.

Finally, we are setting a global `unicodeSlugsEnabled` which would be good to avoid if possible as it pollutes the `window` global.

### Screenshots of behaviour

<img width="966" alt="Screenshot 2023-02-15 at 9 45 12 pm" src="https://user-images.githubusercontent.com/1396140/219018861-11dc8638-69c4-46d2-8f4b-94a1f94e5277.png">

<img width="970" alt="Screenshot 2023-02-15 at 9 45 19 pm" src="https://user-images.githubusercontent.com/1396140/219018880-0a988878-968e-46c5-9e99-f4a1e1c4a215.png">


### Describe the solution you'd like

#### Part 1 - Create a `w-slug` Stimulus controller

This controller should be written in TypeScript and have a baseline of unit tests, it should have the following capabilities.

* The controller should have the type of `HTMLInputElement`
* value: allowUnicodeSlugs (boolean) default false
* method: `slugify` which will call the util `cleanForSlug()` from `client/src/utils/text.ts` - with the second param `useURLify` always false, and passing in the option `unicodeSlugsEnabled` based on the controller's value
* Unit test coverage should use the `blur` event to check that the content is slugified and check the difference in behaviour with the unicode option

This should mean we can replace and remove the current `initSlugCleaning` code, but not yet the `initSlugAutoPopulate` code.

https://github.com/wagtail/wagtail/blob/55483b5424b1df707afbf38ca2298dba5095ec00/client/src/entrypoints/admin/page-editor.js#L33-L41

We can also then remove the calling of `initSlugCleaning` in that same file.

#### Part 2 - Create a new `SlugInput` widget

The goal here is to create a shared widget that sets up the correct Stimulus data attributes.

Create a file `wagtail/admin/widgets/slug.py` and within it a new widget `SlugInput`, it should set up the following data attributes (roughly). You can use https://github.com/wagtail/wagtail/blob/main/wagtail/admin/widgets/auto_height_text.py as a good starting point of a widget that mostly just adds different attributes.

```
"data-controller": "w-slug",
"data-action": "blur->w-slug#slugify",
"data-w-slug-allow-unicode-slugs-value": getattr(settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True)
```

See Django's docs on the base TextInput here https://docs.djangoproject.com/en/4.1/ref/forms/widgets/#django.forms.TextInput

#### Part 3 - Adopt the `SlugInput` widget on the Page form & the copy page form

Adopt in `wagtail/admin/panels/page_utils.py`

```python
    cls.promote_panels = [
        MultiFieldPanel(
            [
                FieldPanel( "slug", widget=SlugInput), # import from the file created above
                FieldPanel("seo_title"),
                FieldPanel("search_description"),
            ],
```

We can also add the widget usage in `wagtail/admin/forms/pages.py`, with something like this...

```python
        self.fields["new_slug"] = forms.SlugField(
            initial=self.page.slug,
            label=_("New slug"),
            allow_unicode=allow_unicode,
            widget=SlugInput,
#...
````

### Part 4 - clean up

We will need to add something to the release notes that the undocumented `initSlugCleaning` will no longer be supported. We may want to document the `w-slug` usage in the release notes but we do NOT want to provide a fall-back (e.g. using `afterLoad) this time.

Ideally it would be nice to trim whitespace while we are at it, the existing behaviour does not do this but it would make sense.

### Describe alternatives you've considered

* Leave as is, but this means we cannot move away from page-editor.js used everywhere.

### Additional context

* This is part of a larger goal to migrate to Stimulus.
* This is also part of a larger goal to avoid `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` being used everywhere and minimise global usage in JS See #2936 & #9771



Discussion:

Resolving patch:
diff --git a/wagtail/admin/forms/pages.py b/wagtail/admin/forms/pages.py
--- a/wagtail/admin/forms/pages.py
+++ b/wagtail/admin/forms/pages.py
@@ -22,7 +22,10 @@ def __init__(self, *args, **kwargs):
         )
         allow_unicode = getattr(settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True)
         self.fields["new_slug"] = forms.SlugField(
-            initial=self.page.slug, label=_("New slug"), allow_unicode=allow_unicode
+            initial=self.page.slug,
+            label=_("New slug"),
+            allow_unicode=allow_unicode,
+            widget=widgets.slug.SlugInput,
         )
         self.fields["new_parent_page"] = forms.ModelChoiceField(
             initial=self.page.get_parent(),
diff --git a/wagtail/admin/panels/page_utils.py b/wagtail/admin/panels/page_utils.py
--- a/wagtail/admin/panels/page_utils.py
+++ b/wagtail/admin/panels/page_utils.py
@@ -4,6 +4,7 @@
 from django.utils.translation import gettext_lazy
 
 from wagtail.admin.forms.pages import WagtailAdminPageForm
+from wagtail.admin.widgets.slug import SlugInput
 from wagtail.models import Page
 from wagtail.utils.decorators import cached_classmethod
 
@@ -31,7 +32,7 @@ def set_default_page_edit_handlers(cls):
     cls.promote_panels = [
         MultiFieldPanel(
             [
-                FieldPanel("slug"),
+                FieldPanel("slug", widget=SlugInput),
                 FieldPanel("seo_title"),
                 FieldPanel("search_description"),
             ],
diff --git a/wagtail/admin/widgets/slug.py b/wagtail/admin/widgets/slug.py
new file mode 100644
--- /dev/null
+++ b/wagtail/admin/widgets/slug.py
@@ -0,0 +1,16 @@
+from django.conf import settings
+from django.forms import widgets
+
+
+class SlugInput(widgets.TextInput):
+    def __init__(self, attrs=None):
+        default_attrs = {
+            "data-controller": "w-slug",
+            "data-action": "blur->w-slug#slugify",
+            "data-w-slug-allow-unicode-value": getattr(
+                settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True
+            ),
+        }
+        if attrs:
+            default_attrs.update(attrs)
+        super().__init__(default_attrs)


Test patch:
diff --git a/client/src/controllers/SlugController.test.js b/client/src/controllers/SlugController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/SlugController.test.js
@@ -0,0 +1,62 @@
+import { Application } from '@hotwired/stimulus';
+import { SlugController } from './SlugController';
+
+describe('SlugController', () => {
+  let application;
+
+  beforeEach(() => {
+    application?.stop();
+
+    document.body.innerHTML = `
+    <input
+      id="id_slug"
+      name="slug"
+      type="text"
+      data-controller="w-slug"
+      data-action="blur->w-slug#slugify"
+    />`;
+
+    application = Application.start();
+    application.register('w-slug', SlugController);
+  });
+
+  it('should trim and slugify the input value when focus is moved away from it', () => {
+    const slugInput = document.querySelector('#id_slug');
+    slugInput.value = '    slug  testing on     edit page ';
+
+    slugInput.dispatchEvent(new CustomEvent('blur'));
+
+    expect(document.querySelector('#id_slug').value).toEqual(
+      'slug-testing-on-edit-page',
+    );
+  });
+
+  it('should not allow unicode characters by default', () => {
+    const slugInput = document.querySelector('#id_slug');
+
+    expect(
+      slugInput.hasAttribute('data-w-slug-allow-unicode-value'),
+    ).toBeFalsy();
+
+    slugInput.value = 'Visiter Toulouse en été 2025';
+
+    slugInput.dispatchEvent(new CustomEvent('blur'));
+
+    expect(slugInput.value).toEqual('visiter-toulouse-en-t-2025');
+  });
+
+  it('should now allow unicode characters by default', () => {
+    const slugInput = document.querySelector('#id_slug');
+    slugInput.setAttribute('data-w-slug-allow-unicode-value', 'true');
+
+    expect(
+      slugInput.hasAttribute('data-w-slug-allow-unicode-value'),
+    ).toBeTruthy();
+
+    slugInput.value = 'Visiter Toulouse en été 2025';
+
+    slugInput.dispatchEvent(new CustomEvent('blur'));
+
+    expect(slugInput.value).toEqual('visiter-toulouse-en-été-2025');
+  });
+});
diff --git a/client/src/utils/text.test.js b/client/src/utils/text.test.js
--- a/client/src/utils/text.test.js
+++ b/client/src/utils/text.test.js
@@ -47,8 +47,8 @@ describe('cleanForSlug', () => {
         'on-this-day-in-november',
       );
       expect(cleanForSlug('This & That', false)).toBe('this--that');
-      expect(cleanForSlug('The Price is $72.00!', false)).toBe(
-        'the-price-is-7200',
+      expect(cleanForSlug('Lisboa é ótima à beira-mar', false)).toBe(
+        'lisboa--tima--beira-mar',
       );
     });
   });
@@ -62,8 +62,8 @@ describe('cleanForSlug', () => {
       /* true triggers to use django's urlify */
 
       expect(cleanForSlug('This & That', true)).toBe('this-that');
-      expect(cleanForSlug('The Price is $72.00!', false)).toBe(
-        'the-price-is-7200',
+      expect(cleanForSlug('Lisboa é ótima à beira-mar', false)).toBe(
+        'lisboa-é-ótima-à-beira-mar',
       );
     });
 
@@ -71,8 +71,8 @@ describe('cleanForSlug', () => {
       /* false triggers ignores django's urlify */
 
       expect(cleanForSlug('This & That', false)).toBe('this--that');
-      expect(cleanForSlug('The Price is $72.00!', false)).toBe(
-        'the-price-is-7200',
+      expect(cleanForSlug('Lisboa é ótima à beira-mar', false)).toBe(
+        'lisboa-é-ótima-à-beira-mar',
       );
     });
   });
diff --git a/wagtail/admin/tests/pages/test_edit_page.py b/wagtail/admin/tests/pages/test_edit_page.py
--- a/wagtail/admin/tests/pages/test_edit_page.py
+++ b/wagtail/admin/tests/pages/test_edit_page.py
@@ -1482,8 +1482,8 @@ def test_editor_page_shows_live_url_in_status_when_draft_edits_exist(self):
             reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
         )
 
-        input_field_for_draft_slug = '<input type="text" name="slug" value="revised-slug-in-draft-only" aria-describedby="panel-child-promote-child-for_search_engines-child-slug-helptext" id="id_slug" maxlength="255" required />'
-        input_field_for_live_slug = '<input type="text" name="slug" value="hello-world" aria-describedby="panel-child-promote-child-for_search_engines-child-slug-helptext" id="id_slug" maxlength="255" required />'
+        input_field_for_draft_slug = '<input type="text" name="slug" value="revised-slug-in-draft-only" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify" data-w-slug-allow-unicode-value maxlength="255" aria-describedby="panel-child-promote-child-for_search_engines-child-slug-helptext" required id="id_slug">'
+        input_field_for_live_slug = '<input type="text" name="slug" value="hello-world" maxlength="255" aria-describedby="panel-child-promote-child-for_search_engines-child-slug-helptext" required id="id_slug" />'
 
         # Status Link should be the live page (not revision)
         self.assertNotContains(
@@ -1508,8 +1508,8 @@ def test_editor_page_shows_custom_live_url_in_status_when_draft_edits_exist(self
             reverse("wagtailadmin_pages:edit", args=(self.single_event_page.id,))
         )
 
-        input_field_for_draft_slug = '<input type="text" name="slug" value="revised-slug-in-draft-only" aria-describedby="panel-child-promote-child-common_page_configuration-child-slug-helptext" id="id_slug" maxlength="255" required />'
-        input_field_for_live_slug = '<input type="text" name="slug" value="mars-landing" aria-describedby="panel-child-promote-child-common_page_configuration-child-slug-helptext" id="id_slug" maxlength="255" required />'
+        input_field_for_draft_slug = '<input type="text" name="slug" value="revised-slug-in-draft-only" maxlength="255" aria-describedby="panel-child-promote-child-common_page_configuration-child-slug-helptext" required id="id_slug" />'
+        input_field_for_live_slug = '<input type="text" name="slug" value="mars-landing" maxlength="255" aria-describedby="panel-child-promote-child-common_page_configuration-child-slug-helptext" required id="id_slug" />'
 
         # Status Link should be the live page (not revision)
         self.assertNotContains(
diff --git a/wagtail/admin/tests/test_widgets.py b/wagtail/admin/tests/test_widgets.py
--- a/wagtail/admin/tests/test_widgets.py
+++ b/wagtail/admin/tests/test_widgets.py
@@ -642,3 +642,23 @@ def test_optgroups(self):
             </select>
         """,
         )
+
+
+class TestSlugInput(TestCase):
+    def test_has_data_attr(self):
+        widget = widgets.slug.SlugInput()
+
+        html = widget.render("test", None, attrs={"id": "test-id"})
+
+        self.assertInHTML(
+            '<input type="text" name="test" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify" data-w-slug-allow-unicode-value id="test-id">',
+            html,
+        )
+
+    @override_settings(WAGTAIL_ALLOW_UNICODE_SLUGS=False)
+    def test_render_data_atrrs_from_settings(self):
+        widget = widgets.slug.SlugInput()
+
+        html = widget.render("test", None, attrs={"id": "test-id"})
+
+        self.assertNotIn("data-w-slug-allow-unicode-value", html)