pull/12554/merge
LB (Ben Johnston) 2025-04-23 19:58:44 -04:00 zatwierdzone przez GitHub
commit c20f761c67
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
12 zmienionych plików z 809 dodań i 429 usunięć

Wyświetl plik

@ -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 (
<StimulusWrapper debug={debug} definitions={definitions}>
<form
onSubmit={(event) => {
event.preventDefault();
}}
ref={useCallback((node) => {
node.addEventListener(
'w-clean:applied',
({ target, detail: { sourceValue } }) => {
setSourceValue((state) => ({
...state,
[target.id]: sourceValue,
}));
},
);
}, [])}
>
<fieldset>
<legend>
Focus and then remove focus (blur) on fields to see changes, trim is
enabled for all.
</legend>
<div className="w-m-4">
<label htmlFor="slugify">
<pre>slugify</pre>
<input
id="slugify-default"
type="text"
data-controller="w-clean"
data-action="blur->w-clean#slugify"
data-w-clean-trim-value
/>
<output className="w-inline-flex w-items-center">
Source value: <pre>{sourceValues['slugify-default']}</pre>
</output>
</label>
</div>
<div className="w-m-4">
<label htmlFor="slugify-unicode">
<pre>slugify (allow unicode)</pre>
<input
id="slugify-unicode"
type="text"
data-controller="w-clean"
data-action="blur->w-clean#slugify"
data-w-clean-allow-unicode-value
data-w-clean-trim-value
/>
<output className="w-inline-flex w-items-center">
Source value: <pre>{sourceValues['slugify-unicode']}</pre>
</output>
</label>
</div>
<div className="w-m-4">
<label htmlFor="urlify-default">
<pre>urlify</pre>
<input
id="urlify-default"
type="text"
data-controller="w-clean"
data-action="blur->w-clean#urlify"
data-w-clean-trim-value
/>
<output className="w-inline-flex w-items-center">
Source value: <pre>{sourceValues['urlify-default']}</pre>
</output>
</label>
</div>
<div className="w-m-4">
<label htmlFor="urlify-unicode">
<pre>urlify (allow unicode)</pre>
<input
id="urlify-unicode"
type="text"
data-controller="w-clean"
data-action="blur->w-clean#urlify"
data-w-clean-allow-unicode-value
data-w-clean-trim-value
/>
<output className="w-inline-flex w-items-center">
Source value: <pre>{sourceValues['urlify-unicode']}</pre>
</output>
</label>
</div>
</fieldset>
</form>
</StimulusWrapper>
);
};
export const Base = Template.bind({});

Wyświetl plik

@ -0,0 +1,484 @@
import { Application } from '@hotwired/stimulus';
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();
document.body.innerHTML = `
<input
id="slug"
name="slug"
type="text"
data-controller="w-clean"
/>`;
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();
});
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', () => {
beforeEach(() => {
application?.stop();
document.body.innerHTML = `
<input
id="slug"
name="slug"
type="text"
data-controller="w-clean"
data-action="blur->w-clean#slugify"
/>`;
application = Application.start();
application.register('w-clean', 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 ';
input.dispatchEvent(new CustomEvent('blur'));
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)
);
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
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',
);
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 () => {
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');
});
});
describe('urlify', () => {
beforeEach(() => {
application?.stop();
document.body.innerHTML = `
<input
id="slug"
name="slug"
type="text"
data-controller="w-clean"
/>`;
application = Application.start();
application.register('w-clean', CleanController);
const input = document.getElementById('slug');
input.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 () => {
expect(events['w-clean:applied']).toHaveLength(0);
const input = document.getElementById('slug');
input.value = 'urlify Testing On edit page ';
const event = new CustomEvent('custom:event', {
detail: { value: 'urlify Testing On edit page' },
bubbles: false,
});
document.getElementById('slug').dispatchEvent(event);
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 () => {
const input = document.getElementById('slug');
input.value = 'Some Title with éçà Spaces';
const event = new CustomEvent('custom:event', {
detail: { value: 'Some Title with éçà Spaces' },
});
document.getElementById('slug').dispatchEvent(event);
await new Promise(process.nextTick);
expect(input.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 input = document.getElementById('slug');
input.setAttribute('data-w-clean-allow-unicode-value', 'true');
input.value = value;
const event = new CustomEvent('custom:event', { detail: { value } });
document.getElementById('slug').dispatchEvent(event);
await new Promise(process.nextTick);
expect(input.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 input = document.getElementById('slug');
input.value = '$$!@#$%^&*';
const event = new CustomEvent('custom:event', {
detail: { value: '$$!@#$%^&*' },
});
document.getElementById('slug').dispatchEvent(event);
await new Promise(process.nextTick);
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-');
});
});
});

Wyświetl plik

@ -0,0 +1,180 @@
import { Controller } from '@hotwired/stimulus';
import { slugify } from '../utils/slugify';
import { urlify } from '../utils/urlify';
enum Actions {
Identity = 'identity',
Slugify = 'slugify',
Urlify = 'urlify',
}
/**
* Adds ability to clean values of an input element with methods such as slugify or urlify.
*
* @example - Using the slugify method
* ```html
* <input type="text" name="slug" data-controller="w-clean" data-action="blur->w-clean#slugify" />
* <input type="text" name="slug-with-trim" data-controller="w-clean" data-action="blur->w-clean#slugify" data-w-slug-trim-value="true" />
* ```
*
* @example - Using the urlify method (registered as w-slug)
* ```html
* <input type="text" name="url-path" data-controller="w-slug" data-action="change->w-slug#urlify" />
* <input type="text" name="url-path-with-unicode" data-controller="w-slug" data-w-slug-allow-unicode="true" data-action="change->w-slug#urlify" />
* ```
*/
export class CleanController extends Controller<HTMLInputElement> {
static values = {
allowUnicode: { default: false, type: Boolean },
trim: { default: false, type: 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;
/** 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 & 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, sourceValue?: string) {
this.element.value = cleanValue;
this.dispatch('applied', {
cancelable: false,
detail: { action, cleanValue, sourceValue },
});
}
/**
* Allow for a comparison value to be provided so that a dispatched event can be
* prevented. This provides a way for other events to interact with this controller
* to block further updates if a value is not in sync.
* By default it will compare to the slugify method, this can be overridden by providing
* either a Stimulus param value on the element or the event's detail.
*/
compare(
event: CustomEvent<{ compareAs?: Actions; value: string }> & {
params?: { compareAs?: Actions };
},
) {
// 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 || '' } },
{ ignoreUpdate: true },
);
const valuesAreSame = this.compareValues(compareValue, this.element.value);
if (!valuesAreSame) {
event?.preventDefault();
}
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;
}
/**
* 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.
*/
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.
* If a custom event with detail.value is provided, that value will be used
* instead of the field's value.
*/
slugify(
event: CustomEvent<{ value: string }> | { detail: { value: string } },
{ ignoreUpdate = false } = {},
) {
const { value: sourceValue = this.element.value } = event?.detail || {};
const preparedValue = this.prepareValue(sourceValue);
if (!preparedValue) return '';
const allowUnicode = this.allowUnicodeValue;
const cleanValue = slugify(preparedValue, { allowUnicode });
if (!ignoreUpdate) {
this.applyUpdate(Actions.Slugify, cleanValue, sourceValue);
}
return cleanValue;
}
/**
* Advanced slugify of a string, updates the controlled element's value
* or can be used to simply return the transformed value.
*
* The urlify (Django port) function performs extra processing on the string &
* is more suitable for creating a slug from the title, rather than sanitizing manually.
* If the urlify util returns an empty string it will fall back to the slugify method.
*
* If a custom event with detail.value is provided, that value will be used
* instead of the field's value.
*/
urlify(
event: CustomEvent<{ value: string }> | { detail: { value: string } },
{ ignoreUpdate = false } = {},
) {
const { value: sourceValue = this.element.value } = event?.detail || {};
const preparedValue = this.prepareValue(sourceValue);
if (!preparedValue) return '';
const allowUnicode = this.allowUnicodeValue;
const cleanValue =
urlify(preparedValue, { allowUnicode }) ||
this.slugify(
{ detail: { value: preparedValue } },
{ ignoreUpdate: true },
);
if (!ignoreUpdate) {
this.applyUpdate(Actions.Urlify, cleanValue, sourceValue);
}
return cleanValue;
}
}

Wyświetl plik

@ -1,310 +0,0 @@
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 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');
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');
});
});
describe('compare behavior', () => {
let application;
beforeEach(() => {
application?.stop();
document.body.innerHTML = `
<input
id="id_slug"
name="slug"
type="text"
data-controller="w-slug"
/>`;
application = Application.start();
application.register('w-slug', SlugController);
const slugInput = document.querySelector('#id_slug');
slugInput.dataset.action = [
'blur->w-slug#urlify',
'custom:event->w-slug#compare',
].join(' ');
});
it('should not prevent default if input has no value', () => {
const event = new CustomEvent('custom:event', {
detail: { value: 'title alpha' },
});
event.preventDefault = jest.fn();
document.getElementById('id_slug').dispatchEvent(event);
expect(document.getElementById('id_slug').value).toBe('');
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('should not prevent default if the values are the same', () => {
document.querySelector('#id_slug').setAttribute('value', 'title-alpha');
const event = new CustomEvent('custom:event', {
detail: { value: 'title alpha' },
});
event.preventDefault = jest.fn();
document.getElementById('id_slug').dispatchEvent(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('should prevent default using the slugify (default) behavior as the compare function when urlify values is not equal', () => {
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'));
expect(slug.value).toEqual('testovij-zagolovok');
const event = new CustomEvent('custom:event', { detail: { value: title } });
event.preventDefault = jest.fn();
slug.dispatchEvent(event);
// 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', () => {
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);
// 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', () => {
const title = 'Тестовий заголовок';
const slug = document.querySelector('#id_slug');
slug.setAttribute('data-w-slug-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'));
expect(slug.value).toEqual('testovij-zagolovok');
const event = new CustomEvent('custom:event', {
detail: { compareAs: 'urlify', value: title },
});
event.preventDefault = jest.fn();
slug.dispatchEvent(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('should prevent default if the values are not the same', () => {
document.querySelector('#id_slug').setAttribute('value', 'title-alpha');
const event = new CustomEvent('custom:event', {
detail: { value: 'title beta' },
});
event.preventDefault = jest.fn();
document.getElementById('id_slug').dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it('should not prevent default if both values are empty strings', () => {
const slugInput = document.querySelector('#id_slug');
slugInput.setAttribute('value', '');
const event = new CustomEvent('custom:event', {
detail: { value: '' },
});
event.preventDefault = jest.fn();
slugInput.dispatchEvent(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('should prevent default if the new value is an empty string but the existing value is not', () => {
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);
expect(event.preventDefault).toHaveBeenCalled();
});
});
describe('urlify behavior', () => {
let application;
beforeEach(() => {
application?.stop();
document.body.innerHTML = `
<input
id="id_slug"
name="slug"
type="text"
data-controller="w-slug"
/>`;
application = Application.start();
application.register('w-slug', SlugController);
const slugInput = document.querySelector('#id_slug');
slugInput.dataset.action = [
'blur->w-slug#slugify',
'custom:event->w-slug#urlify:prevent',
].join(' ');
});
it('should update slug input value if the values are the same', () => {
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);
expect(slugInput.value).toBe('urlify-testing-on-edit-page');
});
it('should transform input with special (unicode) characters to their ASCII equivalent by default', () => {
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);
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', () => {
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.value = value;
const event = new CustomEvent('custom:event', { detail: { value } });
document.getElementById('id_slug').dispatchEvent(event);
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', () => {
const slugInput = document.getElementById('id_slug');
slugInput.value = '$$!@#$%^&*';
const event = new CustomEvent('custom:event', {
detail: { value: '$$!@#$%^&*' },
});
document.getElementById('id_slug').dispatchEvent(event);
expect(slugInput.value).toBe('');
});
});

Wyświetl plik

@ -1,108 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { slugify } from '../utils/slugify';
import { urlify } from '../utils/urlify';
type SlugMethods = 'slugify' | 'urlify';
/**
* Adds ability to slugify the value of an input element.
*
* @example
* ```html
* <input type="text" name="slug" data-controller="w-slug" data-action="blur->w-slug#slugify" />
* ```
*/
export class SlugController extends Controller<HTMLInputElement> {
static values = {
allowUnicode: { default: false, type: Boolean },
};
declare allowUnicodeValue: boolean;
/**
* Allow for a comparison value to be provided so that a dispatched event can be
* prevented. This provides a way for other events to interact with this controller
* to block further updates if a value is not in sync.
* By default it will compare to the slugify method, this can be overridden by providing
* either a Stimulus param value on the element or the event's detail.
*/
compare(
event: CustomEvent<{ compareAs?: SlugMethods; value: string }> & {
params?: { compareAs?: SlugMethods };
},
) {
// do not attempt to compare if the current field is empty
if (!this.element.value) {
return true;
}
const compareAs =
event.detail?.compareAs || event.params?.compareAs || 'slugify';
const compareValue = this[compareAs](
{ detail: { value: event.detail?.value || '' } },
true,
);
const currentValue = this.element.value;
const valuesAreSame = compareValue.trim() === currentValue.trim();
if (!valuesAreSame) {
event?.preventDefault();
}
return valuesAreSame;
}
/**
* Basic slugify of a string, updates the controlled element's value
* or can be used to simply return the transformed value.
* If a custom event with detail.value is provided, that value will be used
* instead of the field's value.
*/
slugify(
event: CustomEvent<{ value: string }> | { detail: { value: string } },
ignoreUpdate = false,
) {
const allowUnicode = this.allowUnicodeValue;
const { value = this.element.value } = event?.detail || {};
const newValue = slugify(value.trim(), { allowUnicode });
if (!ignoreUpdate) {
this.element.value = newValue;
}
return newValue;
}
/**
* Advanced slugify of a string, updates the controlled element's value
* or can be used to simply return the transformed value.
*
* The urlify (Django port) function performs extra processing on the string &
* is more suitable for creating a slug from the title, rather than sanitizing manually.
* If the urlify util returns an empty string it will fall back to the slugify method.
*
* If a custom event with detail.value is provided, that value will be used
* instead of the field's value.
*/
urlify(
event: CustomEvent<{ value: string }> | { detail: { value: string } },
ignoreUpdate = false,
) {
const allowUnicode = this.allowUnicodeValue;
const { value = this.element.value } = event?.detail || {};
const trimmedValue = value.trim();
const newValue =
urlify(trimmedValue, { allowUnicode }) ||
this.slugify({ detail: { value: trimmedValue } }, true);
if (!ignoreUpdate) {
this.element.value = newValue;
}
return newValue;
}
}

Wyświetl plik

@ -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' },

Wyświetl plik

@ -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');
});
});
});

Wyświetl plik

@ -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');
});
});
});

Wyświetl plik

@ -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

Wyświetl plik

@ -1925,8 +1925,8 @@ class TestPageEdit(WagtailTestUtils, TestCase):
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" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" 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" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" 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_draft_slug = '<input type="text" name="slug" value="revised-slug-in-draft-only" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" data-w-slug-allow-unicode-value data-w-slug-trim-value="true" 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" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" data-w-slug-allow-unicode-value data-w-slug-trim-value="true" 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(
@ -1951,8 +1951,8 @@ class TestPageEdit(WagtailTestUtils, TestCase):
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" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" data-w-slug-allow-unicode-value 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" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" data-w-slug-allow-unicode-value maxlength="255" aria-describedby="panel-child-promote-child-common_page_configuration-child-slug-helptext" required id="id_slug" />'
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 w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" data-w-slug-allow-unicode-value data-w-slug-trim-value="true" 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" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-compare-as-param="urlify" data-w-slug-allow-unicode-value data-w-slug-trim-value="true" 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(

Wyświetl plik

@ -704,7 +704,7 @@ class TestSlugInput(TestCase):
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 w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-allow-unicode-value data-w-slug-compare-as-param="urlify" id="test-id">',
'<input type="text" name="test" data-controller="w-slug" data-action="blur-&gt;w-slug#slugify w-sync:check-&gt;w-slug#compare w-sync:apply-&gt;w-slug#urlify:prevent" data-w-slug-allow-unicode-value data-w-slug-compare-as-param="urlify" data-w-slug-trim-value="true" id="test-id">',
html,
)

Wyświetl plik

@ -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.
@ -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)