kopia lustrzana https://github.com/wagtail/wagtail
Merge 2c5fdde777
into c91bff5d21
commit
9651ae9590
|
@ -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,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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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->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 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 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->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 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 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"])
|
|
@ -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->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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 %}
|
Ładowanie…
Reference in New Issue