/** * Fields admin page - section visibility toggling and client-side validation. * * @package ContactForms * @since 2.2.5 */ /* global accuaFieldsPage */ jQuery( function( $ ) { var typeSelect = $( '#parent' ); var l10n = accuaFieldsPage.l10n; // Types shipped with the plugin. Anything else was registered by an // extension plugin through the accua_forms_field_types filter and gets the // generic sections the form editor offers for custom types (default value // and custom required message). var builtinTypes = [ 'textfield', 'textarea', 'email', 'autoreply_email', 'telephone', 'checkbox', 'select', 'radio', 'multiselect', 'multicheckbox', 'post-select', 'post-multicheckbox', 'colorpicker', 'hidden', 'file', 'submit', 'html', 'captcha', 'captcha_v3', 'cap', 'turnstile', 'password', 'password-and-confirm', 'date' ]; // Built-in field types that support each section. For 'html' the default // value holds the HTML content rendered in the form (labels swapped below). var hasDefaultValue = [ 'textfield', 'textarea', 'email', 'autoreply_email', 'telephone', 'checkbox', 'select', 'radio', 'multiselect', 'multicheckbox', 'post-select', 'post-multicheckbox', 'colorpicker', 'hidden', 'html', 'password', 'password-and-confirm' ]; var hasAllowedValues = [ 'select', 'radio', 'multiselect', 'multicheckbox', 'post-select', 'post-multicheckbox', 'file' ]; var hasDate = [ 'date' ]; var hasCustomRequired = [ 'textfield', 'textarea', 'email', 'autoreply_email', 'telephone', 'checkbox', 'select', 'radio', 'multiselect', 'multicheckbox', 'post-select', 'post-multicheckbox', 'colorpicker', 'file', 'password', 'password-and-confirm', 'date' ]; var hasCustomFormat = [ 'email', 'autoreply_email', 'telephone' ]; // Types that never store a submission value: no submissions-list column // can exist for them, so the essential-column option is meaningless there. var hasNoSubmissionColumn = [ 'submit', 'html', 'captcha', 'captcha_v3', 'cap', 'turnstile' ]; function updateVisibility() { var type = typeSelect.val(); var isBuiltin = builtinTypes.indexOf( type ) !== -1; // The description is definition metadata shown in the fields list for // every type, so it is always left visible/editable. $( '#field-section-default-value' ).toggle( ! isBuiltin || hasDefaultValue.indexOf( type ) !== -1 ); $( '#field-section-allowed-values' ).toggle( hasAllowedValues.indexOf( type ) !== -1 ); $( '#field-section-date' ).toggle( hasDate.indexOf( type ) !== -1 ); $( '#field-section-custom-required' ).toggle( ! isBuiltin || hasCustomRequired.indexOf( type ) !== -1 ); $( '#field-section-custom-format' ).toggle( hasCustomFormat.indexOf( type ) !== -1 ); $( '#field-section-essential-column' ).toggle( ! isBuiltin || hasNoSubmissionColumn.indexOf( type ) === -1 ); // The allowed-values textarea doubles as extensions list for file // fields and query parameters for post fields - same keys, same // wording as the form editor widgets. if ( type === 'file' ) { $( '#allowed-values-label' ).text( l10n.allowedExtensionsLabel ); $( '#allowed-values-help' ).text( l10n.allowedExtensionsHelp ); } else if ( type === 'post-select' || type === 'post-multicheckbox' ) { $( '#allowed-values-label' ).text( l10n.queryParamsLabel ); $( '#allowed-values-help' ).text( l10n.queryParamsHelp ); } else { $( '#allowed-values-label' ).text( l10n.allowedValuesLabel ); $( '#allowed-values-help' ).text( l10n.allowedValuesHelp ); } // The default-value textarea holds the HTML content for html fields if ( type === 'html' ) { $( '#default-value-label' ).text( l10n.customHtmlLabel ); $( '#default-value-help' ).text( l10n.customHtmlHelp ); } else { $( '#default-value-label' ).text( l10n.defaultValueLabel ); $( '#default-value-help' ).text( l10n.defaultValueHelp ); } } typeSelect.on( 'change', updateVisibility ); updateVisibility(); // Client-side validation var typesNeedingAllowedValues = [ 'select', 'radio', 'multiselect', 'multicheckbox' ]; $( '#addtag' ).on( 'submit', function( e ) { var valid = true; var errors = []; // Remove previous error highlights and messages $( this ).find( '.form-invalid' ).removeClass( 'form-invalid' ); $( this ).prev( '.field-error-message' ).remove(); // Validate label var $label = $( '#tag-name' ); if ( $.trim( $label.val() ) === '' ) { $label.closest( '.form-field' ).addClass( 'form-invalid' ); errors.push( l10n.errorLabelRequired ); valid = false; } // Validate slug (only when adding) var $slug = $( '#tag-slug' ); if ( ! $slug.prop( 'disabled' ) ) { var slugValue = $.trim( $slug.val() ); if ( slugValue === '' ) { $slug.closest( '.form-field' ).addClass( 'form-invalid' ); errors.push( l10n.errorSlugRequired ); valid = false; } else { var liveSlugError = slugError( slugValue ); if ( liveSlugError !== '' ) { $slug.closest( '.form-field' ).addClass( 'form-invalid' ); errors.push( liveSlugError ); valid = false; } } } // Validate allowed values (when visible and required) var type = typeSelect.val(); if ( typesNeedingAllowedValues.indexOf( type ) !== -1 ) { var $allowed = $( '#form-field-allowed-values' ); if ( $.trim( $allowed.val() ) === '' ) { $allowed.closest( '.form-field' ).addClass( 'form-invalid' ); errors.push( l10n.errorAllowedValuesRequired ); valid = false; } } if ( ! valid ) { e.preventDefault(); // Show error message var errorHtml = '
'; $( this ).before( errorHtml ); // Announce for screen readers if ( window.wp && wp.a11y && wp.a11y.speak ) { wp.a11y.speak( errors.join( '. ' ), 'assertive' ); } // Focus first invalid field var $firstInvalid = $( this ).find( '.form-invalid:first' ); if ( $firstInvalid.length ) { $firstInvalid.find( 'input, textarea, select' ).first().trigger( 'focus' ); } } } ); // Remove error highlight on input $( '#addtag' ).on( 'input change', 'input, textarea, select', function() { $( this ).closest( '.form-field' ).removeClass( 'form-invalid' ); } ); // ------------------------------------------------------------------ // Slug suggestion: while adding, the slug follows the label until the // user edits the slug manually (same idea as the core permalink slug). // ------------------------------------------------------------------ var $slugInput = $( '#tag-slug' ); var slugTouched = $slugInput.prop( 'disabled' ) || $.trim( $slugInput.val() ) !== ''; $slugInput.on( 'input', function() { // New slugs are lowercase (the server lowercases them on save, like // core does with term slugs), so show what will actually be stored // instead of letting the input disagree with the result. var typed = $( this ).val(); if ( typed !== typed.toLowerCase() ) { var caret = this.selectionStart; $( this ).val( typed.toLowerCase() ); if ( caret !== null && typeof this.setSelectionRange === 'function' ) { this.setSelectionRange( caret, caret ); } } // A manual edit stops the suggestion, but emptying the slug // re-enables it: an empty slug always follows the label again. slugTouched = $.trim( $( this ).val() ) !== ''; } ); $( '#tag-name' ).on( 'input', function() { if ( slugTouched ) { return; } var slug = $( this ).val().toLowerCase() .normalize( 'NFD' ).replace( /[\u0300-\u036f]/g, '' ) // fold accents: "à" -> "a" .replace( /[^a-z0-9_-]+/g, '-' ) .replace( /^-+|-+$/g, '' ) .substring( 0, 70 ); $slugInput.val( slug ); validateSlugField(); // a suggested slug can collide with an existing field } ); // ------------------------------------------------------------------ // Inline slug validation while adding: mirrors the server-side rules // and also flags a slug already in use, before anything is submitted. // The server-side validation on save stays authoritative. // ------------------------------------------------------------------ var existingSlugs = accuaFieldsPage.existingSlugs || []; var $slugFeedback = $( '#slug-feedback' ); function slugError( slug ) { if ( slug === '' ) { return ''; // emptiness is reported on submit, not while typing } if ( ! /^[a-z0-9_-]+$/i.test( slug ) ) { return l10n.slugInvalidChars; } if ( slug.indexOf( '__' ) === 0 ) { return l10n.slugDoubleUnderscore; } if ( slug.length > 70 ) { return l10n.slugTooLong; } if ( existingSlugs.indexOf( slug ) !== -1 ) { return l10n.slugExists; } return ''; } function validateSlugField() { if ( $slugInput.prop( 'disabled' ) || ! $slugFeedback.length ) { return true; } var err = slugError( $.trim( $slugInput.val() ) ); $slugInput.closest( '.form-field' ).toggleClass( 'form-invalid', err !== '' ); $slugFeedback.text( err ).prop( 'hidden', err === '' ); return err === ''; } // Delegated on purpose: the generic error-highlight removal above is // delegated too and bound first, so this re-applies the highlight after // it runs for the same input event. $( '#addtag' ).on( 'input', '#tag-slug', validateSlugField ); // ------------------------------------------------------------------ // Delete confirmation // ------------------------------------------------------------------ $( '#delete-field' ).on( 'click', function( e ) { var slug = $( '#addtag input[name="form-field-id"]' ).val() || ''; if ( ! window.confirm( l10n.confirmDelete.replace( '%s', slug ) ) ) { e.preventDefault(); } } ); // ------------------------------------------------------------------ // Live preview: POSTs the current (unsaved) settings to the // accua_forms_field_preview AJAX action and writes the returned document // into the preview iframe (document.write instead of a real navigation: // no browser history entries, frontend CSS + JS still execute). Text // input is debounced; selects and date pickers refresh immediately. // ------------------------------------------------------------------ var $previewFrame = $( '#accua-field-preview' ); var $previewWrapper = $( '#accua-field-preview-wrapper' ); var $previewPanel = $( '#accua-field-preview-panel' ); var previewTimer = null; var previewXhr = null; var previewSettleTimers = []; function clearPreviewSettleTimers() { previewSettleTimers.forEach( clearTimeout ); previewSettleTimers = []; } function previewPayload() { var payload = [ { name: 'action', value: 'accua_forms_field_preview' }, { name: '_wpnonce', value: accuaFieldsPage.previewNonce } ]; $( '#addtag' ).serializeArray().forEach( function( pair ) { // The editor form's own action and nonces must not leak into the // preview request. if ( pair.name === 'action' || pair.name.indexOf( '_wpnonce' ) === 0 || pair.name === '_wp_http_referer' ) { return; } payload.push( pair ); } ); return payload; } function resizePreview() { try { var doc = $previewFrame[ 0 ].contentDocument; if ( doc && doc.body ) { // Neither documentElement.scrollHeight nor the body box can be // trusted: both are stretched to the iframe's own height (the // frontend styles give body 100% height), so the preview could // grow but never shrink back to fit a small field. Measure the // actual content instead - the lowest bottom edge among the // body's children. The +16 leaves room for focus outlines. var win = doc.defaultView; var bottom = 0; Array.prototype.forEach.call( doc.body.children, function ( el ) { var rect = el.getBoundingClientRect(); if ( rect.bottom > bottom ) { bottom = rect.bottom; } } ); bottom += win.pageYOffset || 0; var needed = Math.ceil( bottom ) + 16; var height = Math.min( 700, Math.max( 80, needed ) ); $previewFrame.css( 'height', height + 'px' ); // The document is written with overflow hidden so no scrollbar // flashes while the frame animates to this height; scrolling is // only given back when the content exceeds the height cap. doc.documentElement.style.overflowY = needed > 700 ? 'auto' : ''; } } catch ( e ) { // Cross-origin (should not happen) - keep the CSS height. } } var previewObserver = null; // Follow every later content change (inline validation errors appearing on // blur, async widgets settling) so the frame always fits its content. The // body itself is stretched to 100% height, so observe its children (the // form, the response-messages div, the empty-preview note). function observePreviewBody() { try { var frameWin = $previewFrame[ 0 ].contentWindow; if ( previewObserver ) { previewObserver.disconnect(); previewObserver = null; } if ( frameWin && frameWin.ResizeObserver && frameWin.document.body ) { previewObserver = new frameWin.ResizeObserver( resizePreview ); Array.prototype.forEach.call( frameWin.document.body.children, function ( el ) { previewObserver.observe( el ); } ); } } catch ( e ) { // No observer - the timed re-measures below still apply. } } function refreshPreview() { if ( ! $previewFrame.length ) { return; } // Nothing meaningful to preview until the field has a label. if ( $.trim( $( '#tag-name' ).val() ) === '' ) { $previewPanel.hide(); return; } $previewPanel.show(); $previewWrapper.addClass( 'accua-form-preview-loading' ); if ( previewXhr ) { previewXhr.abort(); } // A superseded refresh must not resize against the document this one // is about to replace. clearPreviewSettleTimers(); previewXhr = $.post( accuaFieldsPage.ajaxUrl, $.param( previewPayload() ) ) .done( function( html ) { var doc = $previewFrame[ 0 ].contentDocument; // The old document's observed nodes are about to be detached - // their final resize must not measure the new, not-yet-styled // content. if ( previewObserver ) { previewObserver.disconnect(); previewObserver = null; } // Measuring right after document.write would size the still // unstyled content (the frontend stylesheets are only loading), // grow the frame, then shrink it back once the CSS applies - a // visible jump. Keep the previous height and the loading // overlay until the written document (stylesheets included) // has loaded, then resize once to the final height. The timer // is a safety net in case a subresource hangs and the frame's // load event never fires. var settled = false; var settle = function() { if ( settled ) { return; } settled = true; observePreviewBody(); resizePreview(); $previewWrapper.removeClass( 'accua-form-preview-loading' ); // Async widgets (post-select options, captcha iframes) // settle late - fallback when ResizeObserver is // unavailable (it re-fires on those changes anyway). previewSettleTimers.push( setTimeout( resizePreview, 600 ) ); previewSettleTimers.push( setTimeout( resizePreview, 1500 ) ); }; // Bound on the frame element (not the frame window, which // document.open() resets) and before doc.close(), so the load // event cannot be missed; re-bound on every refresh. $previewFrame.off( 'load' ).one( 'load', settle ); doc.open(); doc.write( html ); doc.close(); previewSettleTimers.push( setTimeout( settle, 1500 ) ); } ) .always( function( dataOrXhr, status ) { if ( status !== 'abort' ) { previewXhr = null; if ( status !== 'success' ) { $previewWrapper.removeClass( 'accua-form-preview-loading' ); } } } ); } function schedulePreviewRefresh() { clearTimeout( previewTimer ); previewTimer = setTimeout( refreshPreview, 700 ); } if ( $previewFrame.length ) { $( '#addtag' ).on( 'input', 'input, textarea', schedulePreviewRefresh ); $( '#addtag' ).on( 'change', 'select, input[type="date"]', function() { clearTimeout( previewTimer ); refreshPreview(); } ); // Once the editor form is submitted the page is about to navigate: // a debounced refresh firing in that window would be wasted work // racing the unload (an AJAX render plus a document.write into the // iframe of a disappearing page). If the client-side validation // blocks the submit instead, the next input event simply schedules // a fresh refresh. $( '#addtag' ).on( 'submit', function() { clearTimeout( previewTimer ); clearPreviewSettleTimers(); if ( previewXhr ) { previewXhr.abort(); previewXhr = null; } } ); // Hide the panel as soon as the label is cleared (showing again is // handled by the debounced refresh). $( '#tag-name' ).on( 'input', function() { if ( $.trim( $( this ).val() ) === '' ) { clearTimeout( previewTimer ); $previewPanel.hide(); } } ); refreshPreview(); } } );