kopia lustrzana https://github.com/wagtail/wagtail
Merge bdbdc84ba5
into 74b5933ae7
commit
c20f761c67
|
@ -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({});
|
|
@ -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-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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('');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,6 +5,7 @@ import { ActionController } from './ActionController';
|
||||||
import { AutosizeController } from './AutosizeController';
|
import { AutosizeController } from './AutosizeController';
|
||||||
import { BlockController } from './BlockController';
|
import { BlockController } from './BlockController';
|
||||||
import { BulkController } from './BulkController';
|
import { BulkController } from './BulkController';
|
||||||
|
import { CleanController } from './CleanController';
|
||||||
import { ClipboardController } from './ClipboardController';
|
import { ClipboardController } from './ClipboardController';
|
||||||
import { CloneController } from './CloneController';
|
import { CloneController } from './CloneController';
|
||||||
import { CountController } from './CountController';
|
import { CountController } from './CountController';
|
||||||
|
@ -23,7 +24,6 @@ import { ProgressController } from './ProgressController';
|
||||||
import { RevealController } from './RevealController';
|
import { RevealController } from './RevealController';
|
||||||
import { RulesController } from './RulesController';
|
import { RulesController } from './RulesController';
|
||||||
import { SessionController } from './SessionController';
|
import { SessionController } from './SessionController';
|
||||||
import { SlugController } from './SlugController';
|
|
||||||
import { SubmitController } from './SubmitController';
|
import { SubmitController } from './SubmitController';
|
||||||
import { SwapController } from './SwapController';
|
import { SwapController } from './SwapController';
|
||||||
import { SyncController } from './SyncController';
|
import { SyncController } from './SyncController';
|
||||||
|
@ -43,6 +43,8 @@ export const coreControllerDefinitions: Definition[] = [
|
||||||
{ controllerConstructor: AutosizeController, identifier: 'w-autosize' },
|
{ controllerConstructor: AutosizeController, identifier: 'w-autosize' },
|
||||||
{ controllerConstructor: BlockController, identifier: 'w-block' },
|
{ controllerConstructor: BlockController, identifier: 'w-block' },
|
||||||
{ controllerConstructor: BulkController, identifier: 'w-bulk' },
|
{ controllerConstructor: BulkController, identifier: 'w-bulk' },
|
||||||
|
{ controllerConstructor: CleanController, identifier: 'w-clean' },
|
||||||
|
{ controllerConstructor: CleanController, identifier: 'w-slug' },
|
||||||
{ controllerConstructor: ClipboardController, identifier: 'w-clipboard' },
|
{ controllerConstructor: ClipboardController, identifier: 'w-clipboard' },
|
||||||
{ controllerConstructor: CloneController, identifier: 'w-clone' },
|
{ controllerConstructor: CloneController, identifier: 'w-clone' },
|
||||||
{ controllerConstructor: CloneController, identifier: 'w-messages' },
|
{ controllerConstructor: CloneController, identifier: 'w-messages' },
|
||||||
|
@ -63,7 +65,6 @@ export const coreControllerDefinitions: Definition[] = [
|
||||||
{ controllerConstructor: RevealController, identifier: 'w-reveal' },
|
{ controllerConstructor: RevealController, identifier: 'w-reveal' },
|
||||||
{ controllerConstructor: RulesController, identifier: 'w-rules' },
|
{ controllerConstructor: RulesController, identifier: 'w-rules' },
|
||||||
{ controllerConstructor: SessionController, identifier: 'w-session' },
|
{ controllerConstructor: SessionController, identifier: 'w-session' },
|
||||||
{ controllerConstructor: SlugController, identifier: 'w-slug' },
|
|
||||||
{ controllerConstructor: SubmitController, identifier: 'w-submit' },
|
{ controllerConstructor: SubmitController, identifier: 'w-submit' },
|
||||||
{ controllerConstructor: SwapController, identifier: 'w-swap' },
|
{ controllerConstructor: SwapController, identifier: 'w-swap' },
|
||||||
{ controllerConstructor: SyncController, identifier: 'w-sync' },
|
{ controllerConstructor: SyncController, identifier: 'w-sync' },
|
||||||
|
|
|
@ -9,6 +9,10 @@ describe('slugify', () => {
|
||||||
'lisboa--tima--beira-mar',
|
'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', () => {
|
describe('slugify with unicode slugs enabled', () => {
|
||||||
|
@ -33,5 +37,9 @@ describe('slugify', () => {
|
||||||
);
|
);
|
||||||
expect(slugify('উইকিপিডিয়ায় স্বাগতম!', options)).toBe('উইকপডযয-সবগতম');
|
expect(slugify('উইকিপিডিয়ায় স্বাগতম!', options)).toBe('উইকপডযয-সবগতম');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep leading spaces & convert to hyphens if supplied', () => {
|
||||||
|
expect(slugify(' I like _ßpaces', options)).toBe('-i-like-_ßpaces');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,10 @@ describe('urlify', () => {
|
||||||
'lisboa-e-otima-a-beira-mar',
|
'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', () => {
|
describe('urlify with unicode slugs enabled', () => {
|
||||||
|
@ -30,5 +34,9 @@ describe('urlify', () => {
|
||||||
'lisboa-é-ótima-à-beira-mar',
|
'lisboa-é-ótima-à-beira-mar',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep leading spaces & convert to hyphens if supplied', () => {
|
||||||
|
expect(urlify(' I like _ßpaces', options)).toBe('-i-like-_ßpaces');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,8 +12,9 @@ const downcodeMapping = config.reduce((acc, downcodeMap) => {
|
||||||
const regex = new RegExp(Object.keys(downcodeMapping).join('|'), 'g');
|
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,
|
* This util and the mapping is refined port of Django's urlify.js util.
|
||||||
* without the need for a full Regex polyfill implementation.
|
* 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
|
* @see https://github.com/django/django/blob/main/django/contrib/admin/static/admin/js/urlify.js
|
||||||
*/
|
*/
|
||||||
export const urlify = (
|
export const urlify = (
|
||||||
|
@ -37,7 +38,6 @@ export const urlify = (
|
||||||
} else {
|
} else {
|
||||||
str = str.replace(/[^-\w\s]/g, ''); // remove unneeded chars
|
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.replace(/[-\s]+/g, '-'); // convert spaces to hyphens
|
||||||
str = str.substring(0, numChars); // trim to first num_chars chars
|
str = str.substring(0, numChars); // trim to first num_chars chars
|
||||||
str = str.replace(/-+$/g, ''); // trim any trailing hyphens
|
str = str.replace(/-+$/g, ''); // trim any trailing hyphens
|
||||||
|
|
|
@ -1925,8 +1925,8 @@ class TestPageEdit(WagtailTestUtils, TestCase):
|
||||||
reverse("wagtailadmin_pages:edit", args=(self.child_page.id,))
|
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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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)
|
# Status Link should be the live page (not revision)
|
||||||
self.assertNotContains(
|
self.assertNotContains(
|
||||||
|
@ -1951,8 +1951,8 @@ class TestPageEdit(WagtailTestUtils, TestCase):
|
||||||
reverse("wagtailadmin_pages:edit", args=(self.single_event_page.id,))
|
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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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)
|
# Status Link should be the live page (not revision)
|
||||||
self.assertNotContains(
|
self.assertNotContains(
|
||||||
|
|
|
@ -704,7 +704,7 @@ class TestSlugInput(TestCase):
|
||||||
html = widget.render("test", None, attrs={"id": "test-id"})
|
html = widget.render("test", None, attrs={"id": "test-id"})
|
||||||
|
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
'<input type="text" name="test" data-controller="w-slug" data-action="blur->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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->w-slug#slugify w-sync:check->w-slug#compare w-sync:apply->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,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.forms import widgets
|
||||||
|
|
||||||
class SlugInput(widgets.TextInput):
|
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
|
Slugifies content based on `WAGTAIL_ALLOW_UNICODE_SLUGS` and supports
|
||||||
fields syncing their value to this field (see `TitleFieldPanel`) if
|
fields syncing their value to this field (see `TitleFieldPanel`) if
|
||||||
also used.
|
also used.
|
||||||
|
@ -18,6 +18,7 @@ class SlugInput(widgets.TextInput):
|
||||||
settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True
|
settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True
|
||||||
),
|
),
|
||||||
"data-w-slug-compare-as-param": "urlify",
|
"data-w-slug-compare-as-param": "urlify",
|
||||||
|
"data-w-slug-trim-value": "true",
|
||||||
}
|
}
|
||||||
if attrs:
|
if attrs:
|
||||||
default_attrs.update(attrs)
|
default_attrs.update(attrs)
|
||||||
|
|
Ładowanie…
Reference in New Issue