From 146051cfc546aaf4f7283ff9c64f347daaed5c34 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Sun, 30 Mar 2025 17:56:13 +0530 Subject: [PATCH 01/15] Updated testing suite for Sync Controller Added test for backwards compatibility and updated sync controller and its tests based on LB's attempt https://github.com/lb-/wagtail/commits/wip/move-upload-title-to-sync-controller/ --- client/src/controllers/SyncController.test.js | 91 ++++++++++ client/src/controllers/SyncController.ts | 159 ++++++++++++++++-- 2 files changed, 235 insertions(+), 15 deletions(-) diff --git a/client/src/controllers/SyncController.test.js b/client/src/controllers/SyncController.test.js index d6a5543bb5..c91cfc6ad9 100644 --- a/client/src/controllers/SyncController.test.js +++ b/client/src/controllers/SyncController.test.js @@ -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,89 @@ describe('SyncController', () => { expect(document.getElementById('pet-select').value).toEqual('pikachu'); }); }); + // it('should include the nameValue in event payloads', () => { + // document.body.innerHTML = ` + //
+ // + // + //
`; + + // 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 = ` +
+ + +
+ `; + + application.register('w-sync', SyncController); + + const titleElement = document.getElementById('title'); + const eventListener = jest.fn((event) => { + console.log('w-sync:apply event listener called'); + + // Create a completely new `detail` object + const newDetail = { + ...event.detail, + data: { + ...event.detail.data, + title: 'Custom Title', + }, + }; + + // Assign the new object to event.detail + Object.assign(event, { detail: newDetail }); + }); + + document.addEventListener('w-sync:apply', eventListener); + + const fileInput = document.getElementById('file-upload'); + fileInput.dispatchEvent(new Event('change')); + + 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; + } + + console.log('Title value:', titleElement.value); + expect(eventListener).toHaveBeenCalled(); + expect(titleElement.value).toEqual('Custom Title'); + }); }); diff --git a/client/src/controllers/SyncController.ts b/client/src/controllers/SyncController.ts index 779ab505f6..7f24dbf2a2 100644 --- a/client/src/controllers/SyncController.ts +++ b/client/src/controllers/SyncController.ts @@ -25,14 +25,41 @@ export class SyncController extends Controller { 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; /** @@ -41,8 +68,9 @@ export class SyncController extends Controller { * default. */ connect() { - this.processTargetElements('start', true); + this.processTargetElements('start', { resetDisabledValue: true }); this.apply = debounce(this.apply.bind(this), this.debounceValue); + console.log('SyncController connected to:', this.element); } /** @@ -50,7 +78,7 @@ export class SyncController extends Controller { * whether this sync controller should be disabled. */ check() { - this.processTargetElements('check', true); + this.processTargetElements('check', { resetDisabledValue: true }); } /** @@ -62,11 +90,11 @@ export class SyncController extends Controller { * 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); + console.log('apply method called'); 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; @@ -77,7 +105,7 @@ export class SyncController extends Controller { }); }; - this.processTargetElements('apply').forEach((target) => { + this.processTargetElements('apply', { value }).forEach((target) => { if (this.delayValue) { setTimeout(() => { applyValue(target); @@ -109,35 +137,57 @@ export class SyncController extends Controller { * 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') { + return value + .split('\\') + .slice(-1)[0] + .replace(/\.[^.]+$/, ''); + } + + 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 } = {}, ) { + console.log('Processing target elements for event:', eventName); if (!resetDisabledValue && this.disabledValue) { + // console.log('Sync is disabled, skipping.'); return []; } const targetElements = [ ...document.querySelectorAll(this.targetValue), ]; + // console.log('Target elements found:', targetElements); + + 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, // Ensure this is 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, }); + // console.log('Event dispatched:', event); + // console.log('Event target:', target); return !event.defaultPrevented; }); @@ -148,4 +198,83 @@ export class SyncController extends Controller { return elements; } + + /** + * Could use afterload or something to add backwards compatibility with documented + * 'wagtail:images|documents-upload' approach. + */ + static afterLoad(identifier: string) { + // console.log('is this working?', { identifier }); + if (identifier !== 'w-sync') return; + + // domReady().then(() => { + // Why does domReady not work??? + + // console.log('is this working?'); + + /** + * Need to think this through. + * I only really want this on specific fields + * We could normalize all values but is that bad? + * Need to consider issues with bubbling actions + * Maybe... using Ping instead for now? + */ + + const handleEvent = ( + event: CustomEvent<{ + maxLength: number | null; + name: string; + value: string; + }>, + ) => { + // console.log('sync apply! before', event); + const { + /** Will be the target title field */ + target, + } = event; + if (!target || !(target instanceof HTMLInputElement)) return; + const form = target.closest('form'); + if (!form) return; + + // console.log('sync apply!', event); + + 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) { + // Do not set a title if event.preventDefault(); is called by handler + + event.preventDefault(); + } + + if (data.title !== title) { + // If the title has been modified through another listener, update the title field manually, ignoring the default behaviour + // or we just always do this??? + event.preventDefault(); + target.value = data.title; + // maybe dispatch change event - check what jQuery .val does out of the box & check docs!! + } + }; + + document.addEventListener('w-sync:apply', handleEvent as EventListener); + // }); + } } From 7a19c1900e6627aacf6bc528b0bdb6d1bec1b9f1 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Mon, 31 Mar 2025 08:58:43 +0530 Subject: [PATCH 02/15] Update SyncController.test.js --- client/src/controllers/SyncController.test.js | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/client/src/controllers/SyncController.test.js b/client/src/controllers/SyncController.test.js index c91cfc6ad9..3c338c8258 100644 --- a/client/src/controllers/SyncController.test.js +++ b/client/src/controllers/SyncController.test.js @@ -447,25 +447,15 @@ describe('SyncController', () => { const titleElement = document.getElementById('title'); const eventListener = jest.fn((event) => { console.log('w-sync:apply event listener called'); - - // Create a completely new `detail` object - const newDetail = { - ...event.detail, - data: { - ...event.detail.data, - title: 'Custom Title', - }, - }; - - // Assign the new object to event.detail - Object.assign(event, { detail: newDetail }); + 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: '' } }, @@ -478,7 +468,7 @@ describe('SyncController', () => { } console.log('Title value:', titleElement.value); - expect(eventListener).toHaveBeenCalled(); - expect(titleElement.value).toEqual('Custom Title'); + expect(eventListener).toHaveBeenCalled(); // Ensure the listener was called + expect(titleElement.value).toEqual('Custom Title'); // Ensure the value was updated }); }); From d8e0f82e4134a51eedb2f7eb0f9eb9d25301d780 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Tue, 1 Apr 2025 16:19:36 +0530 Subject: [PATCH 03/15] Cleaning up tests --- client/src/controllers/SyncController.test.js | 3 +-- wagtail/contrib/forms/tests/test_models.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/client/src/controllers/SyncController.test.js b/client/src/controllers/SyncController.test.js index 3c338c8258..486dd84366 100644 --- a/client/src/controllers/SyncController.test.js +++ b/client/src/controllers/SyncController.test.js @@ -446,7 +446,7 @@ describe('SyncController', () => { const titleElement = document.getElementById('title'); const eventListener = jest.fn((event) => { - console.log('w-sync:apply event listener called'); + // eslint-disable-next-line no-param-reassign event.detail.data.title = 'Custom Title'; }); document.addEventListener('w-sync:apply', eventListener); @@ -467,7 +467,6 @@ describe('SyncController', () => { titleElement.value = applyEvent.detail.data.title; } - console.log('Title value:', titleElement.value); expect(eventListener).toHaveBeenCalled(); // Ensure the listener was called expect(titleElement.value).toEqual('Custom Title'); // Ensure the value was updated }); diff --git a/wagtail/contrib/forms/tests/test_models.py b/wagtail/contrib/forms/tests/test_models.py index 4da4da7126..4aa0ccdb17 100644 --- a/wagtail/contrib/forms/tests/test_models.py +++ b/wagtail/contrib/forms/tests/test_models.py @@ -605,7 +605,7 @@ class TestFormPageWithCustomFormBuilder(WagtailTestUtils, TestCase): # check ip address field has rendered self.assertContains( response, - '', + '', html=True, ) From b5cf72dcdf25e6a0539ff4047ac490d982aa1548 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Tue, 1 Apr 2025 16:20:02 +0530 Subject: [PATCH 04/15] Temp Clean Controller implementation --- .../src/controllers/CleanController.test.js | 484 ++++++++++++++++++ client/src/controllers/CleanController.ts | 180 +++++++ 2 files changed, 664 insertions(+) create mode 100644 client/src/controllers/CleanController.test.js create mode 100644 client/src/controllers/CleanController.ts diff --git a/client/src/controllers/CleanController.test.js b/client/src/controllers/CleanController.test.js new file mode 100644 index 0000000000..b591745b4a --- /dev/null +++ b/client/src/controllers/CleanController.test.js @@ -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 = ` + `; + + 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 = ` + `; + + 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 = ` + `; + + 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-'); + }); + }); +}); diff --git a/client/src/controllers/CleanController.ts b/client/src/controllers/CleanController.ts new file mode 100644 index 0000000000..65dfbcde5e --- /dev/null +++ b/client/src/controllers/CleanController.ts @@ -0,0 +1,180 @@ +import { Controller } from '@hotwired/stimulus'; +import { slugify } from '../utils/slugify'; +import { urlify } from '../utils/urlify'; + +enum Actions { + Identity = 'identity', + Slugify = 'slugify', + Urlify = 'urlify', +} + +/** + * Adds ability to clean values of an input element with methods such as slugify or urlify. + * + * @example - Using the slugify method + * ```html + * + * + * ``` + * + * @example - Using the urlify method (registered as w-slug) + * ```html + * + * + * ``` + */ +export class CleanController extends Controller { + 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; + } +} From 52c1dbf12802791ab67a58390fd0656565888092 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Mon, 7 Apr 2025 17:40:57 +0530 Subject: [PATCH 05/15] Image bulk and single uploads --- .../wagtailimages/js/add-multiple.js | 94 +++++-------------- .../templates/wagtailimages/images/add.html | 62 ++++++------ 2 files changed, 53 insertions(+), 103 deletions(-) diff --git a/wagtail/images/static_src/wagtailimages/js/add-multiple.js b/wagtail/images/static_src/wagtailimages/js/add-multiple.js index 5163fa36b2..76634eca24 100644 --- a/wagtail/images/static_src/wagtailimages/js/add-multiple.js +++ b/wagtail/images/static_src/wagtailimages/js/add-multiple.js @@ -1,4 +1,23 @@ $(function () { + // initialize Stimulus controllers for bulk upload fields + 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 +72,9 @@ $(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(); + // let Stimulus handle the title generation + return form.serializeArray(); }, done: function (e, data) { diff --git a/wagtail/images/templates/wagtailimages/images/add.html b/wagtail/images/templates/wagtailimages/images/add.html index f20366089e..a526c3654f 100644 --- a/wagtail/images/templates/wagtailimages/images/add.html +++ b/wagtail/images/templates/wagtailimages/images/add.html @@ -1,41 +1,43 @@ {% extends "wagtailadmin/generic/form.html" %} -{% load wagtailimages_tags wagtailadmin_tags i18n %} -{% block titletag %}{% trans "Add an image" %}{% endblock %} +{% load i18n wagtailadmin_tags %} + +{% block titletag %}{% trans "Add a document" %}{% endblock %} {% block extra_js %} {{ block.super }} - {% endblock %} +{% block form %} +
+ {% csrf_token %} + + +
+{% endblock %} + {% block actions %} -{% endblock %} +{% endblock %} \ No newline at end of file From 0574fdbfb6d7938b077ab21e1c36818232a25037 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Mon, 7 Apr 2025 17:41:23 +0530 Subject: [PATCH 06/15] CSP compliant chooserModal.js --- client/src/includes/chooserModal.js | 66 +++++++---------------------- 1 file changed, 16 insertions(+), 50 deletions(-) diff --git a/client/src/includes/chooserModal.js b/client/src/includes/chooserModal.js index 82705691b9..b17c1ee7ea 100644 --- a/client/src/includes/chooserModal.js +++ b/client/src/includes/chooserModal.js @@ -69,49 +69,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; @@ -252,7 +209,7 @@ class ChooserModalOnloadHandlerFactory { return $('[data-tabs]', modal.body).length; } - ajaxifyCreationForm(modal) { + ajaxifyCreationForm(modal) { /* Convert the creation form to an AJAX submission */ $(this.creationFormSelector, modal.body).on('submit', (event) => { if (validateCreationForm(event.currentTarget)) { @@ -263,19 +220,29 @@ 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', }); } } + initSearchController(modal) { this.searchController = new SearchController({ form: $(this.searchFormSelector, modal.body), @@ -374,7 +341,6 @@ class ChooserModal { export { validateCreationForm, submitCreationForm, - initPrefillTitleFromFilename, SearchController, ChooserModalOnloadHandlerFactory, chooserModalOnloadHandlers, From 424b084a4d8e61e822692665c5e000ec36d6f1f6 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Tue, 8 Apr 2025 10:53:24 +0530 Subject: [PATCH 07/15] Update chooserModal.js --- client/src/includes/chooserModal.js | 45 +++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/client/src/includes/chooserModal.js b/client/src/includes/chooserModal.js index b17c1ee7ea..5c189c46f2 100644 --- a/client/src/includes/chooserModal.js +++ b/client/src/includes/chooserModal.js @@ -138,6 +138,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'; @@ -209,7 +251,7 @@ class ChooserModalOnloadHandlerFactory { return $('[data-tabs]', modal.body).length; } - ajaxifyCreationForm(modal) { + ajaxifyCreationForm(modal) { /* Convert the creation form to an AJAX submission */ $(this.creationFormSelector, modal.body).on('submit', (event) => { if (validateCreationForm(event.currentTarget)) { @@ -242,7 +284,6 @@ class ChooserModalOnloadHandlerFactory { } } - initSearchController(modal) { this.searchController = new SearchController({ form: $(this.searchFormSelector, modal.body), From 3b317d5ce59655b107acd59587746bacb26a4423 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Tue, 8 Apr 2025 11:50:34 +0530 Subject: [PATCH 08/15] Document add-multiple.js migration to stimulus --- .../static_src/wagtaildocs/js/add-multiple.js | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js b/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js index 1dfff9e524..fa4d4d8fea 100644 --- a/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js +++ b/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js @@ -1,4 +1,25 @@ $(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, @@ -84,33 +105,10 @@ $(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: *}[]} + * Let Stimulus handle the title generation */ 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'); From f0f067776270eb78f77b3d143c2637c4d941831c Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Tue, 8 Apr 2025 11:50:46 +0530 Subject: [PATCH 09/15] Correct imports --- .../templates/wagtaildocs/documents/add.html | 84 ++++++++++--------- .../templates/wagtailimages/images/add.html | 4 +- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/wagtail/documents/templates/wagtaildocs/documents/add.html b/wagtail/documents/templates/wagtaildocs/documents/add.html index 8763193488..bb2178535a 100644 --- a/wagtail/documents/templates/wagtaildocs/documents/add.html +++ b/wagtail/documents/templates/wagtaildocs/documents/add.html @@ -5,46 +5,54 @@ {% block extra_js %} {{ block.super }} - {% endblock %} -{% block actions %} - +{% block form %} +
+ {% csrf_token %} +
+ +
+
+ +
+
+ {% trans "Upload" as upload_label %} + +
+
{% endblock %} diff --git a/wagtail/images/templates/wagtailimages/images/add.html b/wagtail/images/templates/wagtailimages/images/add.html index a526c3654f..681aa92c01 100644 --- a/wagtail/images/templates/wagtailimages/images/add.html +++ b/wagtail/images/templates/wagtailimages/images/add.html @@ -7,8 +7,8 @@ {{ block.super }} {% endblock %} {% block form %} -
+ {% csrf_token %} -
+ + {# File Input with Sync Controller #} +
+ +

+ {% trans "Supported formats: PDF, Word, Excel, PowerPoint, Text" %} +

-
+ + {# Title Input with Clean Controller #} +
+ +

-
- {% trans "Upload" as upload_label %} + + {# Submit Button #} +
diff --git a/wagtail/images/static_src/wagtailimages/js/add-multiple.js b/wagtail/images/static_src/wagtailimages/js/add-multiple.js index 76634eca24..258d2eacb2 100644 --- a/wagtail/images/static_src/wagtailimages/js/add-multiple.js +++ b/wagtail/images/static_src/wagtailimages/js/add-multiple.js @@ -1,7 +1,10 @@ $(function () { - // initialize Stimulus controllers for bulk upload fields + console.log('Initializing bulk upload fields...'); + + // Initialize Stimulus controllers for bulk upload fields const fileFields = document.querySelectorAll('[data-bulk-upload-file]'); fileFields.forEach((fileField) => { + console.log('Setting up w-sync controller for file field:', fileField); fileField.setAttribute('data-controller', 'w-sync'); fileField.setAttribute('data-action', 'change->w-sync#apply'); fileField.setAttribute( @@ -14,10 +17,12 @@ $(function () { const titleFields = document.querySelectorAll('[data-bulk-upload-title]'); titleFields.forEach((titleField) => { + console.log('Setting up w-clean controller for title field:', titleField); titleField.setAttribute('data-controller', 'w-clean'); titleField.setAttribute('data-action', 'blur->w-clean#slugify'); }); + console.log('Initializing fileupload plugin...'); $('#fileupload').fileupload({ dataType: 'html', sequentialUploads: true, @@ -27,6 +32,7 @@ $(function () { previewMinHeight: 150, previewMaxHeight: 150, add: function (e, data) { + console.log('File upload started. Adding file to upload list.'); var $this = $(this); var that = $this.data('blueimp-fileupload') || $this.data('fileupload'); var li = $($('#upload-list-item').html()).addClass('upload-uploading'); @@ -37,9 +43,11 @@ $(function () { data .process(function () { + console.log('Processing file:', data.files[0].name); return $this.fileupload('process', data); }) .always(function () { + console.log('File processing complete.'); data.context.removeClass('processing'); data.context.find('.left').each(function (index, elm) { $(elm).append(escapeHtml(data.files[index].name)); @@ -51,16 +59,19 @@ $(function () { }); }) .done(function () { + console.log('File processed successfully.'); data.context.find('.start').prop('disabled', false); if ( that._trigger('added', e, data) !== false && (options.autoUpload || data.autoUpload) && data.autoUpload !== false ) { + console.log('Auto-uploading file.'); data.submit(); } }) .fail(function () { + console.log('File processing failed.'); if (data.files.error) { data.context.each(function (index) { var error = data.files[index].error; @@ -73,22 +84,26 @@ $(function () { }, formData: function (form) { + console.log('Preparing form data for submission.'); // let Stimulus handle the title generation return form.serializeArray(); }, done: function (e, data) { + console.log('File upload successful.'); var itemElement = $(data.context); var response = JSON.parse(data.result); if (response.success) { if (response.duplicate) { + console.log('Duplicate file detected.'); itemElement.addClass('upload-duplicate'); $('.right', itemElement).append(response.confirm_duplicate_upload); $('.confirm-duplicate-upload', itemElement).on( 'click', '.confirm-upload', function (event) { + console.log('Confirming duplicate upload.'); event.preventDefault(); var confirmUpload = $(this).closest('.confirm-duplicate-upload'); confirmUpload.remove(); @@ -96,16 +111,19 @@ $(function () { }, ); } else { + console.log('File upload completed successfully.'); itemElement.addClass('upload-success'); $('.right', itemElement).append(response.form); } } else { + console.log('File upload failed:', response.error_message); itemElement.addClass('upload-failure'); $('.right .error_messages', itemElement).append(response.error_message); } }, fail: function (e, data) { + console.log('File upload failed due to server error:', data.errorThrown); var itemElement = $(data.context); var errorMessage = $('.server-error', itemElement); $('.error-text', errorMessage).text(data.errorThrown); @@ -115,16 +133,19 @@ $(function () { }, always: function (e, data) { + console.log('File upload process completed.'); var itemElement = $(data.context); itemElement.removeClass('upload-uploading').addClass('upload-complete'); }, }); + console.log('Binding form submission handlers...'); /** * 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) { + console.log('Processing form submission.'); var form = $(this); var formData = new FormData(this); var itemElement = form.closest('#upload-list > li'); @@ -139,6 +160,7 @@ $(function () { url: this.action, }).done(function (data) { if (data.success) { + console.log('Form submission successful.'); var text = $('.status-msg.update-success').first().text(); document.dispatchEvent( new CustomEvent('w-messages:add', { @@ -146,15 +168,19 @@ $(function () { }), ); itemElement.slideUp(function () { + console.log('Removing item from upload list.'); $(this).remove(); }); } else { + console.log('Form submission failed, updating form.'); form.replaceWith(data.form); } }); }); + console.log('Binding delete handlers...'); $('#upload-list').on('click', '.delete', function (e) { + console.log('Delete button clicked.'); var form = $(this).closest('form'); var itemElement = form.closest('#upload-list > li'); @@ -164,10 +190,14 @@ $(function () { $.post(this.href, { csrfmiddlewaretoken: CSRFToken }, function (data) { if (data.success) { + console.log('File deletion successful.'); itemElement.slideUp(function () { + console.log('Removing item from upload list.'); $(this).remove(); }); } }); }); + + console.log('Initialization complete.'); }); diff --git a/wagtail/images/templates/wagtailimages/images/add.html b/wagtail/images/templates/wagtailimages/images/add.html index 681aa92c01..1c882d2bab 100644 --- a/wagtail/images/templates/wagtailimages/images/add.html +++ b/wagtail/images/templates/wagtailimages/images/add.html @@ -1,52 +1,49 @@ {% 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 }} - - {% endblock %} {% block form %} -
+ {% csrf_token %} + + +

{% trans "Maximum file size: 10 MB" %}

+ + + +
+ +
{% endblock %} - -{% block actions %} - -{% endblock %} \ No newline at end of file From 67989b87ec73424f96b37f17e733f2fd7e19daad Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Thu, 17 Apr 2025 17:36:22 +0530 Subject: [PATCH 13/15] Controllers confirmed working as expected --- client/src/controllers/CleanController.ts | 380 ++++++++++++--------- client/src/controllers/SyncController.ts | 388 ++++++++-------------- client/src/controllers/index.ts | 2 +- 3 files changed, 359 insertions(+), 411 deletions(-) diff --git a/client/src/controllers/CleanController.ts b/client/src/controllers/CleanController.ts index 1828f9c98a..6711a43053 100644 --- a/client/src/controllers/CleanController.ts +++ b/client/src/controllers/CleanController.ts @@ -1,207 +1,267 @@ import { Controller } from '@hotwired/stimulus'; -import { slugify } from '../utils/slugify'; -import { urlify } from '../utils/urlify'; - -enum Actions { - Identity = 'identity', - Slugify = 'slugify', - Urlify = 'urlify', -} +import { debounce } from '../utils/debounce'; /** - * Adds ability to clean values of an input element with methods such as slugify or urlify. + * Adds ability to sync the value or interactions with one input with one + * or more targeted other inputs. * - * @example - Using the slugify method + * @example * ```html - * - * - * ``` - * - * @example - Using the urlify method (registered as w-slug) - * ```html - * - * + *
+ * + * + *
* ``` */ -export class CleanController extends Controller { +export class SyncController extends Controller { static values = { - allowUnicode: { default: false, type: Boolean }, - trim: { default: false, type: Boolean }, + 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: { default: '', type: String }, }; /** - * 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 + * The delay, in milliseconds, to wait before running apply if called multiple + * times consecutively. */ - declare readonly allowUnicodeValue: boolean; - /** If true, value will be trimmed in all clean methods before being processed by that method. */ - declare readonly trimValue: boolean; + 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; /** - * 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. + * Dispatches an event to all target elements so that they can be notified + * that a sync has started, allowing them to disable the sync by preventing + * default. */ - applyUpdate(action: Actions, cleanValue: string, sourceValue?: string) { - console.log('CleanController: Applying update. Action:', action); - console.log('CleanController: Source value:', sourceValue); - console.log('CleanController: Cleaned value:', cleanValue); - this.element.value = cleanValue; - this.dispatch('applied', { - cancelable: false, - detail: { action, cleanValue, sourceValue }, + connect() { + this.processTargetElements('start', { resetDisabledValue: true }); + this.apply = debounce(this.apply.bind(this), this.debounceValue); + } + + /** + * Allows for targeted elements to determine, via preventing the default event, + * whether this sync controller should be disabled. + */ + check() { + this.processTargetElements('check', { resetDisabledValue: true }); + } + + /** + * Applies a value from the controlled element to the targeted + * elements. Calls to this method are debounced based on the + * controller's `debounceValue`. + * + * Applying of the value to the targets can be done with a delay, + * based on the controller's `delayValue`. + */ + apply(event?: Event & { params?: { apply?: string } }) { + const value = this.prepareValue(event?.params?.apply || this.element.value); + + const applyValue = (target) => { + target.value = value; + + if (this.quietValue) { + return; + } + + this.dispatch('change', { + cancelable: false, + prefix: '', + target, + }); + }; + + this.processTargetElements('apply', { value }).forEach((target) => { + if (this.delayValue) { + setTimeout(() => { + applyValue(target); + }, this.delayValue); + } else { + applyValue(target); + } }); } /** - * 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. + * Clears the value of the targeted elements. */ - compare( - event: CustomEvent<{ compareAs?: Actions; value: string }> & { - params?: { compareAs?: Actions }; - }, - ) { - console.log('CleanController: Comparing values.'); - // do not attempt to compare if the field is empty - if (!this.element.value) { - console.log('CleanController: Element value is empty, skipping comparison.'); - return true; - } - - const compareAs = - event.detail?.compareAs || event.params?.compareAs || Actions.Slugify; - console.log('CleanController: Compare action:', compareAs); - - const compareValue = this[compareAs]( - { detail: { value: event.detail?.value || '' } }, - { ignoreUpdate: true }, - ); - console.log('CleanController: Compare value:', compareValue); - - const valuesAreSame = this.compareValues(compareValue, this.element.value); - console.log('CleanController: Values are the same?', valuesAreSame); - - if (!valuesAreSame) { - console.log('CleanController: Values differ, preventing default.'); - event?.preventDefault(); - } - - return valuesAreSame; + clear() { + this.processTargetElements('clear').forEach((target) => { + setTimeout(() => { + target.setAttribute('value', ''); + if (this.quietValue) { + return; + } + this.dispatch('change', { + cancelable: false, + prefix: '', + target: target as HTMLInputElement, + }); + }, this.delayValue); + }); } /** - * Compares the provided strings, ensuring the values are the same. + * Simple method to dispatch a ping event to the targeted elements. */ - compareValues(...values: string[]): boolean { - console.log('CleanController: Comparing values:', values); - return new Set(values.map((value: string) => `${value}`)).size === 1; + ping() { + this.processTargetElements('ping'); } - /** - * 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() { - console.log('CleanController: Identity method called.'); - const action = Actions.Identity; - const value = this.element.value; - this.applyUpdate(action, value, value); + 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; } /** - * Prepares the value before being processed by an action method. + * 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 behaviour. */ - prepareValue(sourceValue = '') { - console.log('CleanController: Preparing value:', sourceValue); - const value = this.trimValue ? sourceValue.trim() : sourceValue; - console.log('CleanController: Prepared value:', value); - return value; + processTargetElements( + eventName: string, + { resetDisabledValue = false, value = this.element.value } = {}, + ) { + if (!resetDisabledValue && this.disabledValue) { + return []; + } + + const targetElements = [ + ...document.querySelectorAll(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: true, + cancelable: true, + detail: { element, maxLength, name, required, value }, + target, + }); + + return !event.defaultPrevented; + }); + + if (resetDisabledValue) { + this.disabledValue = targetElements.length > elements.length; + } + + return elements; } /** - * 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. + * Could use afterload or something to add backwards compatibility with documented + * 'wagtail:images|documents-upload' approach. */ - slugify( - event: CustomEvent<{ value: string }> | { detail: { value: string } }, - { ignoreUpdate = false } = {}, - ) { - console.log('CleanController: Slugify method called.'); - const { value: sourceValue = this.element.value } = event?.detail || {}; - const preparedValue = this.prepareValue(sourceValue); - if (!preparedValue) { - console.log('CleanController: Prepared value is empty, skipping.'); - return ''; + static afterLoad(identifier: string) { + if (identifier !== 'w-sync') { + return; } - const allowUnicode = this.allowUnicodeValue; - console.log('CleanController: Allow Unicode?', allowUnicode); + 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 cleanValue = slugify(preparedValue, { allowUnicode }); - console.log('CleanController: Slugified value:', cleanValue); + const { maxLength: maxTitleLength, name, value: title } = event.detail; - if (!ignoreUpdate) { - this.applyUpdate(Actions.Slugify, cleanValue, sourceValue); - } + if (!name || !title) { + return; + } - return cleanValue; - } + const data = { title }; - /** - * 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 } = {}, - ) { - console.log('CleanController: Urlify method called.'); - const { value: sourceValue = this.element.value } = event?.detail || {}; - const preparedValue = this.prepareValue(sourceValue); - if (!preparedValue) { - console.log('CleanController: Prepared value is empty, skipping.'); - return ''; - } + const filename = target.value; - const allowUnicode = this.allowUnicodeValue; - console.log('CleanController: Allow Unicode?', allowUnicode); - - const cleanValue = - urlify(preparedValue, { allowUnicode }) || - this.slugify( - { detail: { value: preparedValue } }, - { ignoreUpdate: true }, + const wrapperEvent = form.dispatchEvent( + new CustomEvent(name, { + bubbles: true, + cancelable: true, + detail: { + ...event.detail, + data, + filename, + maxTitleLength, + }, + }), ); - console.log('CleanController: Urlified value:', cleanValue); - if (!ignoreUpdate) { - this.applyUpdate(Actions.Urlify, cleanValue, sourceValue); - } + if (!wrapperEvent) { + event.preventDefault(); + } - return cleanValue; + if (data.title !== title) { + event.preventDefault(); + target.value = data.title; + } + }; + + document.addEventListener('w-sync:apply', handleEvent as EventListener); } } diff --git a/client/src/controllers/SyncController.ts b/client/src/controllers/SyncController.ts index 2c4e6732ef..7b917bd6f4 100644 --- a/client/src/controllers/SyncController.ts +++ b/client/src/controllers/SyncController.ts @@ -1,298 +1,186 @@ import { Controller } from '@hotwired/stimulus'; -import { debounce } from '../utils/debounce'; +import { slugify } from '../utils/slugify'; +import { urlify } from '../utils/urlify'; + +enum Actions { + Identity = 'identity', + Slugify = 'slugify', + Urlify = 'urlify', +} /** - * Adds ability to sync the value or interactions with one input with one - * or more targeted other inputs. + * Adds ability to clean values of an input element with methods such as slugify or urlify. * - * @example + * @example - Using the slugify method * ```html - *
- * - * - *
+ * + * + * ``` + * + * @example - Using the urlify method (registered as w-slug) + * ```html + * + * * ``` */ -export class SyncController extends Controller { +export class CleanController extends Controller { static values = { - 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: { default: '', type: String }, + allowUnicode: { default: false, type: Boolean }, + trim: { default: false, type: Boolean }, }; /** - * The delay, in milliseconds, to wait before running apply if called multiple - * times consecutively. + * 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 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; + declare readonly allowUnicodeValue: boolean; + /** If true, value will be trimmed in all clean methods before being processed by that method. */ + declare readonly trimValue: boolean; /** - * Dispatches an event to all target elements so that they can be notified - * that a sync has started, allowing them to disable the sync by preventing - * default. - */ - connect() { - console.log('SyncController: Connected to element:', this.element); - this.processTargetElements('start', { resetDisabledValue: true }); - this.apply = debounce(this.apply.bind(this), this.debounceValue); - console.log('SyncController: Debounced apply method initialized.'); - } - - /** - * Allows for targeted elements to determine, via preventing the default event, - * whether this sync controller should be disabled. - */ - check() { - console.log('SyncController: Checking target elements.'); - this.processTargetElements('check', { resetDisabledValue: true }); - } - - /** - * Applies a value from the controlled element to the targeted - * elements. Calls to this method are debounced based on the - * controller's `debounceValue`. + * Writes the new value to the element & dispatches the applied event. * - * Applying of the value to the targets can be done with a delay, - * based on the controller's `delayValue`. + * @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. */ - apply(event?: Event & { params?: { apply?: string } }) { - console.log('SyncController: Apply method called.'); - const value = this.prepareValue(event?.params?.apply || this.element.value); - console.log('SyncController: Prepared value:', value); - - const applyValue = (target) => { - console.log('SyncController: Applying value to target:', target); - target.value = value; - - if (this.quietValue) { - console.log('SyncController: Quiet mode enabled, skipping dispatch.'); - return; - } - - this.dispatch('change', { - cancelable: false, - prefix: '', - target, - }); - console.log('SyncController: Dispatched change event to target:', target); - }; - - this.processTargetElements('apply', { value }).forEach((target) => { - if (this.delayValue) { - console.log('SyncController: Applying value with delay:', this.delayValue); - setTimeout(() => { - applyValue(target); - }, this.delayValue); - } else { - applyValue(target); - } + applyUpdate(action: Actions, cleanValue: string, sourceValue?: string) { + this.element.value = cleanValue; + this.dispatch('applied', { + cancelable: false, + detail: { action, cleanValue, sourceValue }, }); } /** - * Clears the value of the targeted elements. + * 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. */ - clear() { - console.log('SyncController: Clear method called.'); - this.processTargetElements('clear').forEach((target) => { - console.log('SyncController: Clearing value for target:', target); - setTimeout(() => { - target.setAttribute('value', ''); - if (this.quietValue) { - console.log('SyncController: Quiet mode enabled, skipping dispatch.'); - return; - } - this.dispatch('change', { - cancelable: false, - prefix: '', - target: target as HTMLInputElement, - }); - console.log('SyncController: Dispatched change event to target:', target); - }, this.delayValue); - }); + 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; } /** - * Simple method to dispatch a ping event to the targeted elements. + * Compares the provided strings, ensuring the values are the same. */ - ping() { - console.log('SyncController: Ping method called.'); - this.processTargetElements('ping'); + compareValues(...values: string[]): boolean { + return new Set(values.map((value: string) => `${value}`)).size === 1; } - prepareValue(value: string) { - console.log('SyncController: Preparing value:', value); - if (!this.normalizeValue) { - console.log('SyncController: Normalization disabled, returning raw value.'); - return value; - } - - if (this.element.type === 'file') { - const normalizedValue = value - .split('\\') - .slice(-1)[0] - .replace(/\.[^.]+$/, ''); - console.log('SyncController: Normalized file value:', normalizedValue); - return normalizedValue; - } - + /** + * 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; } /** - * 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 behaviour. + * Prepares the value before being processed by an action method. */ - processTargetElements( - eventName: string, - { resetDisabledValue = false, value = this.element.value } = {}, - ) { - console.log('SyncController: Processing target elements for event:', eventName); - if (!resetDisabledValue && this.disabledValue) { - console.log('SyncController: Sync is disabled, skipping.'); - return []; - } - - const targetElements = [ - ...document.querySelectorAll(this.targetValue), - ]; - console.log('SyncController: Found target elements:', targetElements); - - 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: true, - cancelable: true, - detail: { element, maxLength, name, required, value }, - target, - }); - console.log('SyncController: Dispatched event to target:', target); - console.log('SyncController: Event default prevented?', event.defaultPrevented); - - return !event.defaultPrevented; - }); - - if (resetDisabledValue) { - this.disabledValue = targetElements.length > elements.length; - console.log('SyncController: Updated disabled value:', this.disabledValue); - } - - return elements; + prepareValue(sourceValue = '') { + const value = this.trimValue ? sourceValue.trim() : sourceValue; + return value; } /** - * Could use afterload or something to add backwards compatibility with documented - * 'wagtail:images|documents-upload' approach. + * 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. */ - static afterLoad(identifier: string) { - console.log('SyncController: afterLoad called for identifier:', identifier); - if (identifier !== 'w-sync') { - console.log('SyncController: Identifier does not match, skipping.'); - return; + 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 handleEvent = ( - event: CustomEvent<{ - maxLength: number | null; - name: string; - value: string; - }>, - ) => { - console.log('SyncController: Handling w-sync:apply event:', event); - const { - /** Will be the target title field */ - target, - } = event; - if (!target || !(target instanceof HTMLInputElement)) { - console.log('SyncController: Invalid target, skipping.'); - return; - } - const form = target.closest('form'); - if (!form) { - console.log('SyncController: Form not found, skipping.'); - return; - } + const allowUnicode = this.allowUnicodeValue; - const { maxLength: maxTitleLength, name, value: title } = event.detail; + const cleanValue = slugify(preparedValue, { allowUnicode }); - if (!name || !title) { - console.log('SyncController: Missing name or title, skipping.'); - return; - } + if (!ignoreUpdate) { + this.applyUpdate(Actions.Slugify, cleanValue, sourceValue); + } - const data = { title }; + return cleanValue; + } - const filename = target.value; + /** + * 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 wrapperEvent = form.dispatchEvent( - new CustomEvent(name, { - bubbles: true, - cancelable: true, - detail: { - ...event.detail, - data, - filename, - maxTitleLength, - }, - }), + const allowUnicode = this.allowUnicodeValue; + + const cleanValue = + urlify(preparedValue, { allowUnicode }) || + this.slugify( + { detail: { value: preparedValue } }, + { ignoreUpdate: true }, ); - if (!wrapperEvent) { - console.log('SyncController: Wrapper event prevented, skipping title update.'); - event.preventDefault(); - } + if (!ignoreUpdate) { + this.applyUpdate(Actions.Urlify, cleanValue, sourceValue); + } - if (data.title !== title) { - console.log('SyncController: Title modified, updating manually.'); - event.preventDefault(); - target.value = data.title; - } - }; - - document.addEventListener('w-sync:apply', handleEvent as EventListener); - console.log('SyncController: Added event listener for w-sync:apply.'); + return cleanValue; } } diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index fdc483aa83..abde868a8a 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -5,6 +5,7 @@ import { ActionController } from './ActionController'; import { AutosizeController } from './AutosizeController'; import { BlockController } from './BlockController'; import { BulkController } from './BulkController'; +import { CleanController } from './CleanController'; import { ClipboardController } from './ClipboardController'; import { CloneController } from './CloneController'; import { CountController } from './CountController'; @@ -23,7 +24,6 @@ import { ProgressController } from './ProgressController'; import { RevealController } from './RevealController'; import { RulesController } from './RulesController'; import { SessionController } from './SessionController'; -import { CleanController } from './CleanController'; import { SubmitController } from './SubmitController'; import { SwapController } from './SwapController'; import { SyncController } from './SyncController'; From 99d2cb023e08f7a110cf3ea64d01652545f894c5 Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Thu, 17 Apr 2025 17:36:56 +0530 Subject: [PATCH 14/15] Using js to inject attributes in add.html files --- .../templates/wagtaildocs/documents/add.html | 111 +++++++++--------- .../templates/wagtailimages/images/add.html | 69 ++++++----- 2 files changed, 97 insertions(+), 83 deletions(-) diff --git a/wagtail/documents/templates/wagtaildocs/documents/add.html b/wagtail/documents/templates/wagtaildocs/documents/add.html index a930d9490e..4901a94f3a 100644 --- a/wagtail/documents/templates/wagtaildocs/documents/add.html +++ b/wagtail/documents/templates/wagtaildocs/documents/add.html @@ -1,65 +1,64 @@ {% extends "wagtailadmin/generic/form.html" %} {% load i18n wagtailadmin_tags static %} -{% block titletag %}{% trans "Add a document" %}{% endblock %} - {% block extra_js %} {{ block.super }} + {% endblock %} {% block form %} -
- {% csrf_token %} - - {# File Input with Sync Controller #} -
- - -

- {% trans "Supported formats: PDF, Word, Excel, PowerPoint, Text" %} -

-
- - {# Title Input with Clean Controller #} -
- - -

-
- - {# Submit Button #} -
- -
-
+ {{ block.super }} + +
+ + +

{% trans "Maximum file size: 10 MB. Supported formats: PNG, JPG, GIF, WebP." %}

+
+ +
+ + +
{% endblock %} + +{% block form_actions %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/wagtail/images/templates/wagtailimages/images/add.html b/wagtail/images/templates/wagtailimages/images/add.html index 1c882d2bab..4901a94f3a 100644 --- a/wagtail/images/templates/wagtailimages/images/add.html +++ b/wagtail/images/templates/wagtailimages/images/add.html @@ -1,49 +1,64 @@ {% extends "wagtailadmin/generic/form.html" %} {% load i18n wagtailadmin_tags static %} -{% block titletag %}{% trans "Add a document" %}{% endblock %} - {% block extra_js %} {{ block.super }} + {% endblock %} {% block form %} -
- {% csrf_token %} - - + {{ block.super }} + +
+ -

{% trans "Maximum file size: 10 MB" %}

- +

{% trans "Maximum file size: 10 MB. Supported formats: PNG, JPG, GIF, WebP." %}

+
+ +
- -
- -
- +
{% endblock %} + +{% block form_actions %} + + {{ block.super }} +{% endblock %} \ No newline at end of file From 87b8c0604e449b98d874d6c00472a89ce2d7c0ac Mon Sep 17 00:00:00 2001 From: Aayushman Singh Date: Thu, 17 Apr 2025 18:03:33 +0530 Subject: [PATCH 15/15] Formatting --- .../controllers/CleanController.stories.js | 2 +- client/src/controllers/CleanController.ts | 357 +++++++---------- client/src/controllers/SyncController.ts | 359 +++++++++++------- client/src/utils/slugify.test.js | 2 +- client/src/utils/urlify.test.js | 2 +- client/src/utils/urlify.ts | 2 +- .../static_src/wagtaildocs/js/add-multiple.js | 120 ++---- .../templates/wagtaildocs/documents/add.html | 69 ++-- .../wagtailimages/js/add-multiple.js | 36 -- 9 files changed, 436 insertions(+), 513 deletions(-) diff --git a/client/src/controllers/CleanController.stories.js b/client/src/controllers/CleanController.stories.js index 69f0a18048..f5d92c0a8d 100644 --- a/client/src/controllers/CleanController.stories.js +++ b/client/src/controllers/CleanController.stories.js @@ -113,4 +113,4 @@ const Template = ({ debug = false }) => { ); }; -export const Base = Template.bind({}); \ No newline at end of file +export const Base = Template.bind({}); diff --git a/client/src/controllers/CleanController.ts b/client/src/controllers/CleanController.ts index 6711a43053..7b917bd6f4 100644 --- a/client/src/controllers/CleanController.ts +++ b/client/src/controllers/CleanController.ts @@ -1,267 +1,186 @@ import { Controller } from '@hotwired/stimulus'; -import { debounce } from '../utils/debounce'; +import { slugify } from '../utils/slugify'; +import { urlify } from '../utils/urlify'; + +enum Actions { + Identity = 'identity', + Slugify = 'slugify', + Urlify = 'urlify', +} /** - * Adds ability to sync the value or interactions with one input with one - * or more targeted other inputs. + * Adds ability to clean values of an input element with methods such as slugify or urlify. * - * @example + * @example - Using the slugify method * ```html - *
- * - * - *
+ * + * + * ``` + * + * @example - Using the urlify method (registered as w-slug) + * ```html + * + * * ``` */ -export class SyncController extends Controller { +export class CleanController extends Controller { static values = { - 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: { default: '', type: String }, + allowUnicode: { default: false, type: Boolean }, + trim: { default: false, type: Boolean }, }; /** - * The delay, in milliseconds, to wait before running apply if called multiple - * times consecutively. + * 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 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; + declare readonly allowUnicodeValue: boolean; + /** If true, value will be trimmed in all clean methods before being processed by that method. */ + declare readonly trimValue: boolean; /** - * Dispatches an event to all target elements so that they can be notified - * that a sync has started, allowing them to disable the sync by preventing - * default. - */ - connect() { - this.processTargetElements('start', { resetDisabledValue: true }); - this.apply = debounce(this.apply.bind(this), this.debounceValue); - } - - /** - * Allows for targeted elements to determine, via preventing the default event, - * whether this sync controller should be disabled. - */ - check() { - this.processTargetElements('check', { resetDisabledValue: true }); - } - - /** - * Applies a value from the controlled element to the targeted - * elements. Calls to this method are debounced based on the - * controller's `debounceValue`. + * Writes the new value to the element & dispatches the applied event. * - * Applying of the value to the targets can be done with a delay, - * based on the controller's `delayValue`. + * @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. */ - apply(event?: Event & { params?: { apply?: string } }) { - const value = this.prepareValue(event?.params?.apply || this.element.value); - - const applyValue = (target) => { - target.value = value; - - if (this.quietValue) { - return; - } - - this.dispatch('change', { - cancelable: false, - prefix: '', - target, - }); - }; - - this.processTargetElements('apply', { value }).forEach((target) => { - if (this.delayValue) { - setTimeout(() => { - applyValue(target); - }, this.delayValue); - } else { - applyValue(target); - } + applyUpdate(action: Actions, cleanValue: string, sourceValue?: string) { + this.element.value = cleanValue; + this.dispatch('applied', { + cancelable: false, + detail: { action, cleanValue, sourceValue }, }); } /** - * Clears the value of the targeted elements. + * 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. */ - clear() { - this.processTargetElements('clear').forEach((target) => { - setTimeout(() => { - target.setAttribute('value', ''); - if (this.quietValue) { - return; - } - this.dispatch('change', { - cancelable: false, - prefix: '', - target: target as HTMLInputElement, - }); - }, this.delayValue); - }); + 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; } /** - * Simple method to dispatch a ping event to the targeted elements. + * Compares the provided strings, ensuring the values are the same. */ - ping() { - this.processTargetElements('ping'); + compareValues(...values: string[]): boolean { + return new Set(values.map((value: string) => `${value}`)).size === 1; } - prepareValue(value: string) { - if (!this.normalizeValue) { - return value; - } - - if (this.element.type === 'file') { - const normalizedValue = value - .split('\\') - .slice(-1)[0] - .replace(/\.[^.]+$/, ''); - - return normalizedValue; - } - + /** + * 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; } /** - * 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 behaviour. + * Prepares the value before being processed by an action method. */ - processTargetElements( - eventName: string, - { resetDisabledValue = false, value = this.element.value } = {}, - ) { - if (!resetDisabledValue && this.disabledValue) { - return []; - } - - const targetElements = [ - ...document.querySelectorAll(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: true, - cancelable: true, - detail: { element, maxLength, name, required, value }, - target, - }); - - return !event.defaultPrevented; - }); - - if (resetDisabledValue) { - this.disabledValue = targetElements.length > elements.length; - } - - return elements; + prepareValue(sourceValue = '') { + const value = this.trimValue ? sourceValue.trim() : sourceValue; + return value; } /** - * Could use afterload or something to add backwards compatibility with documented - * 'wagtail:images|documents-upload' approach. + * 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. */ - static afterLoad(identifier: string) { - if (identifier !== 'w-sync') { - return; + 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 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 allowUnicode = this.allowUnicodeValue; - const { maxLength: maxTitleLength, name, value: title } = event.detail; + const cleanValue = slugify(preparedValue, { allowUnicode }); - if (!name || !title) { - return; - } + if (!ignoreUpdate) { + this.applyUpdate(Actions.Slugify, cleanValue, sourceValue); + } - const data = { title }; + return cleanValue; + } - const filename = target.value; + /** + * 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 wrapperEvent = form.dispatchEvent( - new CustomEvent(name, { - bubbles: true, - cancelable: true, - detail: { - ...event.detail, - data, - filename, - maxTitleLength, - }, - }), + const allowUnicode = this.allowUnicodeValue; + + const cleanValue = + urlify(preparedValue, { allowUnicode }) || + this.slugify( + { detail: { value: preparedValue } }, + { ignoreUpdate: true }, ); - if (!wrapperEvent) { - event.preventDefault(); - } + if (!ignoreUpdate) { + this.applyUpdate(Actions.Urlify, cleanValue, sourceValue); + } - if (data.title !== title) { - event.preventDefault(); - target.value = data.title; - } - }; - - document.addEventListener('w-sync:apply', handleEvent as EventListener); + return cleanValue; } } diff --git a/client/src/controllers/SyncController.ts b/client/src/controllers/SyncController.ts index 7b917bd6f4..6711a43053 100644 --- a/client/src/controllers/SyncController.ts +++ b/client/src/controllers/SyncController.ts @@ -1,186 +1,267 @@ import { Controller } from '@hotwired/stimulus'; -import { slugify } from '../utils/slugify'; -import { urlify } from '../utils/urlify'; - -enum Actions { - Identity = 'identity', - Slugify = 'slugify', - Urlify = 'urlify', -} +import { debounce } from '../utils/debounce'; /** - * Adds ability to clean values of an input element with methods such as slugify or urlify. + * Adds ability to sync the value or interactions with one input with one + * or more targeted other inputs. * - * @example - Using the slugify method + * @example * ```html - * - * - * ``` - * - * @example - Using the urlify method (registered as w-slug) - * ```html - * - * + *
+ * + * + *
* ``` */ -export class CleanController extends Controller { +export class SyncController extends Controller { static values = { - allowUnicode: { default: false, type: Boolean }, - trim: { default: false, type: Boolean }, + 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: { default: '', type: String }, }; /** - * 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 + * The delay, in milliseconds, to wait before running apply if called multiple + * times consecutively. */ - declare readonly allowUnicodeValue: boolean; - /** If true, value will be trimmed in all clean methods before being processed by that method. */ - declare readonly trimValue: boolean; + 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; /** - * 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. + * Dispatches an event to all target elements so that they can be notified + * that a sync has started, allowing them to disable the sync by preventing + * default. */ - applyUpdate(action: Actions, cleanValue: string, sourceValue?: string) { - this.element.value = cleanValue; - this.dispatch('applied', { - cancelable: false, - detail: { action, cleanValue, sourceValue }, + connect() { + this.processTargetElements('start', { resetDisabledValue: true }); + this.apply = debounce(this.apply.bind(this), this.debounceValue); + } + + /** + * Allows for targeted elements to determine, via preventing the default event, + * whether this sync controller should be disabled. + */ + check() { + this.processTargetElements('check', { resetDisabledValue: true }); + } + + /** + * Applies a value from the controlled element to the targeted + * elements. Calls to this method are debounced based on the + * controller's `debounceValue`. + * + * Applying of the value to the targets can be done with a delay, + * based on the controller's `delayValue`. + */ + apply(event?: Event & { params?: { apply?: string } }) { + const value = this.prepareValue(event?.params?.apply || this.element.value); + + const applyValue = (target) => { + target.value = value; + + if (this.quietValue) { + return; + } + + this.dispatch('change', { + cancelable: false, + prefix: '', + target, + }); + }; + + this.processTargetElements('apply', { value }).forEach((target) => { + if (this.delayValue) { + setTimeout(() => { + applyValue(target); + }, this.delayValue); + } else { + applyValue(target); + } }); } /** - * 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. + * Clears the value of the targeted elements. */ - 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; + clear() { + this.processTargetElements('clear').forEach((target) => { + setTimeout(() => { + target.setAttribute('value', ''); + if (this.quietValue) { + return; + } + this.dispatch('change', { + cancelable: false, + prefix: '', + target: target as HTMLInputElement, + }); + }, this.delayValue); + }); } /** - * Compares the provided strings, ensuring the values are the same. + * Simple method to dispatch a ping event to the targeted elements. */ - compareValues(...values: string[]): boolean { - return new Set(values.map((value: string) => `${value}`)).size === 1; + ping() { + this.processTargetElements('ping'); } - /** - * 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); + 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; } /** - * Prepares the value before being processed by an action method. + * 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 behaviour. */ - prepareValue(sourceValue = '') { - const value = this.trimValue ? sourceValue.trim() : sourceValue; - return value; + processTargetElements( + eventName: string, + { resetDisabledValue = false, value = this.element.value } = {}, + ) { + if (!resetDisabledValue && this.disabledValue) { + return []; + } + + const targetElements = [ + ...document.querySelectorAll(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: true, + cancelable: true, + detail: { element, maxLength, name, required, value }, + target, + }); + + return !event.defaultPrevented; + }); + + if (resetDisabledValue) { + this.disabledValue = targetElements.length > elements.length; + } + + return elements; } /** - * 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. + * Could use afterload or something to add backwards compatibility with documented + * 'wagtail:images|documents-upload' approach. */ - 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 ''; + static afterLoad(identifier: string) { + if (identifier !== 'w-sync') { + return; } - const allowUnicode = this.allowUnicodeValue; + 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 cleanValue = slugify(preparedValue, { allowUnicode }); + const { maxLength: maxTitleLength, name, value: title } = event.detail; - if (!ignoreUpdate) { - this.applyUpdate(Actions.Slugify, cleanValue, sourceValue); - } + if (!name || !title) { + return; + } - return cleanValue; - } + const data = { title }; - /** - * 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 filename = target.value; - const allowUnicode = this.allowUnicodeValue; - - const cleanValue = - urlify(preparedValue, { allowUnicode }) || - this.slugify( - { detail: { value: preparedValue } }, - { ignoreUpdate: true }, + const wrapperEvent = form.dispatchEvent( + new CustomEvent(name, { + bubbles: true, + cancelable: true, + detail: { + ...event.detail, + data, + filename, + maxTitleLength, + }, + }), ); - if (!ignoreUpdate) { - this.applyUpdate(Actions.Urlify, cleanValue, sourceValue); - } + if (!wrapperEvent) { + event.preventDefault(); + } - return cleanValue; + if (data.title !== title) { + event.preventDefault(); + target.value = data.title; + } + }; + + document.addEventListener('w-sync:apply', handleEvent as EventListener); } } diff --git a/client/src/utils/slugify.test.js b/client/src/utils/slugify.test.js index d7f0e33366..220dcfb322 100644 --- a/client/src/utils/slugify.test.js +++ b/client/src/utils/slugify.test.js @@ -42,4 +42,4 @@ describe('slugify', () => { expect(slugify(' I like _ßpaces', options)).toBe('-i-like-_ßpaces'); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/utils/urlify.test.js b/client/src/utils/urlify.test.js index 5c5185e7aa..b102568e38 100644 --- a/client/src/utils/urlify.test.js +++ b/client/src/utils/urlify.test.js @@ -39,4 +39,4 @@ describe('urlify', () => { expect(urlify(' I like _ßpaces', options)).toBe('-i-like-_ßpaces'); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/utils/urlify.ts b/client/src/utils/urlify.ts index 0ad01d650a..c8630e8a37 100644 --- a/client/src/utils/urlify.ts +++ b/client/src/utils/urlify.ts @@ -42,4 +42,4 @@ export const urlify = ( str = str.substring(0, numChars); // trim to first num_chars chars str = str.replace(/-+$/g, ''); // trim any trailing hyphens return str; -}; \ No newline at end of file +}; diff --git a/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js b/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js index 6f4c1150e4..f39b4041b8 100644 --- a/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js +++ b/wagtail/documents/static_src/wagtaildocs/js/add-multiple.js @@ -1,19 +1,21 @@ $(function () { - console.log('[Documents] Initializing bulk upload form'); - const fileFields = document.querySelectorAll('[data-bulk-upload-file]'); fileFields.forEach((fileField) => { - console.log('[Documents] Setting up w-sync controller for file field', 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-target-value', + '[data-bulk-upload-title]', + ); fileField.setAttribute('data-w-sync-normalize-value', 'true'); - fileField.setAttribute('data-w-sync-name-value', 'wagtail:documents-upload'); + fileField.setAttribute( + 'data-w-sync-name-value', + 'wagtail:documents-upload', + ); }); const titleFields = document.querySelectorAll('[data-bulk-upload-title]'); titleFields.forEach((titleField) => { - console.log('[Documents] Setting up w-clean controller for title field', titleField); titleField.setAttribute('data-controller', 'w-clean'); titleField.setAttribute('data-action', 'blur->w-clean#slugify'); }); @@ -24,41 +26,35 @@ $(function () { dropZone: $('.drop-zone'), add: function (e, data) { - console.group('[Documents] File added to queue'); - console.log('Files:', data.files); - var $this = $(this); var that = $this.data('blueimp-fileupload') || $this.data('fileupload'); var li = $($('#upload-list-item').html()).addClass('upload-uploading'); var options = that.options; - console.log('[Documents] Creating upload list item'); $('#upload-list').append(li); data.context = li; - data.process(function () { - console.log('[Documents] Processing file validation'); + data + .process(function () { return $this.fileupload('process', data); }) .always(function () { - console.log('[Documents] File processing completed'); data.context.removeClass('processing'); data.context.find('.left').each(function (index, elm) { $(elm).append(escapeHtml(data.files[index].name)); }); }) .done(function () { - console.log('[Documents] File validation successful'); data.context.find('.start').prop('disabled', false); - if (that._trigger('added', e, data) !== false && - (options.autoUpload || data.autoUpload) && - data.autoUpload !== false) { - console.log('[Documents] Auto-submitting file'); + if ( + that._trigger('added', e, data) !== false && + (options.autoUpload || data.autoUpload) && + data.autoUpload !== false + ) { data.submit(); } }) .fail(function () { - console.error('[Documents] File validation failed'); if (data.files.error) { data.context.each(function (index) { var error = data.files[index].error; @@ -68,11 +64,9 @@ $(function () { }); } }); - console.groupEnd(); }, processfail: function (e, data) { - console.error('[Documents] File processing failed', data.files[0].error); var itemElement = $(data.context); itemElement.removeClass('upload-uploading').addClass('upload-failure'); }, @@ -83,8 +77,7 @@ $(function () { } var progress = Math.floor((data.loaded / data.total) * 100); - console.log(`[Documents] Upload progress for file: ${progress}%`); - + data.context.each(function () { $(this) .find('.progress') @@ -98,8 +91,7 @@ $(function () { progressall: function (e, data) { var progress = parseInt((data.loaded / data.total) * 100, 10); - console.log(`[Documents] Total upload progress: ${progress}%`); - + $('#overall-progress') .addClass('active') .attr('aria-valuenow', progress) @@ -108,7 +100,6 @@ $(function () { .html(progress + '%'); if (progress >= 100) { - console.log('[Documents] All files completed uploading'); $('#overall-progress') .removeClass('active') .find('.bar') @@ -117,50 +108,39 @@ $(function () { }, formData: function (form) { - console.log('[Documents] Preparing form data', form.serializeArray()); return form.serializeArray(); }, done: function (e, data) { - console.group('[Documents] File upload completed'); var itemElement = $(data.context); var response = JSON.parse(data.result); - console.log('Server response:', response); if (response.success) { - console.log('[Documents] Upload successful'); itemElement.addClass('upload-success'); $('.right', itemElement).append(response.form); } else { - console.error('[Documents] Upload failed', response.error_message); itemElement.addClass('upload-failure'); $('.right .error_messages', itemElement).append(response.error_message); } - console.groupEnd(); }, fail: function (e, data) { - console.error('[Documents] File upload failed', data.errorThrown); var itemElement = $(data.context); itemElement.addClass('upload-failure'); }, always: function (e, data) { - console.log('[Documents] Upload process completed (success or failure)'); var itemElement = $(data.context); itemElement.removeClass('upload-uploading').addClass('upload-complete'); - } + }, }); - // Enhanced logging for form submissions $('#upload-list').on('submit', 'form', function (e) { - console.group('[Documents] Processing form update'); var form = $(this); var formData = new FormData(this); var itemElement = form.closest('#upload-list > li'); e.preventDefault(); - console.log('Form data:', Object.fromEntries(formData)); $.ajax({ contentType: false, @@ -168,65 +148,41 @@ $(function () { processData: false, type: 'POST', url: this.action, - }).done(function (data) { - console.log('[Documents] Form update response', data); - if (data.success) { - console.log('[Documents] Update successful'); - 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 { - console.log('[Documents] Update failed, showing form errors'); - form.replaceWith(data.form); - } - }).fail(function (xhr, status, error) { - console.error('[Documents] Form update failed', status, error); - }); - console.groupEnd(); + }) + .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 + }); }); - // Enhanced logging for delete operations $('#upload-list').on('click', '.delete', function (e) { - console.group('[Documents] Processing delete request'); var form = $(this).closest('form'); var itemElement = form.closest('#upload-list > li'); e.preventDefault(); - console.log('Delete URL:', this.href); var CSRFToken = $('input[name="csrfmiddlewaretoken"]', form).val(); $.post(this.href, { csrfmiddlewaretoken: CSRFToken }, function (data) { - console.log('[Documents] Delete response', data); if (data.success) { - console.log('[Documents] Delete successful'); itemElement.slideUp(function () { $(this).remove(); }); } - }).fail(function (xhr, status, error) { - console.error('[Documents] Delete failed', status, error); - }); - console.groupEnd(); + }).fail(function (xhr, status, error) {}); }); - - document.addEventListener('sync:complete', (e) => { - console.log('[Stimulus] w-sync completed:', e.detail); - }); - - document.addEventListener('sync:error', (e) => { - console.error('[Stimulus] w-sync error:', e.detail); - }); - - document.addEventListener('cleaned', (e) => { - console.log('[Stimulus] w-clean completed:', e.target.value); - }); - - console.log('[Documents] Bulk upload initialization complete'); }); diff --git a/wagtail/documents/templates/wagtaildocs/documents/add.html b/wagtail/documents/templates/wagtaildocs/documents/add.html index 4901a94f3a..6a63eba704 100644 --- a/wagtail/documents/templates/wagtaildocs/documents/add.html +++ b/wagtail/documents/templates/wagtaildocs/documents/add.html @@ -1,6 +1,8 @@ {% extends "wagtailadmin/generic/form.html" %} {% load i18n wagtailadmin_tags static %} +{% block titletag %}{% trans "Add a document" %}{% endblock %} + {% block extra_js %} {{ block.super }}