From f539fc258c4fd0d3b5a968f50913f356138fc997 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:25:14 +0200 Subject: [PATCH] Reduce jQuery usage: vanilla leaf modules + opt-in Interactivity API forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/contributing/jquery-migration-plan.md (five-phase roadmap with ecosystem-compat strategy) and executes phases 1 and 3: Phase 1 — nine leaf modules (unload + url/range/radio/button-group/ checkbox/true-false/oembed/link fields) converted to vanilla DOM internals. acf.Model events maps, helper signatures, and the public acf.* API are unchanged; jQuery stays at API boundaries and where semantics require it (oembed $.ajax abort, wpLink, beforeunload). jQuery-mock Jest tests rewritten 1:1 against real jsdom DOM. Phase 3 — opt-in @wordpress/interactivity bundle for acf_form() pages (frontend_interactivity_form setting, default off; flag-off behavior byte-identical). Server-side eligibility: simple-field forms on WP 6.5+ get the 3.6KB scf/form module with zero jQuery; complex fields fall back to the classic stack automatically. Client validation mirrors the classic contract (native constraints + acf/validate_save_post ajax), then a native POST through the untouched form-front save pipeline. New e2e spec proves the jQuery-free path logged-out, including the classic fallback. Verified: 2802 PHPUnit / 967 Jest / 258 E2E green, PHPStan clean. Co-Authored-By: Claude Fable 5 --- assets/src/js/_acf-field-button-group.js | 85 +++-- assets/src/js/_acf-field-checkbox.js | 115 +++--- assets/src/js/_acf-field-link.js | 41 ++- assets/src/js/_acf-field-oembed.js | 24 +- assets/src/js/_acf-field-radio.js | 75 ++-- assets/src/js/_acf-field-range.js | 4 +- assets/src/js/_acf-field-true-false.js | 35 +- assets/src/js/_acf-field-url.js | 12 +- assets/src/js/_acf-unload.js | 21 +- assets/src/js/frontend/scf-form-validation.js | 296 ++++++++++++++++ assets/src/js/frontend/scf-form-view.js | 139 ++++++++ docs/contributing/jquery-migration-plan.md | 264 ++++++++++++++ includes/forms/form-front-view.php | 164 +++++++++ includes/forms/form-front.php | 21 ++ secure-custom-fields.php | 86 ++--- tests/e2e/front-end-form.spec.ts | 327 ++++++++++++++++++ tests/e2e/plugins/scf-test-front-form.php | 216 ++++++++++++ tests/js/fields/button-group.test.js | 262 +++++++------- tests/js/fields/checkbox.test.js | 62 ++-- tests/js/fields/radio.test.js | 175 +++++----- tests/js/fields/range.test.js | 66 ++-- tests/js/fields/true-false.test.js | 112 +++--- tests/js/fields/url.test.js | 33 +- tests/js/frontend/scf-form-validation.test.js | 327 ++++++++++++++++++ tests/js/unload.test.js | 25 +- .../includes/forms/test-form-front-view.php | 293 ++++++++++++++++ webpack.config.js | 73 +++- 27 files changed, 2825 insertions(+), 528 deletions(-) create mode 100644 assets/src/js/frontend/scf-form-validation.js create mode 100644 assets/src/js/frontend/scf-form-view.js create mode 100644 docs/contributing/jquery-migration-plan.md create mode 100644 includes/forms/form-front-view.php create mode 100644 tests/e2e/front-end-form.spec.ts create mode 100644 tests/e2e/plugins/scf-test-front-form.php create mode 100644 tests/js/frontend/scf-form-validation.test.js create mode 100644 tests/php/includes/forms/test-form-front-view.php diff --git a/assets/src/js/_acf-field-button-group.js b/assets/src/js/_acf-field-button-group.js index 5a6a07ef..985e764c 100644 --- a/assets/src/js/_acf-field-button-group.js +++ b/assets/src/js/_acf-field-button-group.js @@ -21,35 +21,46 @@ import { update } from '@wordpress/icons'; }, setValue: function ( val ) { - this.$( 'input[value="' + val + '"]' ) - .prop( 'checked', true ) - .trigger( 'change' ); + const input = this.$el[ 0 ].querySelector( + 'input[value="' + val + '"]' + ); + if ( input ) { + input.checked = true; + input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); + } this.updateButtonStates(); }, updateButtonStates: function () { - const labels = this.$control().find( 'label' ); - const input = this.$input(); - labels - .removeClass( 'selected' ) - .attr( 'aria-checked', 'false' ) - .attr( 'tabindex', '-1' ); - if ( input.length ) { + const control = this.$control()[ 0 ]; + if ( ! control ) { + return; + } + const labels = control.querySelectorAll( 'label' ); + const input = this.$input()[ 0 ]; + labels.forEach( ( label ) => { + label.classList.remove( 'selected' ); + label.setAttribute( 'aria-checked', 'false' ); + label.setAttribute( 'tabindex', '-1' ); + } ); + if ( input ) { // If there's a checked input, mark its parent label as selected - input - .parent( 'label' ) - .addClass( 'selected' ) - .attr( 'aria-checked', 'true' ) - .attr( 'tabindex', '0' ); - } else { - labels.first().attr( 'tabindex', '0' ); + const inputLabel = input.closest( 'label' ); + if ( inputLabel ) { + inputLabel.classList.add( 'selected' ); + inputLabel.setAttribute( 'aria-checked', 'true' ); + inputLabel.setAttribute( 'tabindex', '0' ); + } + } else if ( labels.length ) { + labels[ 0 ].setAttribute( 'tabindex', '0' ); } }, onClick: function ( e, $el ) { - this.selectButton( $el.parent( 'label' ) ); + this.selectButton( $el[ 0 ].closest( 'label' ) ); }, - onKeyDown: function ( event, label ) { + onKeyDown: function ( event, $label ) { const key = event.which; + const label = $label[ 0 ]; // Space or Enter: select the button if ( key === 13 || key === 32 ) { @@ -61,8 +72,10 @@ import { update } from '@wordpress/icons'; // Arrow keys: move focus between buttons if ( key === 37 || key === 39 || key === 38 || key === 40 ) { event.preventDefault(); - const labels = this.$control().find( 'label' ); - const currentIndex = labels.index( label ); + const labels = Array.from( + this.$control()[ 0 ].querySelectorAll( 'label' ) + ); + const currentIndex = labels.indexOf( label ); let nextIndex; // Left/Up arrow: move to previous, wrap to last if at start @@ -76,18 +89,32 @@ import { update } from '@wordpress/icons'; currentIndex < labels.length - 1 ? currentIndex + 1 : 0; } - const nextLabel = labels.eq( nextIndex ); - labels.attr( 'tabindex', '-1' ); - nextLabel.attr( 'tabindex', '0' ).trigger( 'focus' ); + const nextLabel = labels[ nextIndex ]; + labels.forEach( ( item ) => { + item.setAttribute( 'tabindex', '-1' ); + } ); + nextLabel.setAttribute( 'tabindex', '0' ); + nextLabel.focus(); } }, selectButton: function ( element ) { - const inputRadio = element.find( 'input[type="radio"]' ); - const isSelected = element.hasClass( 'selected' ); - inputRadio.prop( 'checked', true ).trigger( 'change' ); - if ( this.get( 'allow_null' ) && isSelected ) { - inputRadio.prop( 'checked', false ).trigger( 'change' ); + const inputRadio = element + ? element.querySelector( 'input[type="radio"]' ) + : null; + const isSelected = + element && element.classList.contains( 'selected' ); + if ( inputRadio ) { + inputRadio.checked = true; + inputRadio.dispatchEvent( + new Event( 'change', { bubbles: true } ) + ); + if ( this.get( 'allow_null' ) && isSelected ) { + inputRadio.checked = false; + inputRadio.dispatchEvent( + new Event( 'change', { bubbles: true } ) + ); + } } this.updateButtonStates(); }, diff --git a/assets/src/js/_acf-field-checkbox.js b/assets/src/js/_acf-field-checkbox.js index 9e24b7a2..4ab50828 100644 --- a/assets/src/js/_acf-field-checkbox.js +++ b/assets/src/js/_acf-field-checkbox.js @@ -33,56 +33,65 @@ val = val ? [ val ] : []; } - this.$inputs().each( function () { - const $input = $( this ); - const checked = val.includes( $input.val() ); - $input.prop( 'checked', checked ); + const inputs = Array.from( this.$inputs() ); + inputs.forEach( function ( input ) { + const checked = val.includes( input.value ); + input.checked = checked; + + const label = input.closest( 'label' ); + if ( ! label ) { + return; + } if ( checked ) { - $input.parent( 'label' ).addClass( 'selected' ); + label.classList.add( 'selected' ); } else { - $input.parent( 'label' ).removeClass( 'selected' ); + label.classList.remove( 'selected' ); } } ); - const $toggle = this.$toggle(); - if ( $toggle.length ) { - const checked = this.$inputs().not( ':checked' ).length === 0; - $toggle.prop( 'checked', checked ); + const toggle = this.$toggle()[ 0 ]; + if ( toggle ) { + toggle.checked = inputs.every( function ( input ) { + return input.checked; + } ); } }, getValue: function () { var val = []; - this.$( ':checked' ).each( function () { - val.push( $( this ).val() ); - } ); + this.$el[ 0 ] + .querySelectorAll( ':checked' ) + .forEach( function ( input ) { + val.push( input.value ); + } ); return val.length ? val : false; }, onChange: function ( e, $el ) { // Vars. - var checked = $el.prop( 'checked' ); - var $label = $el.parent( 'label' ); - var $toggle = this.$toggle(); + var input = $el[ 0 ]; + var checked = input.checked; + var label = input.closest( 'label' ); + var toggle = this.$toggle()[ 0 ]; // Add or remove "selected" class. - if ( checked ) { - $label.addClass( 'selected' ); - } else { - $label.removeClass( 'selected' ); + if ( label ) { + if ( checked ) { + label.classList.add( 'selected' ); + } else { + label.classList.remove( 'selected' ); + } } // Update toggle state if all inputs are checked. - if ( $toggle.length ) { - var $inputs = this.$inputs(); + if ( toggle ) { + var inputs = Array.from( this.$inputs() ); // all checked - if ( $inputs.not( ':checked' ).length == 0 ) { - $toggle.prop( 'checked', true ); - } else { - $toggle.prop( 'checked', false ); - } + toggle.checked = inputs.every( function ( item ) { + return item.checked; + } ); } }, @@ -91,35 +100,49 @@ '
  • '; - $el.parent( 'li' ).before( html ); - $el.parent( 'li' ) - .parent() - .find( 'input[type="text"]' ) - .last() - .trigger( 'focus' ); + var li = $el[ 0 ].closest( 'li' ); + li.insertAdjacentHTML( 'beforebegin', html ); + var texts = + li.parentElement.querySelectorAll( 'input[type="text"]' ); + if ( texts.length ) { + texts[ texts.length - 1 ].focus(); + } }, onClickToggle: function ( e, $el ) { - var $inputs = this.$inputs(); - var checked = $el.prop( 'checked' ); - $inputs.prop( 'checked', checked ).trigger( 'change' ); + var inputs = Array.from( this.$inputs() ); + var checked = $el[ 0 ].checked; + + // Set all states first so listeners never observe a partial state. + inputs.forEach( function ( input ) { + input.checked = checked; + } ); + inputs.forEach( function ( input ) { + input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); + } ); }, onClickCustom: function ( e, $el ) { - var checked = $el.prop( 'checked' ); - var $text = $el.next( 'input[type="text"]' ); + var input = $el[ 0 ]; + var checked = input.checked; + var text = input.nextElementSibling; + + // bail early if no adjacent text input + if ( ! text || ! text.matches( 'input[type="text"]' ) ) { + return; + } // checked if ( checked ) { - $text.prop( 'disabled', false ); + text.disabled = false; // not checked } else { - $text.prop( 'disabled', true ); + text.disabled = true; // remove - if ( $text.val() == '' ) { - $el.parent( 'li' ).remove(); + if ( text.value == '' ) { + input.closest( 'li' ).remove(); } } }, @@ -130,12 +153,12 @@ e.preventDefault(); // Toggle the checkbox state and trigger change event - $el.prop( 'checked', ! $el.prop( 'checked' ) ).trigger( - 'change' - ); + var input = $el[ 0 ]; + input.checked = ! input.checked; + input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); // If this is the "Select All" toggle checkbox, run the toggle logic - if ( $el.is( '.acf-checkbox-toggle' ) ) { + if ( input.classList.contains( 'acf-checkbox-toggle' ) ) { this.onClickToggle( e, $el ); } } diff --git a/assets/src/js/_acf-field-link.js b/assets/src/js/_acf-field-link.js index ea3fb744..9657f9c3 100644 --- a/assets/src/js/_acf-field-link.js +++ b/assets/src/js/_acf-field-link.js @@ -19,18 +19,18 @@ getValue: function () { // vars - var $node = this.$node(); + var node = this.$node()[ 0 ]; // return false if empty - if ( ! $node.attr( 'href' ) ) { + if ( ! node || ! node.getAttribute( 'href' ) ) { return false; } // return return { - title: $node.html(), - url: $node.attr( 'href' ), - target: $node.attr( 'target' ), + title: node.innerHTML, + url: node.getAttribute( 'href' ), + target: node.getAttribute( 'target' ) || '', }; }, @@ -43,29 +43,34 @@ } ); // vars - var $div = this.$control(); - var $node = this.$node(); + var el = this.$el[ 0 ]; + var div = this.$control()[ 0 ]; + var node = this.$node()[ 0 ]; // remove class - $div.removeClass( '-value -external' ); + div.classList.remove( '-value', '-external' ); // add class - if ( val.url ) $div.addClass( '-value' ); - if ( val.target === '_blank' ) $div.addClass( '-external' ); + if ( val.url ) div.classList.add( '-value' ); + if ( val.target === '_blank' ) div.classList.add( '-external' ); // update text - this.$( '.link-title' ).html( val.title ); - this.$( '.link-url' ).attr( 'href', val.url ).text( val.url ); + el.querySelector( '.link-title' ).innerHTML = val.title; + var linkUrl = el.querySelector( '.link-url' ); + linkUrl.setAttribute( 'href', val.url ); + linkUrl.textContent = val.url; // update node - $node.html( val.title ); - $node.attr( 'href', val.url ); - $node.attr( 'target', val.target ); + node.innerHTML = val.title; + node.setAttribute( 'href', val.url ); + node.setAttribute( 'target', val.target ); // update inputs - this.$( '.input-title' ).val( val.title ); - this.$( '.input-target' ).val( val.target ); - this.$( '.input-url' ).val( val.url ).trigger( 'change' ); + el.querySelector( '.input-title' ).value = val.title; + el.querySelector( '.input-target' ).value = val.target; + var inputUrl = el.querySelector( '.input-url' ); + inputUrl.value = val.url; + inputUrl.dispatchEvent( new Event( 'change', { bubbles: true } ) ); }, onClickEdit: function ( e, $el ) { diff --git a/assets/src/js/_acf-field-oembed.js b/assets/src/js/_acf-field-oembed.js index 83c3247b..25ddc589 100644 --- a/assets/src/js/_acf-field-oembed.js +++ b/assets/src/js/_acf-field-oembed.js @@ -22,28 +22,29 @@ }, getValue: function () { - return this.$input().val(); + return this.$input()[ 0 ].value; }, getSearchVal: function () { - return this.$search().val(); + return this.$search()[ 0 ].value; }, setValue: function ( val ) { // class + var control = this.$control()[ 0 ]; if ( val ) { - this.$control().addClass( 'has-value' ); + control.classList.add( 'has-value' ); } else { - this.$control().removeClass( 'has-value' ); + control.classList.remove( 'has-value' ); } acf.val( this.$input(), val ); - this.$search().val( val || '' ); + this.$search()[ 0 ].value = val || ''; if ( val ) { this.maybeSearch(); } else { - this.$( '.canvas-media' ).html( '' ); + this.$el[ 0 ].querySelector( '.canvas-media' ).innerHTML = ''; } }, @@ -80,7 +81,7 @@ } // set new timeout - var callback = $.proxy( this.search, this, url ); + var callback = this.search.bind( this, url ); this.set( 'timeout', setTimeout( callback, 300 ) ); }, @@ -119,6 +120,9 @@ // update vars this.val( json.url ); + + // jQuery .html() is kept: oEmbed provider markup may + // contain ' ); + + expect( notice.querySelector( 'script' ) ).toBeNull(); + expect( notice.querySelector( 'p' ).textContent ).toBe( + '' + ); + } ); +} ); + +describe( 'getNativeErrors', () => { + it( 'collects required-field violations with the native message', () => { + const form = buildForm(); + + const errors = getNativeErrors( form ); + + expect( errors ).toHaveLength( 1 ); + expect( errors[ 0 ].input ).toBe( 'acf[field_req_text]' ); + expect( typeof errors[ 0 ].message ).toBe( 'string' ); + expect( errors[ 0 ].message.length ).toBeGreaterThan( 0 ); + } ); + + it( 'returns no errors when constraints are satisfied', () => { + const form = buildForm(); + form.querySelector( '[name="acf[field_req_text]"]' ).value = 'Filled'; + + expect( getNativeErrors( form ) ).toHaveLength( 0 ); + } ); + + it( 'skips disabled controls', () => { + const form = buildForm(); + form.querySelector( '[name="acf[field_req_text]"]' ).disabled = true; + + expect( getNativeErrors( form ) ).toHaveLength( 0 ); + } ); + + it( 'reports each control name once', () => { + const form = buildForm(); + const wrap = form.querySelector( + '.acf-field[data-name="required_text"] .acf-input' + ); + const clone = wrap.querySelector( 'input' ).cloneNode(); + wrap.appendChild( clone ); + + const errors = getNativeErrors( form ); + + expect( errors ).toHaveLength( 1 ); + } ); +} ); + +describe( 'findFieldByInputName', () => { + it( 'finds a field by exact input name', () => { + const form = buildForm(); + + const field = findFieldByInputName( form, 'acf[field_req_text]' ); + + expect( field ).not.toBeNull(); + expect( field.dataset.name ).toBe( 'required_text' ); + } ); + + it( 'falls back to a prefix match for array-style names', () => { + const form = buildForm(); + + const field = findFieldByInputName( form, 'acf[field_checks]' ); + + expect( field ).not.toBeNull(); + expect( field.dataset.name ).toBe( 'checks' ); + } ); + + it( 'returns null for unknown names', () => { + const form = buildForm(); + + expect( findFieldByInputName( form, 'acf[missing]' ) ).toBeNull(); + } ); +} ); + +describe( 'showFieldError / clearFieldError', () => { + it( 'adds the acf-error class and prepends a notice to .acf-input', () => { + const form = buildForm(); + + const field = showFieldError( form, { + input: 'acf[field_req_text]', + message: 'Required Text value is required', + } ); + + expect( field.classList.contains( 'acf-error' ) ).toBe( true ); + const notice = field.querySelector( '.acf-input' ).firstElementChild; + expect( notice.className ).toBe( + 'acf-notice -error acf-error-message' + ); + expect( notice.textContent ).toBe( 'Required Text value is required' ); + } ); + + it( 'replaces an existing notice instead of stacking them', () => { + const form = buildForm(); + + showFieldError( form, { + input: 'acf[field_req_text]', + message: 'First', + } ); + showFieldError( form, { + input: 'acf[field_req_text]', + message: 'Second', + } ); + + const notices = form.querySelectorAll( + '.acf-field[data-name="required_text"] .acf-notice' + ); + expect( notices ).toHaveLength( 1 ); + expect( notices[ 0 ].textContent ).toBe( 'Second' ); + } ); + + it( 'clearFieldError removes the class and the notice', () => { + const form = buildForm(); + const field = showFieldError( form, { + input: 'acf[field_req_text]', + message: 'Required', + } ); + + clearFieldError( field ); + + expect( field.classList.contains( 'acf-error' ) ).toBe( false ); + expect( field.querySelector( '.acf-notice' ) ).toBeNull(); + } ); +} ); + +describe( 'buildSummaryMessage', () => { + it( 'mirrors the classic single-field wording', () => { + expect( buildSummaryMessage( [], 1, I18N ) ).toBe( + 'Validation failed. 1 field requires attention' + ); + } ); + + it( 'mirrors the classic multi-field wording', () => { + expect( buildSummaryMessage( [], 3, I18N ) ).toBe( + 'Validation failed. 3 fields require attention' + ); + } ); + + it( 'appends global error messages', () => { + expect( + buildSummaryMessage( + [ { input: false, message: 'Spam Detected' } ], + 0, + I18N + ) + ).toBe( 'Validation failed. Spam Detected' ); + } ); +} ); + +describe( 'showErrors / clearAllErrors', () => { + it( 'renders field errors plus a form-level summary', () => { + const form = buildForm(); + + showErrors( + form, + [ + { + input: 'acf[field_req_text]', + message: 'Required Text value is required', + }, + ], + I18N + ); + + expect( form.querySelectorAll( '.acf-field.acf-error' ) ).toHaveLength( + 1 + ); + const summary = form.firstElementChild; + expect( summary.className ).toBe( + 'acf-notice -error acf-error-message' + ); + expect( summary.textContent ).toBe( + 'Validation failed. 1 field requires attention' + ); + } ); + + it( 'deduplicates errors for the same input, keeping the last', () => { + const form = buildForm(); + + showErrors( + form, + [ + { input: 'acf[field_req_text]', message: 'First' }, + { input: 'acf[field_req_text]', message: 'Second' }, + ], + I18N + ); + + const notices = form.querySelectorAll( + '.acf-field .acf-notice.acf-error-message' + ); + expect( notices ).toHaveLength( 1 ); + expect( notices[ 0 ].textContent ).toBe( 'Second' ); + } ); + + it( 'counts only errors whose input could be located', () => { + const form = buildForm(); + + showErrors( + form, + [ + { input: 'acf[field_req_text]', message: 'Required' }, + { input: 'acf[field_missing]', message: 'Ghost' }, + ], + I18N + ); + + expect( form.firstElementChild.textContent ).toBe( + 'Validation failed. 1 field requires attention' + ); + } ); + + it( 'clearAllErrors removes field errors and the summary', () => { + const form = buildForm(); + showErrors( + form, + [ { input: 'acf[field_req_text]', message: 'Required' } ], + I18N + ); + + clearAllErrors( form ); + + expect( form.querySelector( '.acf-error' ) ).toBeNull(); + expect( form.querySelector( '.acf-notice' ) ).toBeNull(); + } ); +} ); + +describe( 'lockForm / unlockForm', () => { + it( 'lockForm disables the submit button and activates the spinner', () => { + const form = buildForm(); + + lockForm( form ); + + const submit = form.querySelector( '[type="submit"]' ); + const spinner = form.querySelector( '.acf-spinner' ); + expect( submit.classList.contains( 'disabled' ) ).toBe( true ); + expect( submit.hasAttribute( 'disabled' ) ).toBe( true ); + expect( spinner.classList.contains( 'is-active' ) ).toBe( true ); + expect( spinner.style.display ).toBe( 'inline-block' ); + } ); + + it( 'unlockForm re-enables the submit button and hides the spinner', () => { + const form = buildForm(); + lockForm( form ); + + unlockForm( form ); + + const submit = form.querySelector( '[type="submit"]' ); + const spinner = form.querySelector( '.acf-spinner' ); + expect( submit.classList.contains( 'disabled' ) ).toBe( false ); + expect( submit.hasAttribute( 'disabled' ) ).toBe( false ); + expect( spinner.classList.contains( 'is-active' ) ).toBe( false ); + expect( spinner.style.display ).toBe( 'none' ); + } ); +} ); diff --git a/tests/js/unload.test.js b/tests/js/unload.test.js index cb591fff..ffc86fdd 100644 --- a/tests/js/unload.test.js +++ b/tests/js/unload.test.js @@ -13,7 +13,11 @@ const { createJQueryStub } = require( './mocks/acf-jquery' ); describe( 'SCF Unload Warning', () => { let acf; - let $window; + let addListenerSpy; + let removeListenerSpy; + + const beforeUnloadCalls = ( spy ) => + spy.mock.calls.filter( ( call ) => call[ 0 ] === 'beforeunload' ); beforeEach( () => { global.jQuery = createJQueryStub(); @@ -28,15 +32,16 @@ describe( 'SCF Unload Warning', () => { } ); acf = window.acf; - $window = global.jQuery( window ); // The model waits for the 'load' action before initializing. acf.doAction( 'load' ); - $window.on.mockClear(); - $window.off.mockClear(); + addListenerSpy = jest.spyOn( window, 'addEventListener' ); + removeListenerSpy = jest.spyOn( window, 'removeEventListener' ); } ); afterEach( () => { + window.removeEventListener( 'beforeunload', acf.unload.onUnload ); + jest.restoreAllMocks(); delete window.acf; delete window.acfL10n; } ); @@ -66,6 +71,8 @@ describe( 'SCF Unload Warning', () => { lateAcf.doAction( 'validation_failure' ); expect( lateAcf.unload.changed ).toBe( true ); + + window.removeEventListener( 'beforeunload', lateAcf.unload.onUnload ); } ); describe( 'startListening()', () => { @@ -73,7 +80,7 @@ describe( 'SCF Unload Warning', () => { acf.unload.startListening(); expect( acf.unload.changed ).toBe( true ); - expect( $window.on ).toHaveBeenCalledWith( + expect( addListenerSpy ).toHaveBeenCalledWith( 'beforeunload', acf.unload.onUnload ); @@ -83,7 +90,7 @@ describe( 'SCF Unload Warning', () => { acf.unload.startListening(); acf.unload.startListening(); - expect( $window.on ).toHaveBeenCalledTimes( 1 ); + expect( beforeUnloadCalls( addListenerSpy ) ).toHaveLength( 1 ); } ); it( 'should do nothing while disabled', () => { @@ -91,7 +98,7 @@ describe( 'SCF Unload Warning', () => { acf.unload.startListening(); expect( acf.unload.changed ).toBe( false ); - expect( $window.on ).not.toHaveBeenCalled(); + expect( beforeUnloadCalls( addListenerSpy ) ).toHaveLength( 0 ); acf.unload.enable(); acf.unload.startListening(); @@ -106,7 +113,7 @@ describe( 'SCF Unload Warning', () => { acf.unload.stopListening(); expect( acf.unload.changed ).toBe( false ); - expect( $window.off ).toHaveBeenCalledWith( + expect( removeListenerSpy ).toHaveBeenCalledWith( 'beforeunload', acf.unload.onUnload ); @@ -117,7 +124,7 @@ describe( 'SCF Unload Warning', () => { acf.unload.reset(); expect( acf.unload.changed ).toBe( false ); - expect( $window.off ).toHaveBeenCalled(); + expect( beforeUnloadCalls( removeListenerSpy ) ).toHaveLength( 1 ); } ); } ); diff --git a/tests/php/includes/forms/test-form-front-view.php b/tests/php/includes/forms/test-form-front-view.php new file mode 100644 index 00000000..92e1c4b4 --- /dev/null +++ b/tests/php/includes/forms/test-form-front-view.php @@ -0,0 +1,293 @@ +assertTrue( function_exists( 'scf_frontend_form_interactivity_enabled' ) ); + $this->assertTrue( function_exists( 'scf_get_frontend_form_view_field_types' ) ); + $this->assertTrue( function_exists( 'scf_frontend_form_fields_are_view_compatible' ) ); + $this->assertTrue( function_exists( 'scf_frontend_form_view_attributes' ) ); + $this->assertTrue( function_exists( 'scf_enqueue_frontend_form_view' ) ); + } + + /** + * Test the setting defaults to false so behavior is unchanged out of the box. + */ + public function test_setting_defaults_to_disabled() { + $this->assertFalse( acf_get_setting( 'frontend_interactivity_form' ) ); + $this->assertFalse( scf_frontend_form_interactivity_enabled() ); + } + + /** + * Test enabling the setting via acf_update_setting(). + */ + public function test_setting_enabled_via_update_setting() { + acf_update_setting( 'frontend_interactivity_form', true ); + + $this->assertTrue( scf_frontend_form_interactivity_enabled() ); + } + + /** + * Test enabling the setting via the acf/settings filter. + */ + public function test_setting_enabled_via_filter() { + add_filter( 'acf/settings/frontend_interactivity_form', '__return_true' ); + + $this->assertTrue( scf_frontend_form_interactivity_enabled() ); + + remove_filter( 'acf/settings/frontend_interactivity_form', '__return_true' ); + } + + /** + * Test the simple field type allow-list contents. + */ + public function test_view_field_types_allow_list() { + $types = scf_get_frontend_form_view_field_types(); + + $expected = array( 'text', 'textarea', 'number', 'email', 'url', 'password', 'range', 'select', 'checkbox', 'radio', 'button_group', 'true_false' ); + foreach ( $expected as $type ) { + $this->assertContains( $type, $types, "Allow-list should contain {$type}" ); + } + + $complex = array( 'repeater', 'flexible_content', 'gallery', 'image', 'file', 'wysiwyg', 'date_picker', 'date_time_picker', 'time_picker', 'color_picker', 'google_map', 'relationship', 'post_object', 'page_link', 'user', 'taxonomy', 'oembed', 'link', 'clone', 'group', 'icon_picker', 'nav_menu' ); + foreach ( $complex as $type ) { + $this->assertNotContains( $type, $types, "Allow-list should not contain {$type}" ); + } + } + + /** + * Test fields compatibility check with only simple fields. + */ + public function test_simple_fields_are_view_compatible() { + $fields = array( + array( 'type' => 'text' ), + array( 'type' => 'email' ), + array( + 'type' => 'select', + 'ui' => 0, + 'ajax' => 0, + ), + array( 'type' => 'true_false' ), + ); + + $this->assertTrue( scf_frontend_form_fields_are_view_compatible( $fields ) ); + } + + /** + * Test an empty field list is compatible. + */ + public function test_empty_fields_are_view_compatible() { + $this->assertTrue( scf_frontend_form_fields_are_view_compatible( array() ) ); + } + + /** + * Test a complex field forces the classic fallback. + */ + public function test_complex_field_is_not_view_compatible() { + $fields = array( + array( 'type' => 'text' ), + array( 'type' => 'date_picker' ), + ); + + $this->assertFalse( scf_frontend_form_fields_are_view_compatible( $fields ) ); + } + + /** + * Test a Select2-backed select forces the classic fallback. + */ + public function test_select_with_ui_is_not_view_compatible() { + $this->assertFalse( + scf_frontend_form_fields_are_view_compatible( + array( + array( + 'type' => 'select', + 'ui' => 1, + ), + ) + ) + ); + + $this->assertFalse( + scf_frontend_form_fields_are_view_compatible( + array( + array( + 'type' => 'select', + 'ui' => 0, + 'ajax' => 1, + ), + ) + ) + ); + } + + /** + * Test a field without a type is treated as incompatible. + */ + public function test_field_without_type_is_not_view_compatible() { + $this->assertFalse( scf_frontend_form_fields_are_view_compatible( array( array( 'name' => 'mystery' ) ) ) ); + } + + /** + * Test the view attributes add the Interactivity API directives. + */ + public function test_view_attributes_add_directives() { + $attributes = scf_frontend_form_view_attributes( + array( + 'id' => 'acf-form', + 'class' => 'acf-form', + ) + ); + + $this->assertSame( 'scf/form', $attributes['data-wp-interactive'] ); + $this->assertSame( 'actions.handleSubmit', $attributes['data-wp-on--submit'] ); + $this->assertSame( 'actions.clearError', $attributes['data-wp-on--input'] ); + $this->assertSame( 'actions.clearError', $attributes['data-wp-on--change'] ); + $this->assertSame( 'novalidate', $attributes['novalidate'] ); + + // Existing attributes are preserved. + $this->assertSame( 'acf-form', $attributes['id'] ); + $this->assertSame( 'acf-form', $attributes['class'] ); + } + + /** + * Test render_form output contains the directives when the setting is + * enabled and every field is simple. + */ + public function test_render_form_adds_directives_when_enabled_and_simple() { + acf_update_setting( 'frontend_interactivity_form', true ); + + acf_add_local_field_group( + array( + 'key' => 'group_scf_ffv_simple', + 'title' => 'View Bundle Simple', + 'fields' => array( + array( + 'key' => 'field_scf_ffv_text', + 'name' => 'ffv_text', + 'label' => 'Text', + 'type' => 'text', + 'required' => 1, + ), + ), + 'location' => array(), + ) + ); + + $form_front = new acf_form_front(); + + ob_start(); + $form_front->render_form( + array( + 'id' => 'scf-ffv-simple', + 'post_id' => 123, + 'field_groups' => array( 'group_scf_ffv_simple' ), + ) + ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'data-wp-interactive="scf/form"', $output ); + $this->assertStringContainsString( 'data-wp-on--submit="actions.handleSubmit"', $output ); + $this->assertStringContainsString( 'novalidate', $output ); + } + + /** + * Test render_form output omits the directives for a complex field, falling + * back to the classic stack. + */ + public function test_render_form_omits_directives_for_complex_fields() { + acf_update_setting( 'frontend_interactivity_form', true ); + + acf_add_local_field_group( + array( + 'key' => 'group_scf_ffv_complex', + 'title' => 'View Bundle Complex', + 'fields' => array( + array( + 'key' => 'field_scf_ffv_date', + 'name' => 'ffv_date', + 'label' => 'Date', + 'type' => 'date_picker', + ), + ), + 'location' => array(), + ) + ); + + $form_front = new acf_form_front(); + + ob_start(); + $form_front->render_form( + array( + 'id' => 'scf-ffv-complex', + 'post_id' => 123, + 'field_groups' => array( 'group_scf_ffv_complex' ), + ) + ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'data-wp-interactive', $output ); + } + + /** + * Test render_form output is unchanged when the setting is disabled. + */ + public function test_render_form_unchanged_when_disabled() { + acf_add_local_field_group( + array( + 'key' => 'group_scf_ffv_default', + 'title' => 'View Bundle Default', + 'fields' => array( + array( + 'key' => 'field_scf_ffv_default_text', + 'name' => 'ffv_default_text', + 'label' => 'Text', + 'type' => 'text', + ), + ), + 'location' => array(), + ) + ); + + $form_front = new acf_form_front(); + + ob_start(); + $form_front->render_form( + array( + 'id' => 'scf-ffv-default', + 'post_id' => 123, + 'field_groups' => array( 'group_scf_ffv_default' ), + ) + ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'data-wp-interactive', $output ); + $this->assertStringNotContainsString( 'novalidate', $output ); + } +} diff --git a/webpack.config.js b/webpack.config.js index d40fd12c..44d293dc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -134,5 +134,74 @@ const minifiedConfig = { ], }; -// Export both configurations -module.exports = [ unminifiedConfig, minifiedConfig ]; +// Script-module build (Interactivity API view bundles). Modules are built in +// a separate webpack config because they require ESM output +// (`experiments.outputModule`), which cannot be mixed with the classic +// script entries above. @wordpress/interactivity is externalized to a module +// import by DependencyExtractionWebpackPlugin and listed in the generated +// .asset.php file for wp_register_script_module(). +const moduleCommonConfig = { + entry: { + 'js/frontend/scf-form-view': './assets/src/js/frontend/scf-form-view.js', + }, + output: { + path: path.resolve( __dirname, 'assets/build/' ), + module: true, + chunkFormat: 'module', + library: { type: 'module' }, + environment: { module: true }, + }, + experiments: { + outputModule: true, + }, + module: { + rules: [ commonConfig.module.rules[ 0 ] ], + }, +}; + +// Unminified module build +const moduleUnminifiedConfig = { + ...moduleCommonConfig, + mode: 'development', + output: { + ...moduleCommonConfig.output, + filename: '[name].js', + }, + devtool: 'source-map', + optimization: { + minimize: false, + }, + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; + +// Minified module build +const moduleMinifiedConfig = { + ...moduleCommonConfig, + mode: 'production', + output: { + ...moduleCommonConfig.output, + filename: '[name].min.js', + }, + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin( { + terserOptions: { + format: { + comments: false, // Remove comments + }, + }, + extractComments: false, + } ), + ], + }, + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; + +// Export all configurations +module.exports = [ + unminifiedConfig, + minifiedConfig, + moduleUnminifiedConfig, + moduleMinifiedConfig, +];