From de8652114fd255e594ad1ca49a9fd0ed9fbcfa9a Mon Sep 17 00:00:00 2001 From: LB Johnston Date: Wed, 2 Oct 2024 11:19:58 +1000 Subject: [PATCH 1/7] Rename SlugController to CleanController - Keeps the `w-slug` identifier for main Page SlugInput fields - Sets up for future enhancements to this controller - Clean up JSDoc & examples --- ...roller.test.js => CleanController.test.js} | 36 +++++++++---------- .../{SlugController.ts => CleanController.ts} | 30 +++++++++++----- client/src/controllers/index.ts | 5 +-- wagtail/admin/widgets/slug.py | 2 +- 4 files changed, 44 insertions(+), 29 deletions(-) rename client/src/controllers/{SlugController.test.js => CleanController.test.js} (90%) rename client/src/controllers/{SlugController.ts => CleanController.ts} (74%) diff --git a/client/src/controllers/SlugController.test.js b/client/src/controllers/CleanController.test.js similarity index 90% rename from client/src/controllers/SlugController.test.js rename to client/src/controllers/CleanController.test.js index 2975da33d5..a17e3fd157 100644 --- a/client/src/controllers/SlugController.test.js +++ b/client/src/controllers/CleanController.test.js @@ -1,7 +1,7 @@ import { Application } from '@hotwired/stimulus'; -import { SlugController } from './SlugController'; +import { CleanController } from './CleanController'; -describe('SlugController', () => { +describe('CleanController', () => { let application; beforeEach(() => { @@ -12,12 +12,12 @@ describe('SlugController', () => { id="id_slug" name="slug" type="text" - data-controller="w-slug" - data-action="blur->w-slug#slugify" + data-controller="w-clean" + data-action="blur->w-clean#slugify" />`; application = Application.start(); - application.register('w-slug', SlugController); + application.register('w-clean', CleanController); }); it('should trim and slugify the input value when focus is moved away from it', () => { @@ -35,7 +35,7 @@ describe('SlugController', () => { const slugInput = document.querySelector('#id_slug'); expect( - slugInput.hasAttribute('data-w-slug-allow-unicode-value'), + slugInput.hasAttribute('data-w-clean-allow-unicode-value'), ).toBeFalsy(); slugInput.value = 'Visiter Toulouse en été 2025'; @@ -47,10 +47,10 @@ describe('SlugController', () => { it('should allow unicode characters when allow-unicode-value is set to truthy', () => { const slugInput = document.querySelector('#id_slug'); - slugInput.setAttribute('data-w-slug-allow-unicode-value', 'true'); + slugInput.setAttribute('data-w-clean-allow-unicode-value', 'true'); expect( - slugInput.hasAttribute('data-w-slug-allow-unicode-value'), + slugInput.hasAttribute('data-w-clean-allow-unicode-value'), ).toBeTruthy(); slugInput.value = 'Visiter Toulouse en été 2025'; @@ -72,17 +72,17 @@ describe('compare behavior', () => { id="id_slug" name="slug" type="text" - data-controller="w-slug" + data-controller="w-clean" />`; application = Application.start(); - application.register('w-slug', SlugController); + application.register('w-clean', CleanController); const slugInput = document.querySelector('#id_slug'); slugInput.dataset.action = [ - 'blur->w-slug#urlify', - 'custom:event->w-slug#compare', + 'blur->w-clean#urlify', + 'custom:event->w-clean#compare', ].join(' '); }); @@ -164,7 +164,7 @@ describe('compare behavior', () => { const slug = document.querySelector('#id_slug'); - slug.setAttribute('data-w-slug-compare-as-param', 'urlify'); + slug.setAttribute('data-w-clean-compare-as-param', 'urlify'); slug.setAttribute('value', title); // apply the urlify method to the content to ensure the value before check is urlify @@ -239,17 +239,17 @@ describe('urlify behavior', () => { id="id_slug" name="slug" type="text" - data-controller="w-slug" + data-controller="w-clean" />`; application = Application.start(); - application.register('w-slug', SlugController); + application.register('w-clean', CleanController); const slugInput = document.querySelector('#id_slug'); slugInput.dataset.action = [ - 'blur->w-slug#slugify', - 'custom:event->w-slug#urlify:prevent', + 'blur->w-clean#slugify', + 'custom:event->w-clean#urlify:prevent', ].join(' '); }); @@ -284,7 +284,7 @@ describe('urlify behavior', () => { const value = 'Dê-me fatias de pizza de manhã --ou-- à noite'; const slugInput = document.getElementById('id_slug'); - slugInput.setAttribute('data-w-slug-allow-unicode-value', 'true'); + slugInput.setAttribute('data-w-clean-allow-unicode-value', 'true'); slugInput.value = value; diff --git a/client/src/controllers/SlugController.ts b/client/src/controllers/CleanController.ts similarity index 74% rename from client/src/controllers/SlugController.ts rename to client/src/controllers/CleanController.ts index ad387af676..99e7fe08b7 100644 --- a/client/src/controllers/SlugController.ts +++ b/client/src/controllers/CleanController.ts @@ -2,22 +2,36 @@ import { Controller } from '@hotwired/stimulus'; import { slugify } from '../utils/slugify'; import { urlify } from '../utils/urlify'; -type SlugMethods = 'slugify' | 'urlify'; +enum Actions { + Slugify = 'slugify', + Urlify = 'urlify', +} /** - * Adds ability to slugify the value of an input element. + * Adds ability to clean values of an input element with methods such as slugify or urlify. * - * @example + * @example - Using the slugify method * ```html * * ``` + * + * @example - Using the urlify method (registered as w-slug) + * ```html + * + * + * ``` */ -export class SlugController extends Controller { +export class CleanController extends Controller { static values = { allowUnicode: { default: false, type: Boolean }, }; - declare allowUnicodeValue: boolean; + /** + * If true, unicode values in the cleaned values will be allowed. + * Otherwise unicode values will try to be transliterated. + * @see `WAGTAIL_ALLOW_UNICODE_SLUGS` in settings + */ + declare readonly allowUnicodeValue: boolean; /** * Allow for a comparison value to be provided so that a dispatched event can be @@ -27,8 +41,8 @@ export class SlugController extends Controller { * either a Stimulus param value on the element or the event's detail. */ compare( - event: CustomEvent<{ compareAs?: SlugMethods; value: string }> & { - params?: { compareAs?: SlugMethods }; + event: CustomEvent<{ compareAs?: Actions; value: string }> & { + params?: { compareAs?: Actions }; }, ) { // do not attempt to compare if the current field is empty @@ -37,7 +51,7 @@ export class SlugController extends Controller { } const compareAs = - event.detail?.compareAs || event.params?.compareAs || 'slugify'; + event.detail?.compareAs || event.params?.compareAs || Actions.Slugify; const compareValue = this[compareAs]( { detail: { value: event.detail?.value || '' } }, diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index 17acb01256..abde868a8a 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -5,6 +5,7 @@ import { ActionController } from './ActionController'; import { AutosizeController } from './AutosizeController'; import { BlockController } from './BlockController'; import { BulkController } from './BulkController'; +import { CleanController } from './CleanController'; import { ClipboardController } from './ClipboardController'; import { CloneController } from './CloneController'; import { CountController } from './CountController'; @@ -23,7 +24,6 @@ import { ProgressController } from './ProgressController'; import { RevealController } from './RevealController'; import { RulesController } from './RulesController'; import { SessionController } from './SessionController'; -import { SlugController } from './SlugController'; import { SubmitController } from './SubmitController'; import { SwapController } from './SwapController'; import { SyncController } from './SyncController'; @@ -43,6 +43,8 @@ export const coreControllerDefinitions: Definition[] = [ { controllerConstructor: AutosizeController, identifier: 'w-autosize' }, { controllerConstructor: BlockController, identifier: 'w-block' }, { controllerConstructor: BulkController, identifier: 'w-bulk' }, + { controllerConstructor: CleanController, identifier: 'w-clean' }, + { controllerConstructor: CleanController, identifier: 'w-slug' }, { controllerConstructor: ClipboardController, identifier: 'w-clipboard' }, { controllerConstructor: CloneController, identifier: 'w-clone' }, { controllerConstructor: CloneController, identifier: 'w-messages' }, @@ -63,7 +65,6 @@ export const coreControllerDefinitions: Definition[] = [ { controllerConstructor: RevealController, identifier: 'w-reveal' }, { controllerConstructor: RulesController, identifier: 'w-rules' }, { controllerConstructor: SessionController, identifier: 'w-session' }, - { controllerConstructor: SlugController, identifier: 'w-slug' }, { controllerConstructor: SubmitController, identifier: 'w-submit' }, { controllerConstructor: SwapController, identifier: 'w-swap' }, { controllerConstructor: SyncController, identifier: 'w-sync' }, diff --git a/wagtail/admin/widgets/slug.py b/wagtail/admin/widgets/slug.py index 4dfee1062d..99eeee38f1 100644 --- a/wagtail/admin/widgets/slug.py +++ b/wagtail/admin/widgets/slug.py @@ -4,7 +4,7 @@ from django.forms import widgets class SlugInput(widgets.TextInput): """ - Associates the input field with the Stimulus w-slug (SlugController). + Associates the input field with the Stimulus w-slug (CleanController). Slugifies content based on `WAGTAIL_ALLOW_UNICODE_SLUGS` and supports fields syncing their value to this field (see `TitleFieldPanel`) if also used. From e9111a000f4baec6a362be839b9d39082ded00ff Mon Sep 17 00:00:00 2001 From: LB Johnston Date: Wed, 2 Oct 2024 12:22:31 +1000 Subject: [PATCH 2/7] Update all CleanController unit tests to support async behaviour --- .../src/controllers/CleanController.test.js | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/client/src/controllers/CleanController.test.js b/client/src/controllers/CleanController.test.js index a17e3fd157..08f57acd91 100644 --- a/client/src/controllers/CleanController.test.js +++ b/client/src/controllers/CleanController.test.js @@ -20,18 +20,20 @@ describe('CleanController', () => { application.register('w-clean', CleanController); }); - it('should trim and slugify the input value when focus is moved away from it', () => { + it('should trim and slugify the input value when focus is moved away from it', async () => { const slugInput = document.querySelector('#id_slug'); slugInput.value = ' slug testing on edit page '; slugInput.dispatchEvent(new CustomEvent('blur')); + await new Promise(process.nextTick); + expect(document.querySelector('#id_slug').value).toEqual( 'slug-testing-on-edit-page', ); }); - it('should not allow unicode characters by default', () => { + it('should not allow unicode characters by default', async () => { const slugInput = document.querySelector('#id_slug'); expect( @@ -42,10 +44,12 @@ describe('CleanController', () => { slugInput.dispatchEvent(new CustomEvent('blur')); + await new Promise(process.nextTick); + expect(slugInput.value).toEqual('visiter-toulouse-en-t-2025'); }); - it('should allow unicode characters when allow-unicode-value is set to truthy', () => { + it('should allow unicode characters when allow-unicode-value is set to truthy', async () => { const slugInput = document.querySelector('#id_slug'); slugInput.setAttribute('data-w-clean-allow-unicode-value', 'true'); @@ -57,6 +61,8 @@ describe('CleanController', () => { slugInput.dispatchEvent(new CustomEvent('blur')); + await new Promise(process.nextTick); + expect(slugInput.value).toEqual('visiter-toulouse-en-été-2025'); }); }); @@ -86,7 +92,7 @@ describe('compare behavior', () => { ].join(' '); }); - it('should not prevent default if input has no value', () => { + it('should not prevent default if input has no value', async () => { const event = new CustomEvent('custom:event', { detail: { value: 'title alpha' }, }); @@ -95,11 +101,13 @@ describe('compare behavior', () => { document.getElementById('id_slug').dispatchEvent(event); + await new Promise(process.nextTick); + expect(document.getElementById('id_slug').value).toBe(''); expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should not prevent default if the values are the same', () => { + it('should not prevent default if the values are the same', async () => { document.querySelector('#id_slug').setAttribute('value', 'title-alpha'); const event = new CustomEvent('custom:event', { @@ -110,10 +118,12 @@ describe('compare behavior', () => { document.getElementById('id_slug').dispatchEvent(event); + await new Promise(process.nextTick); + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should prevent default using the slugify (default) behavior as the compare function when urlify values is not equal', () => { + it('should prevent default using the slugify (default) behavior as the compare function when urlify values is not equal', async () => { const slug = document.querySelector('#id_slug'); const title = 'Тестовий заголовок'; @@ -123,6 +133,8 @@ describe('compare behavior', () => { // apply the urlify method to the content to ensure the value before check is urlify slug.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(slug.value).toEqual('testovij-zagolovok'); const event = new CustomEvent('custom:event', { detail: { value: title } }); @@ -131,11 +143,13 @@ describe('compare behavior', () => { slug.dispatchEvent(event); + await new Promise(process.nextTick); + // slugify used for the compareAs value by default, so 'compare' fails expect(event.preventDefault).toHaveBeenCalled(); }); - it('should not prevent default using the slugify (default) behavior as the compare function when urlify value is equal', () => { + it('should not prevent default using the slugify (default) behavior as the compare function when urlify value is equal', async () => { const slug = document.querySelector('#id_slug'); const title = 'the-french-dispatch-a-love-letter-to-journalists'; @@ -155,11 +169,13 @@ describe('compare behavior', () => { slug.dispatchEvent(event); + await new Promise(process.nextTick); + // slugify used for the compareAs value by default, so 'compare' passes with the initial urlify value on blur expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should not prevent default using the urlify behavior as the compare function when urlify value matches', () => { + it('should not prevent default using the urlify behavior as the compare function when urlify value matches', async () => { const title = 'Тестовий заголовок'; const slug = document.querySelector('#id_slug'); @@ -170,6 +186,8 @@ describe('compare behavior', () => { // apply the urlify method to the content to ensure the value before check is urlify slug.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(slug.value).toEqual('testovij-zagolovok'); const event = new CustomEvent('custom:event', { @@ -180,10 +198,12 @@ describe('compare behavior', () => { slug.dispatchEvent(event); + await new Promise(process.nextTick); + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should prevent default if the values are not the same', () => { + it('should prevent default if the values are not the same', async () => { document.querySelector('#id_slug').setAttribute('value', 'title-alpha'); const event = new CustomEvent('custom:event', { @@ -194,10 +214,12 @@ describe('compare behavior', () => { document.getElementById('id_slug').dispatchEvent(event); + await new Promise(process.nextTick); + expect(event.preventDefault).toHaveBeenCalled(); }); - it('should not prevent default if both values are empty strings', () => { + it('should not prevent default if both values are empty strings', async () => { const slugInput = document.querySelector('#id_slug'); slugInput.setAttribute('value', ''); @@ -209,10 +231,12 @@ describe('compare behavior', () => { slugInput.dispatchEvent(event); + await new Promise(process.nextTick); + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should prevent default if the new value is an empty string but the existing value is not', () => { + it('should prevent default if the new value is an empty string but the existing value is not', async () => { const slugInput = document.querySelector('#id_slug'); slugInput.setAttribute('value', 'existing-value'); @@ -224,6 +248,8 @@ describe('compare behavior', () => { slugInput.dispatchEvent(event); + await new Promise(process.nextTick); + expect(event.preventDefault).toHaveBeenCalled(); }); }); @@ -253,7 +279,7 @@ describe('urlify behavior', () => { ].join(' '); }); - it('should update slug input value if the values are the same', () => { + it('should update slug input value if the values are the same', async () => { const slugInput = document.getElementById('id_slug'); slugInput.value = 'urlify Testing On edit page '; @@ -264,10 +290,12 @@ describe('urlify behavior', () => { document.getElementById('id_slug').dispatchEvent(event); + await new Promise(process.nextTick); + expect(slugInput.value).toBe('urlify-testing-on-edit-page'); }); - it('should transform input with special (unicode) characters to their ASCII equivalent by default', () => { + it('should transform input with special (unicode) characters to their ASCII equivalent by default', async () => { const slugInput = document.getElementById('id_slug'); slugInput.value = 'Some Title with éçà Spaces'; @@ -277,10 +305,12 @@ describe('urlify behavior', () => { document.getElementById('id_slug').dispatchEvent(event); + await new Promise(process.nextTick); + expect(slugInput.value).toBe('some-title-with-eca-spaces'); }); - it('should transform input with special (unicode) characters to keep unicode values if allow unicode value is truthy', () => { + it('should transform input with special (unicode) characters to keep unicode values if allow unicode value is truthy', async () => { const value = 'Dê-me fatias de pizza de manhã --ou-- à noite'; const slugInput = document.getElementById('id_slug'); @@ -292,10 +322,12 @@ describe('urlify behavior', () => { document.getElementById('id_slug').dispatchEvent(event); + await new Promise(process.nextTick); + expect(slugInput.value).toBe('dê-me-fatias-de-pizza-de-manhã-ou-à-noite'); }); - it('should return an empty string when input contains only special characters', () => { + it('should return an empty string when input contains only special characters', async () => { const slugInput = document.getElementById('id_slug'); slugInput.value = '$$!@#$%^&*'; @@ -305,6 +337,8 @@ describe('urlify behavior', () => { document.getElementById('id_slug').dispatchEvent(event); + await new Promise(process.nextTick); + expect(slugInput.value).toBe(''); }); }); From 29a6c90569d239cfd6c7254f1b1982bb2cd17ba1 Mon Sep 17 00:00:00 2001 From: LB Johnston Date: Wed, 2 Oct 2024 12:38:35 +1000 Subject: [PATCH 3/7] Refactor CleanController unit tests to be easier to maintain - Move the 'base' unit tests into 'slugify' & put these after 'compare' - Use `getElementById` instead of `querySelector` - Streamline tests to be a bit more generic - Update spelling of the word 'behaviour' --- .../src/controllers/CleanController.test.js | 584 +++++++++--------- 1 file changed, 293 insertions(+), 291 deletions(-) diff --git a/client/src/controllers/CleanController.test.js b/client/src/controllers/CleanController.test.js index 08f57acd91..d5f40b88f7 100644 --- a/client/src/controllers/CleanController.test.js +++ b/client/src/controllers/CleanController.test.js @@ -4,341 +4,343 @@ import { CleanController } from './CleanController'; describe('CleanController', () => { let application; - beforeEach(() => { - application?.stop(); + describe('compare', () => { + beforeEach(() => { + application?.stop(); - document.body.innerHTML = ` + document.body.innerHTML = ` + `; + + application = Application.start(); + application.register('w-clean', CleanController); + + const input = document.getElementById('slug'); + + input.dataset.action = [ + 'blur->w-clean#urlify', + 'custom:event->w-clean#compare', + ].join(' '); + }); + + it('should not prevent default if input has no value', async () => { + const event = new CustomEvent('custom:event', { + detail: { value: 'title alpha' }, + }); + + event.preventDefault = jest.fn(); + + document.getElementById('slug').dispatchEvent(event); + + await new Promise(process.nextTick); + + expect(document.getElementById('slug').value).toBe(''); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should not prevent default if the values are the same', async () => { + document.getElementById('slug').setAttribute('value', 'title-alpha'); + + const event = new CustomEvent('custom:event', { + detail: { value: 'title alpha' }, + }); + + event.preventDefault = jest.fn(); + + document.getElementById('slug').dispatchEvent(event); + + await new Promise(process.nextTick); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should prevent default using the slugify (default) behavior as the compare function when urlify values is not equal', async () => { + const input = document.getElementById('slug'); + + const title = 'Тестовий заголовок'; + + input.setAttribute('value', title); + + // apply the urlify method to the content to ensure the value before check is urlify + input.dispatchEvent(new Event('blur')); + + await new Promise(process.nextTick); + + expect(input.value).toEqual('testovij-zagolovok'); + + const event = new CustomEvent('custom:event', { + detail: { value: title }, + }); + + event.preventDefault = jest.fn(); + + input.dispatchEvent(event); + + await new Promise(process.nextTick); + + // slugify used for the compareAs value by default, so 'compare' fails + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should not prevent default using the slugify (default) behavior as the compare function when urlify value is equal', async () => { + const input = document.getElementById('slug'); + + const title = 'the-french-dispatch-a-love-letter-to-journalists'; + + input.setAttribute('value', title); + + // apply the urlify method to the content to ensure the value before check is urlify + input.dispatchEvent(new Event('blur')); + + expect(input.value).toEqual( + 'the-french-dispatch-a-love-letter-to-journalists', + ); + + const event = new CustomEvent('custom:event', { + detail: { value: title }, + }); + + event.preventDefault = jest.fn(); + + input.dispatchEvent(event); + + await new Promise(process.nextTick); + + // slugify used for the compareAs value by default, so 'compare' passes with the initial urlify value on blur + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should not prevent default using the urlify behavior as the compare function when urlify value matches', async () => { + const title = 'Тестовий заголовок'; + + const input = document.getElementById('slug'); + + input.setAttribute('data-w-clean-compare-as-param', 'urlify'); + input.setAttribute('value', title); + + // apply the urlify method to the content to ensure the value before check is urlify + input.dispatchEvent(new Event('blur')); + + await new Promise(process.nextTick); + + expect(input.value).toEqual('testovij-zagolovok'); + + const event = new CustomEvent('custom:event', { + detail: { compareAs: 'urlify', value: title }, + }); + + event.preventDefault = jest.fn(); + + input.dispatchEvent(event); + + await new Promise(process.nextTick); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should prevent default if the values are not the same', async () => { + document.getElementById('slug').setAttribute('value', 'title-alpha'); + + const event = new CustomEvent('custom:event', { + detail: { value: 'title beta' }, + }); + + event.preventDefault = jest.fn(); + + document.getElementById('slug').dispatchEvent(event); + + await new Promise(process.nextTick); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should not prevent default if both values are empty strings', async () => { + const input = document.getElementById('slug'); + input.setAttribute('value', ''); + + const event = new CustomEvent('custom:event', { + detail: { value: '' }, + }); + + event.preventDefault = jest.fn(); + + input.dispatchEvent(event); + + await new Promise(process.nextTick); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should prevent default if the new value is an empty string but the existing value is not', async () => { + const input = document.getElementById('slug'); + input.setAttribute('value', 'existing-value'); + + const event = new CustomEvent('custom:event', { + detail: { value: '' }, + }); + + event.preventDefault = jest.fn(); + + input.dispatchEvent(event); + + await new Promise(process.nextTick); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('slugify', () => { + beforeEach(() => { + application?.stop(); + + document.body.innerHTML = ` `; - application = Application.start(); - application.register('w-clean', CleanController); + application = Application.start(); + application.register('w-clean', CleanController); + }); + + it('should trim and slugify the input value when focus is moved away from it', async () => { + const input = document.getElementById('slug'); + input.value = ' slug testing on edit page '; + + input.dispatchEvent(new CustomEvent('blur')); + + await new Promise(process.nextTick); + + expect(document.getElementById('slug').value).toEqual( + 'slug-testing-on-edit-page', + ); + }); + + it('should not allow unicode characters by default', async () => { + const input = document.getElementById('slug'); + + expect( + input.hasAttribute('data-w-clean-allow-unicode-value'), + ).toBeFalsy(); + + input.value = 'Visiter Toulouse en été 2025'; + + input.dispatchEvent(new CustomEvent('blur')); + + await new Promise(process.nextTick); + + expect(input.value).toEqual('visiter-toulouse-en-t-2025'); + }); + + it('should allow unicode characters when allow-unicode-value is set to truthy', async () => { + const input = document.getElementById('slug'); + input.setAttribute('data-w-clean-allow-unicode-value', 'true'); + + expect( + input.hasAttribute('data-w-clean-allow-unicode-value'), + ).toBeTruthy(); + + input.value = 'Visiter Toulouse en été 2025'; + + input.dispatchEvent(new CustomEvent('blur')); + + await new Promise(process.nextTick); + + expect(input.value).toEqual('visiter-toulouse-en-été-2025'); + }); }); - it('should trim and slugify the input value when focus is moved away from it', async () => { - const slugInput = document.querySelector('#id_slug'); - slugInput.value = ' slug testing on edit page '; + describe('urlify', () => { + beforeEach(() => { + application?.stop(); - slugInput.dispatchEvent(new CustomEvent('blur')); - - await new Promise(process.nextTick); - - expect(document.querySelector('#id_slug').value).toEqual( - 'slug-testing-on-edit-page', - ); - }); - - it('should not allow unicode characters by default', async () => { - const slugInput = document.querySelector('#id_slug'); - - expect( - slugInput.hasAttribute('data-w-clean-allow-unicode-value'), - ).toBeFalsy(); - - slugInput.value = 'Visiter Toulouse en été 2025'; - - slugInput.dispatchEvent(new CustomEvent('blur')); - - await new Promise(process.nextTick); - - expect(slugInput.value).toEqual('visiter-toulouse-en-t-2025'); - }); - - it('should allow unicode characters when allow-unicode-value is set to truthy', async () => { - const slugInput = document.querySelector('#id_slug'); - slugInput.setAttribute('data-w-clean-allow-unicode-value', 'true'); - - expect( - slugInput.hasAttribute('data-w-clean-allow-unicode-value'), - ).toBeTruthy(); - - slugInput.value = 'Visiter Toulouse en été 2025'; - - slugInput.dispatchEvent(new CustomEvent('blur')); - - await new Promise(process.nextTick); - - expect(slugInput.value).toEqual('visiter-toulouse-en-été-2025'); - }); -}); - -describe('compare behavior', () => { - let application; - - beforeEach(() => { - application?.stop(); - - document.body.innerHTML = ` + document.body.innerHTML = ` `; - application = Application.start(); - application.register('w-clean', CleanController); + application = Application.start(); + application.register('w-clean', CleanController); - const slugInput = document.querySelector('#id_slug'); + const input = document.getElementById('slug'); - slugInput.dataset.action = [ - 'blur->w-clean#urlify', - 'custom:event->w-clean#compare', - ].join(' '); - }); - - it('should not prevent default if input has no value', async () => { - const event = new CustomEvent('custom:event', { - detail: { value: 'title alpha' }, + input.dataset.action = [ + 'blur->w-clean#slugify', + 'custom:event->w-clean#urlify:prevent', + ].join(' '); }); - event.preventDefault = jest.fn(); + it('should update slug input value if the values are the same', async () => { + const input = document.getElementById('slug'); + input.value = 'urlify Testing On edit page '; - document.getElementById('id_slug').dispatchEvent(event); + const event = new CustomEvent('custom:event', { + detail: { value: 'urlify Testing On edit page' }, + bubbles: false, + }); - await new Promise(process.nextTick); + document.getElementById('slug').dispatchEvent(event); - expect(document.getElementById('id_slug').value).toBe(''); - expect(event.preventDefault).not.toHaveBeenCalled(); - }); + await new Promise(process.nextTick); - it('should not prevent default if the values are the same', async () => { - document.querySelector('#id_slug').setAttribute('value', 'title-alpha'); - - const event = new CustomEvent('custom:event', { - detail: { value: 'title alpha' }, + expect(input.value).toBe('urlify-testing-on-edit-page'); }); - event.preventDefault = jest.fn(); + it('should transform input with special (unicode) characters to their ASCII equivalent by default', async () => { + const input = document.getElementById('slug'); + input.value = 'Some Title with éçà Spaces'; - document.getElementById('id_slug').dispatchEvent(event); + const event = new CustomEvent('custom:event', { + detail: { value: 'Some Title with éçà Spaces' }, + }); - await new Promise(process.nextTick); + document.getElementById('slug').dispatchEvent(event); - expect(event.preventDefault).not.toHaveBeenCalled(); - }); + await new Promise(process.nextTick); - it('should prevent default using the slugify (default) behavior as the compare function when urlify values is not equal', async () => { - const slug = document.querySelector('#id_slug'); - - const title = 'Тестовий заголовок'; - - slug.setAttribute('value', title); - - // apply the urlify method to the content to ensure the value before check is urlify - slug.dispatchEvent(new Event('blur')); - - await new Promise(process.nextTick); - - expect(slug.value).toEqual('testovij-zagolovok'); - - const event = new CustomEvent('custom:event', { detail: { value: title } }); - - event.preventDefault = jest.fn(); - - slug.dispatchEvent(event); - - await new Promise(process.nextTick); - - // slugify used for the compareAs value by default, so 'compare' fails - expect(event.preventDefault).toHaveBeenCalled(); - }); - - it('should not prevent default using the slugify (default) behavior as the compare function when urlify value is equal', async () => { - const slug = document.querySelector('#id_slug'); - - const title = 'the-french-dispatch-a-love-letter-to-journalists'; - - slug.setAttribute('value', title); - - // apply the urlify method to the content to ensure the value before check is urlify - slug.dispatchEvent(new Event('blur')); - - expect(slug.value).toEqual( - 'the-french-dispatch-a-love-letter-to-journalists', - ); - - const event = new CustomEvent('custom:event', { detail: { value: title } }); - - event.preventDefault = jest.fn(); - - slug.dispatchEvent(event); - - await new Promise(process.nextTick); - - // slugify used for the compareAs value by default, so 'compare' passes with the initial urlify value on blur - expect(event.preventDefault).not.toHaveBeenCalled(); - }); - - it('should not prevent default using the urlify behavior as the compare function when urlify value matches', async () => { - const title = 'Тестовий заголовок'; - - const slug = document.querySelector('#id_slug'); - - slug.setAttribute('data-w-clean-compare-as-param', 'urlify'); - slug.setAttribute('value', title); - - // apply the urlify method to the content to ensure the value before check is urlify - slug.dispatchEvent(new Event('blur')); - - await new Promise(process.nextTick); - - expect(slug.value).toEqual('testovij-zagolovok'); - - const event = new CustomEvent('custom:event', { - detail: { compareAs: 'urlify', value: title }, + expect(input.value).toBe('some-title-with-eca-spaces'); }); - event.preventDefault = jest.fn(); + it('should transform input with special (unicode) characters to keep unicode values if allow unicode value is truthy', async () => { + const value = 'Dê-me fatias de pizza de manhã --ou-- à noite'; - slug.dispatchEvent(event); + const input = document.getElementById('slug'); + input.setAttribute('data-w-clean-allow-unicode-value', 'true'); - await new Promise(process.nextTick); + input.value = value; - expect(event.preventDefault).not.toHaveBeenCalled(); - }); + const event = new CustomEvent('custom:event', { detail: { value } }); - it('should prevent default if the values are not the same', async () => { - document.querySelector('#id_slug').setAttribute('value', 'title-alpha'); + document.getElementById('slug').dispatchEvent(event); - const event = new CustomEvent('custom:event', { - detail: { value: 'title beta' }, + await new Promise(process.nextTick); + + expect(input.value).toBe('dê-me-fatias-de-pizza-de-manhã-ou-à-noite'); }); - event.preventDefault = jest.fn(); + it('should return an empty string when input contains only special characters', async () => { + const input = document.getElementById('slug'); + input.value = '$$!@#$%^&*'; - document.getElementById('id_slug').dispatchEvent(event); + const event = new CustomEvent('custom:event', { + detail: { value: '$$!@#$%^&*' }, + }); - await new Promise(process.nextTick); + document.getElementById('slug').dispatchEvent(event); - expect(event.preventDefault).toHaveBeenCalled(); - }); + await new Promise(process.nextTick); - it('should not prevent default if both values are empty strings', async () => { - const slugInput = document.querySelector('#id_slug'); - slugInput.setAttribute('value', ''); - - const event = new CustomEvent('custom:event', { - detail: { value: '' }, + expect(input.value).toBe(''); }); - - event.preventDefault = jest.fn(); - - slugInput.dispatchEvent(event); - - await new Promise(process.nextTick); - - expect(event.preventDefault).not.toHaveBeenCalled(); - }); - - it('should prevent default if the new value is an empty string but the existing value is not', async () => { - const slugInput = document.querySelector('#id_slug'); - slugInput.setAttribute('value', 'existing-value'); - - const event = new CustomEvent('custom:event', { - detail: { value: '' }, - }); - - event.preventDefault = jest.fn(); - - slugInput.dispatchEvent(event); - - await new Promise(process.nextTick); - - expect(event.preventDefault).toHaveBeenCalled(); - }); -}); - -describe('urlify behavior', () => { - let application; - - beforeEach(() => { - application?.stop(); - - document.body.innerHTML = ` - `; - - application = Application.start(); - application.register('w-clean', CleanController); - - const slugInput = document.querySelector('#id_slug'); - - slugInput.dataset.action = [ - 'blur->w-clean#slugify', - 'custom:event->w-clean#urlify:prevent', - ].join(' '); - }); - - it('should update slug input value if the values are the same', async () => { - const slugInput = document.getElementById('id_slug'); - slugInput.value = 'urlify Testing On edit page '; - - const event = new CustomEvent('custom:event', { - detail: { value: 'urlify Testing On edit page' }, - bubbles: false, - }); - - document.getElementById('id_slug').dispatchEvent(event); - - await new Promise(process.nextTick); - - expect(slugInput.value).toBe('urlify-testing-on-edit-page'); - }); - - it('should transform input with special (unicode) characters to their ASCII equivalent by default', async () => { - const slugInput = document.getElementById('id_slug'); - slugInput.value = 'Some Title with éçà Spaces'; - - const event = new CustomEvent('custom:event', { - detail: { value: 'Some Title with éçà Spaces' }, - }); - - document.getElementById('id_slug').dispatchEvent(event); - - await new Promise(process.nextTick); - - expect(slugInput.value).toBe('some-title-with-eca-spaces'); - }); - - it('should transform input with special (unicode) characters to keep unicode values if allow unicode value is truthy', async () => { - const value = 'Dê-me fatias de pizza de manhã --ou-- à noite'; - - const slugInput = document.getElementById('id_slug'); - slugInput.setAttribute('data-w-clean-allow-unicode-value', 'true'); - - slugInput.value = value; - - const event = new CustomEvent('custom:event', { detail: { value } }); - - document.getElementById('id_slug').dispatchEvent(event); - - await new Promise(process.nextTick); - - expect(slugInput.value).toBe('dê-me-fatias-de-pizza-de-manhã-ou-à-noite'); - }); - - it('should return an empty string when input contains only special characters', async () => { - const slugInput = document.getElementById('id_slug'); - slugInput.value = '$$!@#$%^&*'; - - const event = new CustomEvent('custom:event', { - detail: { value: '$$!@#$%^&*' }, - }); - - document.getElementById('id_slug').dispatchEvent(event); - - await new Promise(process.nextTick); - - expect(slugInput.value).toBe(''); }); }); From 9fe605df16f6aa832903dc401bf0b863e3e4e58b Mon Sep 17 00:00:00 2001 From: LB Date: Mon, 18 Nov 2024 07:38:27 +1000 Subject: [PATCH 4/7] CleanController - Make trim optional, refine methods - Slugify/urlify had inconsistent trimming, allow these to be optional & centralised in the CleanController - Ensure the controller can be easier to extend (for custom overrides) - Add optional `trim` support so that this can be opted in to & is more consistent across slugify/urlify usage - Avoid running slugify/urlify logic if the prepared value is empty --- .../src/controllers/CleanController.test.js | 45 +++++++++++ client/src/controllers/CleanController.ts | 79 +++++++++++++------ client/src/utils/slugify.test.js | 8 ++ client/src/utils/urlify.test.js | 8 ++ client/src/utils/urlify.ts | 6 +- wagtail/admin/tests/pages/test_edit_page.py | 8 +- wagtail/admin/tests/test_widgets.py | 2 +- wagtail/admin/widgets/slug.py | 1 + 8 files changed, 126 insertions(+), 31 deletions(-) diff --git a/client/src/controllers/CleanController.test.js b/client/src/controllers/CleanController.test.js index d5f40b88f7..ff2efa5fe6 100644 --- a/client/src/controllers/CleanController.test.js +++ b/client/src/controllers/CleanController.test.js @@ -218,6 +218,22 @@ describe('CleanController', () => { await new Promise(process.nextTick); + expect(document.getElementById('slug').value).toEqual( + '-slug-testing-on-edit-page-', // non-trimmed adds dashes for all spaces (inc. end/start) + ); + }); + + it('should slugify & trim (when enabled) the input value when focus is moved away from it', async () => { + const input = document.getElementById('slug'); + + input.setAttribute('data-w-clean-trim-value', 'true'); // enable trimmed values + + input.value = ' slug testing on edit page '; + + input.dispatchEvent(new CustomEvent('blur')); + + await new Promise(process.nextTick); + expect(document.getElementById('slug').value).toEqual( 'slug-testing-on-edit-page', ); @@ -342,5 +358,34 @@ describe('CleanController', () => { expect(input.value).toBe(''); }); + + it('should trim the value, only if trim is enabled', async () => { + const testValue = ' I féta eínai kalýteri . '; + + const input = document.getElementById('slug'); + + // the default behavior, with trim disabled + input.value = testValue; + + input.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(input.value).toBe('-i-fta-enai-kalteri--'); + + // after enabling trim + input.setAttribute('data-w-clean-trim-value', 'true'); + input.value = testValue; + + input.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(input.value).toBe('i-fta-enai-kalteri-'); + + // with unicode allowed & trim enabled + input.setAttribute('data-w-clean-allow-unicode-value', 'true'); + input.value = testValue; + + input.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(input.value).toBe('i-féta-eínai-kalýteri-'); + }); }); }); diff --git a/client/src/controllers/CleanController.ts b/client/src/controllers/CleanController.ts index 99e7fe08b7..ecd23fd66c 100644 --- a/client/src/controllers/CleanController.ts +++ b/client/src/controllers/CleanController.ts @@ -12,7 +12,8 @@ enum Actions { * * @example - Using the slugify method * ```html - * + * + * * ``` * * @example - Using the urlify method (registered as w-slug) @@ -24,6 +25,7 @@ enum Actions { export class CleanController extends Controller { static values = { allowUnicode: { default: false, type: Boolean }, + trim: { default: false, type: Boolean }, }; /** @@ -32,6 +34,17 @@ export class CleanController extends Controller { * @see `WAGTAIL_ALLOW_UNICODE_SLUGS` in settings */ declare readonly allowUnicodeValue: boolean; + /** If true, value will be trimmed in all clean methods before being processed by that method. */ + declare readonly trimValue: boolean; + + /** + * Writes the new value to the element. + */ + applyUpdate(action: Actions, cleanValue: string) { + if (action) { + this.element.value = cleanValue; + } + } /** * Allow for a comparison value to be provided so that a dispatched event can be @@ -45,22 +58,18 @@ export class CleanController extends Controller { params?: { compareAs?: Actions }; }, ) { - // do not attempt to compare if the current field is empty - if (!this.element.value) { - return true; - } + // do not attempt to compare if the field is empty + if (!this.element.value) return true; const compareAs = event.detail?.compareAs || event.params?.compareAs || Actions.Slugify; const compareValue = this[compareAs]( { detail: { value: event.detail?.value || '' } }, - true, + { ignoreUpdate: true }, ); - const currentValue = this.element.value; - - const valuesAreSame = compareValue.trim() === currentValue.trim(); + const valuesAreSame = this.compareValues(compareValue, this.element.value); if (!valuesAreSame) { event?.preventDefault(); @@ -69,6 +78,21 @@ export class CleanController extends Controller { return valuesAreSame; } + /** + * Compares the provided strings, ensuring the values are the same. + */ + compareValues(...values: string[]): boolean { + return new Set(values.map((value: string) => `${value}`)).size === 1; + } + + /** + * Prepares the value before being processed by an action method. + */ + prepareValue(sourceValue = '') { + const value = this.trimValue ? sourceValue.trim() : sourceValue; + return value; + } + /** * Basic slugify of a string, updates the controlled element's value * or can be used to simply return the transformed value. @@ -77,17 +101,21 @@ export class CleanController extends Controller { */ slugify( event: CustomEvent<{ value: string }> | { detail: { value: string } }, - ignoreUpdate = false, + { ignoreUpdate = false } = {}, ) { + const { value: sourceValue = this.element.value } = event?.detail || {}; + const preparedValue = this.prepareValue(sourceValue); + if (!preparedValue) return ''; + const allowUnicode = this.allowUnicodeValue; - const { value = this.element.value } = event?.detail || {}; - const newValue = slugify(value.trim(), { allowUnicode }); + + const cleanValue = slugify(preparedValue, { allowUnicode }); if (!ignoreUpdate) { - this.element.value = newValue; + this.applyUpdate(Actions.Slugify, cleanValue); } - return newValue; + return cleanValue; } /** @@ -103,20 +131,25 @@ export class CleanController extends Controller { */ urlify( event: CustomEvent<{ value: string }> | { detail: { value: string } }, - ignoreUpdate = false, + { ignoreUpdate = false } = {}, ) { - const allowUnicode = this.allowUnicodeValue; - const { value = this.element.value } = event?.detail || {}; - const trimmedValue = value.trim(); + const { value: sourceValue = this.element.value } = event?.detail || {}; + const preparedValue = this.prepareValue(sourceValue); + if (!preparedValue) return ''; - const newValue = - urlify(trimmedValue, { allowUnicode }) || - this.slugify({ detail: { value: trimmedValue } }, true); + const allowUnicode = this.allowUnicodeValue; + + const cleanValue = + urlify(preparedValue, { allowUnicode }) || + this.slugify( + { detail: { value: preparedValue } }, + { ignoreUpdate: true }, + ); if (!ignoreUpdate) { - this.element.value = newValue; + this.applyUpdate(Actions.Urlify, cleanValue); } - return newValue; + return cleanValue; } } diff --git a/client/src/utils/slugify.test.js b/client/src/utils/slugify.test.js index e65fe507d8..220dcfb322 100644 --- a/client/src/utils/slugify.test.js +++ b/client/src/utils/slugify.test.js @@ -9,6 +9,10 @@ describe('slugify', () => { 'lisboa--tima--beira-mar', ); }); + + it('should keep leading spaces & convert to hyphens if supplied', () => { + expect(slugify(' I like _ßpaces')).toBe('-i-like-_paces'); + }); }); describe('slugify with unicode slugs enabled', () => { @@ -33,5 +37,9 @@ describe('slugify', () => { ); expect(slugify('উইকিপিডিয়ায় স্বাগতম!', options)).toBe('উইকপডযয-সবগতম'); }); + + it('should keep leading spaces & convert to hyphens if supplied', () => { + expect(slugify(' I like _ßpaces', options)).toBe('-i-like-_ßpaces'); + }); }); }); diff --git a/client/src/utils/urlify.test.js b/client/src/utils/urlify.test.js index 4320f2e568..b102568e38 100644 --- a/client/src/utils/urlify.test.js +++ b/client/src/utils/urlify.test.js @@ -9,6 +9,10 @@ describe('urlify', () => { 'lisboa-e-otima-a-beira-mar', ); }); + + it('should keep leading spaces & convert to hyphens if supplied', () => { + expect(urlify(' I like _ßpaces')).toBe('-i-like-_sspaces'); + }); }); describe('urlify with unicode slugs enabled', () => { @@ -30,5 +34,9 @@ describe('urlify', () => { 'lisboa-é-ótima-à-beira-mar', ); }); + + it('should keep leading spaces & convert to hyphens if supplied', () => { + expect(urlify(' I like _ßpaces', options)).toBe('-i-like-_ßpaces'); + }); }); }); diff --git a/client/src/utils/urlify.ts b/client/src/utils/urlify.ts index b7bcf73259..c8630e8a37 100644 --- a/client/src/utils/urlify.ts +++ b/client/src/utils/urlify.ts @@ -12,8 +12,9 @@ const downcodeMapping = config.reduce((acc, downcodeMap) => { const regex = new RegExp(Object.keys(downcodeMapping).join('|'), 'g'); /** - * IMPORTANT This util and the mapping is a direct port of Django's urlify.js util, - * without the need for a full Regex polyfill implementation. + * This util and the mapping is refined port of Django's urlify.js util. + * Without the Regex polyfill & without running trim (assuming the trim will be run before if needed). + * * @see https://github.com/django/django/blob/main/django/contrib/admin/static/admin/js/urlify.js */ export const urlify = ( @@ -37,7 +38,6 @@ export const urlify = ( } else { str = str.replace(/[^-\w\s]/g, ''); // remove unneeded chars } - str = str.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces str = str.replace(/[-\s]+/g, '-'); // convert spaces to hyphens str = str.substring(0, numChars); // trim to first num_chars chars str = str.replace(/-+$/g, ''); // trim any trailing hyphens diff --git a/wagtail/admin/tests/pages/test_edit_page.py b/wagtail/admin/tests/pages/test_edit_page.py index 131f8bde8a..f73f0daed4 100644 --- a/wagtail/admin/tests/pages/test_edit_page.py +++ b/wagtail/admin/tests/pages/test_edit_page.py @@ -1925,8 +1925,8 @@ class TestPageEdit(WagtailTestUtils, TestCase): reverse("wagtailadmin_pages:edit", args=(self.child_page.id,)) ) - input_field_for_draft_slug = '' - input_field_for_live_slug = '' + input_field_for_draft_slug = '' + input_field_for_live_slug = '' # Status Link should be the live page (not revision) self.assertNotContains( @@ -1951,8 +1951,8 @@ class TestPageEdit(WagtailTestUtils, TestCase): reverse("wagtailadmin_pages:edit", args=(self.single_event_page.id,)) ) - input_field_for_draft_slug = '' - input_field_for_live_slug = '' + input_field_for_draft_slug = '' + input_field_for_live_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 index 8b92dc1b32..0249ff653f 100644 --- a/wagtail/admin/tests/test_widgets.py +++ b/wagtail/admin/tests/test_widgets.py @@ -704,7 +704,7 @@ class TestSlugInput(TestCase): html = widget.render("test", None, attrs={"id": "test-id"}) self.assertInHTML( - '', + '', html, ) diff --git a/wagtail/admin/widgets/slug.py b/wagtail/admin/widgets/slug.py index 99eeee38f1..6f43f16089 100644 --- a/wagtail/admin/widgets/slug.py +++ b/wagtail/admin/widgets/slug.py @@ -18,6 +18,7 @@ class SlugInput(widgets.TextInput): settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True ), "data-w-slug-compare-as-param": "urlify", + "data-w-slug-trim-value": "true", } if attrs: default_attrs.update(attrs) From c010a1e5eb307cd83eb0055887180259d53501ad Mon Sep 17 00:00:00 2001 From: LB Date: Sun, 17 Nov 2024 00:15:08 +1000 Subject: [PATCH 5/7] CleanController - Add event dispatching - Dispatch events when a value is applied to a field - use `:applied` and ensure the clean/source value is available + the action used to clean --- .../src/controllers/CleanController.test.js | 43 +++++++++++++++++++ client/src/controllers/CleanController.ts | 26 ++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/client/src/controllers/CleanController.test.js b/client/src/controllers/CleanController.test.js index ff2efa5fe6..27757bdea9 100644 --- a/client/src/controllers/CleanController.test.js +++ b/client/src/controllers/CleanController.test.js @@ -4,6 +4,22 @@ import { CleanController } from './CleanController'; describe('CleanController', () => { let application; + const eventNames = ['w-clean:applied']; + + const events = {}; + + eventNames.forEach((name) => { + document.addEventListener(name, (event) => { + events[name].push(event); + }); + }); + + beforeEach(() => { + eventNames.forEach((name) => { + events[name] = []; + }); + }); + describe('compare', () => { beforeEach(() => { application?.stop(); @@ -211,6 +227,8 @@ describe('CleanController', () => { }); it('should trim and slugify the input value when focus is moved away from it', async () => { + expect(events['w-clean:applied']).toHaveLength(0); + const input = document.getElementById('slug'); input.value = ' slug testing on edit page '; @@ -221,9 +239,18 @@ describe('CleanController', () => { expect(document.getElementById('slug').value).toEqual( '-slug-testing-on-edit-page-', // non-trimmed adds dashes for all spaces (inc. end/start) ); + + expect(events['w-clean:applied']).toHaveLength(1); + expect(events['w-clean:applied']).toHaveProperty('0.detail', { + action: 'slugify', + cleanValue: '-slug-testing-on-edit-page-', // non-trimmed adds dashes for all spaces (inc. end/start) + sourceValue: ' slug testing on edit page ', + }); }); it('should slugify & trim (when enabled) the input value when focus is moved away from it', async () => { + expect(events['w-clean:applied']).toHaveLength(0); + const input = document.getElementById('slug'); input.setAttribute('data-w-clean-trim-value', 'true'); // enable trimmed values @@ -237,6 +264,13 @@ describe('CleanController', () => { expect(document.getElementById('slug').value).toEqual( 'slug-testing-on-edit-page', ); + + expect(events['w-clean:applied']).toHaveLength(1); + expect(events['w-clean:applied']).toHaveProperty('0.detail', { + action: 'slugify', + cleanValue: 'slug-testing-on-edit-page', + sourceValue: ' slug testing on edit page ', + }); }); it('should not allow unicode characters by default', async () => { @@ -297,6 +331,8 @@ describe('CleanController', () => { }); it('should update slug input value if the values are the same', async () => { + expect(events['w-clean:applied']).toHaveLength(0); + const input = document.getElementById('slug'); input.value = 'urlify Testing On edit page '; @@ -310,6 +346,13 @@ describe('CleanController', () => { await new Promise(process.nextTick); expect(input.value).toBe('urlify-testing-on-edit-page'); + + expect(events['w-clean:applied']).toHaveLength(1); + expect(events['w-clean:applied']).toHaveProperty('0.detail', { + action: 'urlify', + cleanValue: 'urlify-testing-on-edit-page', + sourceValue: 'urlify Testing On edit page', + }); }); it('should transform input with special (unicode) characters to their ASCII equivalent by default', async () => { diff --git a/client/src/controllers/CleanController.ts b/client/src/controllers/CleanController.ts index ecd23fd66c..8a11e2398f 100644 --- a/client/src/controllers/CleanController.ts +++ b/client/src/controllers/CleanController.ts @@ -38,12 +38,24 @@ export class CleanController extends Controller { declare readonly trimValue: boolean; /** - * Writes the new value to the element. + * Writes the new value to the element & dispatches the applied event. + * + * @fires CleanController#applied - If a change applied to the input value, this event is dispatched. + * + * @event CleanController#applied + * @type {CustomEvent} + * @property {string} name - `w-slug:applied` | `w-clean:applied` + * @property {Object} detail + * @property {string} detail.action - The action that was applied (e.g. 'urlify' or 'slugify'). + * @property {string} detail.cleanValue - The the cleaned value that is applied. + * @property {string} detail.sourceValue - The original value. */ - applyUpdate(action: Actions, cleanValue: string) { - if (action) { - this.element.value = cleanValue; - } + applyUpdate(action: Actions, cleanValue: string, sourceValue?: string) { + this.element.value = cleanValue; + this.dispatch('applied', { + cancelable: false, + detail: { action, cleanValue, sourceValue }, + }); } /** @@ -112,7 +124,7 @@ export class CleanController extends Controller { const cleanValue = slugify(preparedValue, { allowUnicode }); if (!ignoreUpdate) { - this.applyUpdate(Actions.Slugify, cleanValue); + this.applyUpdate(Actions.Slugify, cleanValue, sourceValue); } return cleanValue; @@ -147,7 +159,7 @@ export class CleanController extends Controller { ); if (!ignoreUpdate) { - this.applyUpdate(Actions.Urlify, cleanValue); + this.applyUpdate(Actions.Urlify, cleanValue, sourceValue); } return cleanValue; From 8751ac27eb20e9d45cb140509a2db7fe510dd11c Mon Sep 17 00:00:00 2001 From: LB Date: Mon, 18 Nov 2024 20:15:30 +1000 Subject: [PATCH 6/7] CleanController - Add identity action This allows the comparison to run and always return true --- .../src/controllers/CleanController.test.js | 50 +++++++++++++++++++ client/src/controllers/CleanController.ts | 13 +++++ 2 files changed, 63 insertions(+) diff --git a/client/src/controllers/CleanController.test.js b/client/src/controllers/CleanController.test.js index 27757bdea9..b591745b4a 100644 --- a/client/src/controllers/CleanController.test.js +++ b/client/src/controllers/CleanController.test.js @@ -207,6 +207,56 @@ describe('CleanController', () => { expect(event.preventDefault).toHaveBeenCalled(); }); + + it('should allow the compare as identity to ensure that the values are always considered equal', async () => { + expect(events['w-clean:applied']).toHaveLength(0); + + const input = document.getElementById('slug'); + input.setAttribute('data-w-clean-compare-as-param', 'identity'); + + input.value = 'title-alpha'; + + const event = new CustomEvent('custom:event', { + detail: { value: 'title beta' }, + }); + + event.preventDefault = jest.fn(); + + input.dispatchEvent(event); + + await new Promise(process.nextTick); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(events['w-clean:applied']).toHaveLength(1); + expect(events['w-clean:applied']).toHaveProperty('0.detail', { + action: 'identity', + cleanValue: 'title-alpha', + sourceValue: 'title-alpha', + }); + + // now use the compare from the event detail + input.removeAttribute('data-w-clean-compare-as-param'); + + input.value = 'title-delta'; + + const event2 = new CustomEvent('custom:event', { + detail: { value: 'title whatever', compareAs: 'identity' }, + }); + + event2.preventDefault = jest.fn(); + + input.dispatchEvent(event2); + + await new Promise(process.nextTick); + + expect(event2.preventDefault).not.toHaveBeenCalled(); + expect(events['w-clean:applied']).toHaveLength(2); + expect(events['w-clean:applied']).toHaveProperty('1.detail', { + action: 'identity', + cleanValue: 'title-delta', + sourceValue: 'title-delta', + }); + }); }); describe('slugify', () => { diff --git a/client/src/controllers/CleanController.ts b/client/src/controllers/CleanController.ts index 8a11e2398f..65dfbcde5e 100644 --- a/client/src/controllers/CleanController.ts +++ b/client/src/controllers/CleanController.ts @@ -3,6 +3,7 @@ import { slugify } from '../utils/slugify'; import { urlify } from '../utils/urlify'; enum Actions { + Identity = 'identity', Slugify = 'slugify', Urlify = 'urlify', } @@ -97,6 +98,18 @@ export class CleanController extends Controller { return new Set(values.map((value: string) => `${value}`)).size === 1; } + /** + * Returns the element's value as is, without any modifications. + * Useful for identity fields or when no cleaning is required but the event + * is needed or comparison is required to always pass. + */ + identity() { + const action = Actions.Identity; + const value = this.element.value; + this.applyUpdate(action, value, value); + return value; + } + /** * Prepares the value before being processed by an action method. */ From bdbdc84ba5d92f1a86631045610e486164a33fa3 Mon Sep 17 00:00:00 2001 From: LB Date: Mon, 18 Nov 2024 08:25:38 +1000 Subject: [PATCH 7/7] Add CleanController Storybook story --- .../controllers/CleanController.stories.js | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 client/src/controllers/CleanController.stories.js diff --git a/client/src/controllers/CleanController.stories.js b/client/src/controllers/CleanController.stories.js new file mode 100644 index 0000000000..f5d92c0a8d --- /dev/null +++ b/client/src/controllers/CleanController.stories.js @@ -0,0 +1,116 @@ +import React, { useCallback } from 'react'; + +import { StimulusWrapper } from '../../storybook/StimulusWrapper'; +import { CleanController } from './CleanController'; + +export default { + title: 'Stimulus / CleanController', + argTypes: { + debug: { + control: 'boolean', + defaultValue: true, + }, + }, +}; + +const definitions = [ + { + identifier: 'w-clean', + controllerConstructor: CleanController, + }, +]; + +const Template = ({ debug = false }) => { + const [sourceValues, setSourceValue] = React.useState({}); + return ( + +
{ + event.preventDefault(); + }} + ref={useCallback((node) => { + node.addEventListener( + 'w-clean:applied', + ({ target, detail: { sourceValue } }) => { + setSourceValue((state) => ({ + ...state, + [target.id]: sourceValue, + })); + }, + ); + }, [])} + > +
+ + Focus and then remove focus (blur) on fields to see changes, trim is + enabled for all. + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; + +export const Base = Template.bind({});