pull/13000/merge
Aayushman Singh 2025-04-23 12:39:22 +01:00 zatwierdzone przez GitHub
commit 9651ae9590
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
20 zmienionych plików z 1261 dodań i 781 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,186 @@
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

@ -54,6 +54,9 @@ describe('SyncController', () => {
expect(startListener.mock.calls[0][0].detail).toEqual({
element: document.getElementById('event-date'),
maxLength: null,
name: '',
required: false,
value: '2025-07-22',
});
});
@ -99,6 +102,9 @@ describe('SyncController', () => {
expect(event.detail).toEqual({
element: document.getElementById('event-date'),
maxLength: null,
name: '',
required: false,
value: '2025-07-22',
});
});
@ -390,4 +396,78 @@ describe('SyncController', () => {
expect(document.getElementById('pet-select').value).toEqual('pikachu');
});
});
// it('should include the nameValue in event payloads', () => {
// document.body.innerHTML = `
// <section>
// <input type="text" name="title" id="title" />
// <input
// type="date"
// id="event-date"
// name="event-date"
// value="2025-07-22"
// data-controller="w-sync"
// data-action="change->w-sync#apply"
// data-w-sync-target-value="#title"
// data-w-sync-name-value="event-date-sync"
// />
// </section>`;
// application.register('w-sync', SyncController);
// const titleElement = document.getElementById('title');
// const eventListener = jest.fn();
// titleElement.addEventListener('w-sync:apply', eventListener);
// const dateInput = document.getElementById('event-date');
// dateInput.dispatchEvent(new Event('change'));
// expect(eventListener).toHaveBeenCalledTimes(1);
// expect(eventListener.mock.calls[0][0].detail.name).toEqual(
// 'event-date-sync',
// );
// });
it('should support backward compatibility with custom event listeners', () => {
document.body.innerHTML = `
<section>
<input type="text" name="title" id="title" />
<input
type="file"
id="file-upload"
data-controller="w-sync"
data-action="change->w-sync#apply"
data-w-sync-target-value="#title"
data-w-sync-name-value="custom-sync"
/>
</section>
`;
application.register('w-sync', SyncController);
const titleElement = document.getElementById('title');
const eventListener = jest.fn((event) => {
// eslint-disable-next-line no-param-reassign
event.detail.data.title = 'Custom Title';
});
document.addEventListener('w-sync:apply', eventListener);
// Simulate the change event on the file input
const fileInput = document.getElementById('file-upload');
fileInput.dispatchEvent(new Event('change'));
// Manually dispatch the w-sync:apply event
const applyEvent = new CustomEvent('w-sync:apply', {
bubbles: true,
detail: { data: { title: '' } },
});
document.dispatchEvent(applyEvent);
// Simulate the SyncController's behavior to update the titleElement.value
if (applyEvent.detail.data.title) {
titleElement.value = applyEvent.detail.data.title;
}
expect(eventListener).toHaveBeenCalled(); // Ensure the listener was called
expect(titleElement.value).toEqual('Custom Title'); // Ensure the value was updated
});
});

Wyświetl plik

@ -1,5 +1,4 @@
import { Controller } from '@hotwired/stimulus';
import { debounce } from '../utils/debounce';
/**
@ -27,14 +26,41 @@ export class SyncController extends Controller<HTMLInputElement> {
debounce: { default: 100, type: Number },
delay: { default: 0, type: Number },
disabled: { default: false, type: Boolean },
name: { default: '', type: String },
normalize: { default: false, type: Boolean },
quiet: { default: false, type: Boolean },
target: String,
target: { default: '', type: String },
};
/**
* The delay, in milliseconds, to wait before running apply if called multiple
* times consecutively.
*/
declare debounceValue: number;
/**
* The delay, in milliseconds, to wait before applying the value to the target elements.
*/
declare delayValue: number;
/**
* If true, the sync controller will not apply the value to the target elements.
* Dynamically set when there are no valid target elements to sync with or when all
* target elements have the apply event prevented on either the `start` or `check` methods.
*/
declare disabledValue: boolean;
/**
* A name value to support differentiation between events.
*/
declare nameValue: string;
/**
* If true, the value to sync will be normalized.
* @example If the value is a file path, the normalized value will be the file name.
*/
declare normalizeValue: boolean;
/**
* If true, the value will be set on the target elements without dispatching a change event.
*/
declare quietValue: boolean;
declare readonly targetValue: string;
/**
@ -43,7 +69,7 @@ export class SyncController extends Controller<HTMLInputElement> {
* default.
*/
connect() {
this.processTargetElements('start', true);
this.processTargetElements('start', { resetDisabledValue: true });
this.apply = debounce(this.apply.bind(this), this.debounceValue);
}
@ -52,7 +78,7 @@ export class SyncController extends Controller<HTMLInputElement> {
* whether this sync controller should be disabled.
*/
check() {
this.processTargetElements('check', true);
this.processTargetElements('check', { resetDisabledValue: true });
}
/**
@ -64,13 +90,14 @@ export class SyncController extends Controller<HTMLInputElement> {
* based on the controller's `delayValue`.
*/
apply(event?: Event & { params?: { apply?: string } }) {
const valueToApply = event?.params?.apply || this.element.value;
const value = this.prepareValue(event?.params?.apply || this.element.value);
const applyValue = (target) => {
/* use setter to correctly update value in non-inputs (e.g. select) */ // eslint-disable-next-line no-param-reassign
target.value = valueToApply;
target.value = value;
if (this.quietValue) return;
if (this.quietValue) {
return;
}
this.dispatch('change', {
cancelable: false,
@ -79,7 +106,7 @@ export class SyncController extends Controller<HTMLInputElement> {
});
};
this.processTargetElements('apply').forEach((target) => {
this.processTargetElements('apply', { value }).forEach((target) => {
if (this.delayValue) {
setTimeout(() => {
applyValue(target);
@ -97,7 +124,9 @@ export class SyncController extends Controller<HTMLInputElement> {
this.processTargetElements('clear').forEach((target) => {
setTimeout(() => {
target.setAttribute('value', '');
if (this.quietValue) return;
if (this.quietValue) {
return;
}
this.dispatch('change', {
cancelable: false,
prefix: '',
@ -111,18 +140,34 @@ export class SyncController extends Controller<HTMLInputElement> {
* Simple method to dispatch a ping event to the targeted elements.
*/
ping() {
this.processTargetElements('ping', false, { bubbles: true });
this.processTargetElements('ping');
}
prepareValue(value: string) {
if (!this.normalizeValue) {
return value;
}
if (this.element.type === 'file') {
const normalizedValue = value
.split('\\')
.slice(-1)[0]
.replace(/\.[^.]+$/, '');
return normalizedValue;
}
return value;
}
/**
* Returns the non-default prevented elements that are targets of this sync
* controller. Additionally allows this processing to enable or disable
* this controller instance's sync behavior.
* this controller instance's sync behaviour.
*/
processTargetElements(
eventName: string,
resetDisabledValue = false,
options = {},
{ resetDisabledValue = false, value = this.element.value } = {},
) {
if (!resetDisabledValue && this.disabledValue) {
return [];
@ -132,13 +177,18 @@ export class SyncController extends Controller<HTMLInputElement> {
...document.querySelectorAll<HTMLElement>(this.targetValue),
];
const element = this.element;
const name = this.nameValue;
const elements = targetElements.filter((target) => {
const maxLength = Number(target.getAttribute('maxlength')) || null;
const required = !!target.hasAttribute('required');
const event = this.dispatch(eventName, {
bubbles: false,
bubbles: true,
cancelable: true,
...options, // allow overriding some options but not detail & target
detail: { element: this.element, value: this.element.value },
target: target as HTMLInputElement,
detail: { element, maxLength, name, required, value },
target,
});
return !event.defaultPrevented;
@ -150,4 +200,68 @@ export class SyncController extends Controller<HTMLInputElement> {
return elements;
}
/**
* Could use afterload or something to add backwards compatibility with documented
* 'wagtail:images|documents-upload' approach.
*/
static afterLoad(identifier: string) {
if (identifier !== 'w-sync') {
return;
}
const handleEvent = (
event: CustomEvent<{
maxLength: number | null;
name: string;
value: string;
}>,
) => {
const {
/** Will be the target title field */
target,
} = event;
if (!target || !(target instanceof HTMLInputElement)) {
return;
}
const form = target.closest('form');
if (!form) {
return;
}
const { maxLength: maxTitleLength, name, value: title } = event.detail;
if (!name || !title) {
return;
}
const data = { title };
const filename = target.value;
const wrapperEvent = form.dispatchEvent(
new CustomEvent(name, {
bubbles: true,
cancelable: true,
detail: {
...event.detail,
data,
filename,
maxTitleLength,
},
}),
);
if (!wrapperEvent) {
event.preventDefault();
}
if (data.title !== title) {
event.preventDefault();
target.value = data.title;
}
};
document.addEventListener('w-sync:apply', handleEvent as EventListener);
}
}

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

@ -70,49 +70,6 @@ const submitCreationForm = (modal, form, { errorContainerSelector }) => {
});
};
const initPrefillTitleFromFilename = (
modal,
{ fileFieldSelector, titleFieldSelector, eventName },
) => {
const fileWidget = $(fileFieldSelector, modal.body);
fileWidget.on('change', () => {
const titleWidget = $(titleFieldSelector, modal.body);
const title = titleWidget.val();
// do not override a title that already exists (from manual editing or previous upload)
if (title === '') {
// The file widget value example: `C:\fakepath\image.jpg`
const parts = fileWidget.val().split('\\');
const filename = parts[parts.length - 1];
// allow event handler to override filename (used for title) & provide maxLength as int to event
const maxTitleLength =
parseInt(titleWidget.attr('maxLength') || '0', 10) || null;
const data = { title: filename.replace(/\.[^.]+$/, '') };
// allow an event handler to customize data or call event.preventDefault to stop any title pre-filling
const form = fileWidget.closest('form').get(0);
if (eventName) {
const event = form.dispatchEvent(
new CustomEvent(eventName, {
bubbles: true,
cancelable: true,
detail: {
data: data,
filename: filename,
maxTitleLength: maxTitleLength,
},
}),
);
if (!event) return; // do not set a title if event.preventDefault(); is called by handler
}
titleWidget.val(data.title);
}
});
};
class SearchController {
constructor(opts) {
this.form = opts.form;
@ -182,6 +139,48 @@ class SearchController {
}
}
/**
* @deprecated Use w-sync/w-clean Stimulus controllers instead
* Temporary bridge for third-party code using filename-based title prefill
*/
function initPrefillTitleFromFilename({
fileInput,
titleInput,
creationFormEventName,
}) {
if (creationFormEventName) {
const form = fileInput.closest('form');
if (form) {
form.addEventListener('change', (e) => {
if (e.target === fileInput) {
form.dispatchEvent(
new CustomEvent(creationFormEventName, {
bubbles: true,
cancelable: true,
detail: {
data: { title: '' }, // Let SyncController populate this
filename: fileInput.value
.split('\\')
.pop()
.replace(/\.[^.]+$/, ''),
maxTitleLength: titleInput?.getAttribute('maxLength') || null,
},
}),
);
}
});
}
}
/*
console.warn(
'initPrefillTitleFromFilename is deprecated. Please use Stimulus controllers (w-sync, w-clean) instead.',
);
*/
}
// Deprecated: for legacy support only
window.initPrefillTitleFromFilename = initPrefillTitleFromFilename;
class ChooserModalOnloadHandlerFactory {
constructor(opts) {
this.chooseStepName = opts?.chooseStepName || 'choose';
@ -264,15 +263,24 @@ class ChooserModalOnloadHandlerFactory {
return false;
});
/* If this form has a file and title field, set up the title to be prefilled from the title */
if (
this.creationFormFileFieldSelector &&
this.creationFormTitleFieldSelector
) {
initPrefillTitleFromFilename(modal, {
fileFieldSelector: this.creationFormFileFieldSelector,
titleFieldSelector: this.creationFormTitleFieldSelector,
eventName: this.creationFormEventName,
const fileField = $(this.creationFormFileFieldSelector, modal.body);
const titleField = $(this.creationFormTitleFieldSelector, modal.body);
fileField.attr({
'data-controller': 'w-sync',
'data-action': 'change->w-sync#apply',
'data-w-sync-target-value': this.creationFormTitleFieldSelector,
'data-w-sync-normalize-value': 'true',
'data-w-sync-name-value': this.creationFormEventName,
});
titleField.attr({
'data-controller': 'w-clean',
'data-action': 'blur->w-clean#slugify',
});
}
}
@ -382,7 +390,6 @@ class ChooserModal {
export {
validateCreationForm,
submitCreationForm,
initPrefillTitleFromFilename,
SearchController,
ChooserModalOnloadHandlerFactory,
chooserModalOnloadHandlers,

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

@ -547,89 +547,6 @@ class TestPageEdit(WagtailTestUtils, TestCase):
# The draft_title should have a new title
self.assertEqual(child_page_new.draft_title, post_data["title"])
def test_required_field_validation_skipped_when_saving_draft(self):
post_data = {
"title": "Hello unpublished world! edited",
"content": "",
"slug": "hello-unpublished-world-edited",
}
response = self.client.post(
reverse("wagtailadmin_pages:edit", args=(self.unpublished_page.id,)),
post_data,
)
self.assertRedirects(
response,
reverse("wagtailadmin_pages:edit", args=(self.unpublished_page.id,)),
)
self.unpublished_page.refresh_from_db()
self.assertEqual(self.unpublished_page.content, "")
def test_required_field_validation_enforced_on_publish(self):
post_data = {
"title": "Hello unpublished world! edited",
"content": "",
"slug": "hello-unpublished-world-edited",
"action-publish": "Publish",
}
response = self.client.post(
reverse("wagtailadmin_pages:edit", args=(self.unpublished_page.id,)),
post_data,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "This field is required.")
def test_required_asterisk_on_reshowing_form(self):
"""
If a form is reshown due to a validation error elsewhere, fields whose validation
was deferred should still show the required asterisk.
"""
post_data = {
"title": "Event page",
"date_from": "",
"slug": "event-page",
"audience": "public",
"location": "",
"cost": "Free",
"signup_link": "Not a valid URL",
"carousel_items-TOTAL_FORMS": 0,
"carousel_items-INITIAL_FORMS": 0,
"carousel_items-MIN_NUM_FORMS": 0,
"carousel_items-MAX_NUM_FORMS": 0,
"speakers-TOTAL_FORMS": 0,
"speakers-INITIAL_FORMS": 0,
"speakers-MIN_NUM_FORMS": 0,
"speakers-MAX_NUM_FORMS": 0,
"related_links-TOTAL_FORMS": 0,
"related_links-INITIAL_FORMS": 0,
"related_links-MIN_NUM_FORMS": 0,
"related_links-MAX_NUM_FORMS": 0,
"head_counts-TOTAL_FORMS": 0,
"head_counts-INITIAL_FORMS": 0,
"head_counts-MIN_NUM_FORMS": 0,
"head_counts-MAX_NUM_FORMS": 0,
}
response = self.client.post(
reverse(
"wagtailadmin_pages:edit",
args=[self.event_page.id],
),
post_data,
)
self.assertEqual(response.status_code, 200)
# Empty fields should not cause a validation error, but the invalid URL should
self.assertNotContains(response, "This field is required.")
self.assertContains(response, "Enter a valid URL.", count=1)
# Asterisks should still show against required fields
soup = self.get_soup(response.content)
self.assertTrue(
soup.select_one('label[for="id_date_from"] > span.w-required-mark')
)
self.assertTrue(
soup.select_one('label[for="id_location"] > span.w-required-mark')
)
def test_page_edit_post_when_locked(self):
# Tests that trying to edit a locked page results in an error
@ -1925,8 +1842,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 +1868,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(
@ -2479,7 +2396,6 @@ class TestChildRelationsOnSuperclass(WagtailTestUtils, TestCase):
"advert_placements-0-advert": "1",
"advert_placements-0-colour": "", # should fail as colour is a required field
"advert_placements-0-id": "",
"action-publish": "Publish",
}
response = self.client.post(
reverse(
@ -4077,4 +3993,4 @@ class TestCommentOutput(WagtailTestUtils, TestCase):
comments_data = json.loads(comments_data_json)
comment_text = [comment["text"] for comment in comments_data["comments"]]
comment_text.sort()
self.assertEqual(comment_text, ["A test comment", "This is quite expensive"])
self.assertEqual(comment_text, ["A test comment", "This is quite expensive"])

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)

Wyświetl plik

@ -603,6 +603,13 @@ class TestFormPageWithCustomFormBuilder(WagtailTestUtils, TestCase):
html=True,
)
# check ip address field has rendered
self.assertContains(
response,
'<input type="text" name="device_ip_address" maxlength="39" required id="id_device_ip_address" />',
html=True,
)
# (not comparing HTML directly because https://docs.djangoproject.com/en/5.1/releases/5.1.5/
# added a maxlength attribute)
soup = self.get_soup(response.content)

Wyświetl plik

@ -1,8 +1,30 @@
$(function () {
const fileFields = document.querySelectorAll('[data-bulk-upload-file]');
fileFields.forEach((fileField) => {
fileField.setAttribute('data-controller', 'w-sync');
fileField.setAttribute('data-action', 'change->w-sync#apply');
fileField.setAttribute(
'data-w-sync-target-value',
'[data-bulk-upload-title]',
);
fileField.setAttribute('data-w-sync-normalize-value', 'true');
fileField.setAttribute(
'data-w-sync-name-value',
'wagtail:documents-upload',
);
});
const titleFields = document.querySelectorAll('[data-bulk-upload-title]');
titleFields.forEach((titleField) => {
titleField.setAttribute('data-controller', 'w-clean');
titleField.setAttribute('data-action', 'blur->w-clean#slugify');
});
$('#fileupload').fileupload({
dataType: 'html',
sequentialUploads: true,
dropZone: $('.drop-zone'),
add: function (e, data) {
var $this = $(this);
var that = $this.data('blueimp-fileupload') || $this.data('fileupload');
@ -55,6 +77,7 @@ $(function () {
}
var progress = Math.floor((data.loaded / data.total) * 100);
data.context.each(function () {
$(this)
.find('.progress')
@ -68,6 +91,7 @@ $(function () {
progressall: function (e, data) {
var progress = parseInt((data.loaded / data.total) * 100, 10);
$('#overall-progress')
.addClass('active')
.attr('aria-valuenow', progress)
@ -83,34 +107,8 @@ $(function () {
}
},
/**
* Allow a custom title to be defined by an event handler for this form.
* If event.preventDefault is called, the original behaviour of using the raw
* filename (with extension) as the title is preserved.
*
* @param {HtmlElement[]} form
* @returns {{name: 'string', value: *}[]}
*/
formData: function (form) {
var filename = this.files[0].name;
var data = { title: filename.replace(/\.[^.]+$/, '') };
var event = form.get(0).dispatchEvent(
new CustomEvent('wagtail:documents-upload', {
bubbles: true,
cancelable: true,
detail: {
data: data,
filename: filename,
maxTitleLength: this.maxTitleLength,
},
}),
);
// default behaviour (title is just file name)
return event
? form.serializeArray().concat({ name: 'title', value: data.title })
: form.serializeArray();
return form.serializeArray();
},
done: function (e, data) {
@ -119,7 +117,6 @@ $(function () {
if (response.success) {
itemElement.addClass('upload-success');
$('.right', itemElement).append(response.form);
} else {
itemElement.addClass('upload-failure');
@ -138,10 +135,6 @@ $(function () {
},
});
/**
* ajax-enhance forms added on done()
* allows the user to modify the title, collection, tags and delete after upload
*/
$('#upload-list').on('submit', 'form', function (e) {
var form = $(this);
var formData = new FormData(this);
@ -155,21 +148,25 @@ $(function () {
processData: false,
type: 'POST',
url: this.action,
}).done(function (data) {
if (data.success) {
var text = $('.status-msg.update-success').first().text();
document.dispatchEvent(
new CustomEvent('w-messages:add', {
detail: { clear: true, text, type: 'success' },
}),
);
itemElement.slideUp(function () {
$(this).remove();
});
} else {
form.replaceWith(data.form);
}
});
})
.done(function (data) {
if (data.success) {
var text = $('.status-msg.update-success').first().text();
document.dispatchEvent(
new CustomEvent('w-messages:add', {
detail: { clear: true, text, type: 'success' },
}),
);
itemElement.slideUp(function () {
$(this).remove();
});
} else {
form.replaceWith(data.form);
}
})
.fail(function (xhr, status, error) {
// Handle failure
});
});
$('#upload-list').on('click', '.delete', function (e) {
@ -186,6 +183,6 @@ $(function () {
$(this).remove();
});
}
});
}).fail(function (xhr, status, error) {});
});
});

Wyświetl plik

@ -1,50 +1,67 @@
{% extends "wagtailadmin/generic/form.html" %}
{% load i18n wagtailadmin_tags %}
{% load i18n wagtailadmin_tags static %}
{% block titletag %}{% trans "Add a document" %}{% endblock %}
{% block extra_js %}
{{ block.super }}
<script>
$(function() {
$('#id_file').on(
'change',
function() {
var $titleField = $('#id_title');
// do not override a title that already exists (from manual editing or previous upload)
if ($titleField.val()) return;
// file widget value example: `C:\fakepath\image.jpg` - convert to just the filename part
var filename = $(this).val().split('\\').slice(-1)[0];
var data = { title: filename.replace(/\.[^.]+$/, '') };
var maxTitleLength = parseInt($titleField.attr('maxLength') || '0', 10) || null;
// allow an event handler to customise data or call event.preventDefault to stop any title pre-filling
var form = $(this).closest('form').get(0);
var event = form.dispatchEvent(new CustomEvent(
'wagtail:documents-upload',
{ bubbles: true, cancelable: true, detail: { data: data, filename: filename, maxTitleLength: maxTitleLength } }
));
if (!event) return; // do not set a title if event.preventDefault(); is called by handler
$titleField.val(data.title);
}
);
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('id_file');
const titleInput = document.getElementById('id_title')
if (fileInput) {
fileInput.setAttribute('data-controller', 'w-sync');
fileInput.setAttribute('data-w-sync-target-value', '#id_title');
fileInput.setAttribute('data-w-sync-normalize-value', 'true');
fileInput.setAttribute('data-w-sync-name-value', 'wagtail:documents-upload');
fileInput.setAttribute('data-action', 'change->w-sync#apply');
}
if (titleInput) {
titleInput.setAttribute('data-controller', 'w-clean');
titleInput.setAttribute('data-w-clean-source-value', '#id_file');
titleInput.setAttribute('data-action', 'change->w-clean#slugify');
}
});
</script>
{% endblock %}
{% block actions %}
<button
type="submit"
class="button button-longrunning"
data-controller="w-progress"
data-action="w-progress#activate"
data-w-progress-active-value="{% trans 'Uploading…' %}"
>
{% icon name="spinner" %}
<em data-w-progress-target="label">{% trans 'Upload' %}</em>
</button>
{% block form %}
<div class="field">
<label for="id_file">{% trans "Document file" %}</label>
<input
type="file"
name="file"
id="id_file"
required
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf"
/>
</div>
<div class="field">
<label for="id_title">{% trans "Title" %}</label>
<input
type="text"
name="title"
id="id_title"
required"
>
</div>
<div class="actions">
<button
type="submit"
class="button button-longrunning"
data-controller="w-progress"
data-action="w-progress#activate"
data-w-progress-active-value="{% trans 'Uploading…' %}"
>
{% icon name="spinner" %}
<em data-w-progress-target="label">{% trans 'Upload' %}</em>
</button>
</div>
{% endblock %}

Wyświetl plik

@ -1,4 +1,22 @@
$(function () {
const fileFields = document.querySelectorAll('[data-bulk-upload-file]');
fileFields.forEach((fileField) => {
fileField.setAttribute('data-controller', 'w-sync');
fileField.setAttribute('data-action', 'change->w-sync#apply');
fileField.setAttribute(
'data-w-sync-target-value',
'[data-bulk-upload-title]',
);
fileField.setAttribute('data-w-sync-normalize-value', 'true');
fileField.setAttribute('data-w-sync-name-value', 'wagtail:images-upload');
});
const titleFields = document.querySelectorAll('[data-bulk-upload-title]');
titleFields.forEach((titleField) => {
titleField.setAttribute('data-controller', 'w-clean');
titleField.setAttribute('data-action', 'blur->w-clean#slugify');
});
$('#fileupload').fileupload({
dataType: 'html',
sequentialUploads: true,
@ -53,80 +71,8 @@ $(function () {
});
},
processfail: function (e, data) {
var itemElement = $(data.context);
itemElement.removeClass('upload-uploading').addClass('upload-failure');
},
progress: function (e, data) {
if (e.isDefaultPrevented()) {
return false;
}
var progress = Math.floor((data.loaded / data.total) * 100);
data.context.each(function () {
$(this)
.find('.progress')
.addClass('active')
.attr('aria-valuenow', progress)
.find('.bar')
.css('width', progress + '%')
.html(progress + '%');
});
},
progressall: function (e, data) {
var progress = parseInt((data.loaded / data.total) * 100, 10);
$('#overall-progress')
.addClass('active')
.attr('aria-valuenow', progress)
.find('.bar')
.css('width', progress + '%')
.html(progress + '%');
if (progress >= 100) {
$('#overall-progress')
.removeClass('active')
.find('.bar')
.css('width', '0%');
}
},
/**
* Allow a custom title to be defined by an event handler for this form.
* If event.preventDefault is called, the original behavior of using the raw
* filename (with extension) as the title is preserved.
*
* @example
* document.addEventListener('wagtail:images-upload', function(event) {
* // remove file extension
* var newTitle = (event.detail.data.title || '').replace(/\.[^.]+$/, '');
* event.detail.data.title = newTitle;
* });
*
* @param {HtmlElement[]} form
* @returns {{name: 'string', value: *}[]}
*/
formData: function (form) {
var filename = this.files[0].name;
var data = { title: filename.replace(/\.[^.]+$/, '') };
var event = form.get(0).dispatchEvent(
new CustomEvent('wagtail:images-upload', {
bubbles: true,
cancelable: true,
detail: {
data: data,
filename: filename,
maxTitleLength: this.maxTitleLength,
},
}),
);
// default behaviour (title is just file name)
return event
? form.serializeArray().concat({ name: 'title', value: data.title })
: form.serializeArray();
return form.serializeArray();
},
done: function (e, data) {
@ -172,10 +118,6 @@ $(function () {
},
});
/**
* ajax-enhance forms added on done()
* allows the user to modify the title, collection, tags and delete after upload
*/
$('#upload-list').on('submit', 'form', function (e) {
var form = $(this);
var formData = new FormData(this);

Wyświetl plik

@ -1,50 +1,64 @@
{% extends "wagtailadmin/generic/form.html" %}
{% load wagtailimages_tags wagtailadmin_tags i18n %}
{% block titletag %}{% trans "Add an image" %}{% endblock %}
{% load i18n wagtailadmin_tags static %}
{% block extra_js %}
{{ block.super }}
<script>
$(function() {
$('#id_file').on(
'change',
function() {
var $titleField = $('#id_title');
// do not override a title that already exists (from manual editing or previous upload)
if ($titleField.val()) return;
// file widget value example: `C:\fakepath\image.jpg` - convert to just the filename part
var filename = $(this).val().split('\\').slice(-1)[0];
var data = { title: filename.replace(/\.[^.]+$/, '') };
var maxTitleLength = parseInt($titleField.attr('maxLength') || '0', 10) || null;
// allow an event handler to customise data or call event.preventDefault to stop any title pre-filling
var form = $(this).closest('form').get(0);
var event = form.dispatchEvent(new CustomEvent(
'wagtail:images-upload',
{ bubbles: true, cancelable: true, detail: { data: data, filename: filename, maxTitleLength: maxTitleLength } }
));
if (!event) return; // do not set a title if event.preventDefault(); is called by handler
$titleField.val(data.title);
}
);
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('id_file');
const titleInput = document.getElementById('id_title')
if (fileInput) {
fileInput.setAttribute('data-controller', 'w-sync');
fileInput.setAttribute('data-w-sync-target-value', '#id_title');
fileInput.setAttribute('data-w-sync-normalize-value', 'true');
fileInput.setAttribute('data-w-sync-name-value', 'wagtail:images-upload');
fileInput.setAttribute('data-action', 'change->w-sync#apply');
}
if (titleInput) {
titleInput.setAttribute('data-controller', 'w-clean');
titleInput.setAttribute('data-w-clean-source-value', '#id_file');
titleInput.setAttribute('data-action', 'change->w-clean#slugify');
}
});
</script>
{% endblock %}
{% block actions %}
<button
type="submit"
class="button button-longrunning"
{% block form %}
{{ block.super }}
<div class="field">
<label for="id_file">{% trans "Image file" %}</label>
<input
type="file"
name="file"
id="id_file"
required
accept="image/*,.png,.jpg,.jpeg,.gif,.webp"
>
<p id="id_file-help">{% trans "Maximum file size: 10 MB. Supported formats: PNG, JPG, GIF, WebP." %}</p>
</div>
<div class="field">
<label for="id_title">{% trans "Title" %}</label>
<input
type="text"
name="title"
id="id_title"
required
>
</div>
{% endblock %}
{% block form_actions %}
<button type="submit" class="button button-longrunning"
data-controller="w-progress"
data-action="w-progress#activate"
data-w-progress-active-value="{% trans 'Uploading…' %}"
>
data-w-progress-active-value="{% trans 'Uploading…' %}">
{% icon name="spinner" %}
<em data-w-progress-target="label">{% trans 'Upload' %}</em>
</button>
{% endblock %}
{{ block.super }}
{% endblock %}