PluginProbe
Contact Forms by Cimatti / trunk
Contact Forms by Cimatti vtrunk
2.3.6 2.3.5 2.3.0 2.2.32 2.2.4 2.2.0 2.1.2 2.1.1 trunk 1.0 1.1 1.2 1.2.1 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 All 62 releases
contact-forms / assets / js / admin / fields-page.js

fields-page.js in Contact Forms by Cimatti trunk, at assets/js/admin/fields-page.js

421 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Fields admin page - section visibility toggling and client-side validation.
3 *
4 * @package ContactForms
5 * @since 2.2.5
6 */
7 /* global accuaFieldsPage */
8 jQuery( function( $ ) {
9 var typeSelect = $( '#parent' );
10 var l10n = accuaFieldsPage.l10n;
11
12 // Types shipped with the plugin. Anything else was registered by an
13 // extension plugin through the accua_forms_field_types filter and gets the
14 // generic sections the form editor offers for custom types (default value
15 // and custom required message).
16 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' ];
17
18 // Built-in field types that support each section. For 'html' the default
19 // value holds the HTML content rendered in the form (labels swapped below).
20 var hasDefaultValue = [ 'textfield', 'textarea', 'email', 'autoreply_email', 'telephone', 'checkbox', 'select', 'radio', 'multiselect', 'multicheckbox', 'post-select', 'post-multicheckbox', 'colorpicker', 'hidden', 'html', 'password', 'password-and-confirm' ];
21 var hasAllowedValues = [ 'select', 'radio', 'multiselect', 'multicheckbox', 'post-select', 'post-multicheckbox', 'file' ];
22 var hasDate = [ 'date' ];
23 var hasCustomRequired = [ 'textfield', 'textarea', 'email', 'autoreply_email', 'telephone', 'checkbox', 'select', 'radio', 'multiselect', 'multicheckbox', 'post-select', 'post-multicheckbox', 'colorpicker', 'file', 'password', 'password-and-confirm', 'date' ];
24 var hasCustomFormat = [ 'email', 'autoreply_email', 'telephone' ];
25 // Types that never store a submission value: no submissions-list column
26 // can exist for them, so the essential-column option is meaningless there.
27 var hasNoSubmissionColumn = [ 'submit', 'html', 'captcha', 'captcha_v3', 'cap', 'turnstile' ];
28
29 function updateVisibility() {
30 var type = typeSelect.val();
31 var isBuiltin = builtinTypes.indexOf( type ) !== -1;
32 // The description is definition metadata shown in the fields list for
33 // every type, so it is always left visible/editable.
34 $( '#field-section-default-value' ).toggle( ! isBuiltin || hasDefaultValue.indexOf( type ) !== -1 );
35 $( '#field-section-allowed-values' ).toggle( hasAllowedValues.indexOf( type ) !== -1 );
36 $( '#field-section-date' ).toggle( hasDate.indexOf( type ) !== -1 );
37 $( '#field-section-custom-required' ).toggle( ! isBuiltin || hasCustomRequired.indexOf( type ) !== -1 );
38 $( '#field-section-custom-format' ).toggle( hasCustomFormat.indexOf( type ) !== -1 );
39 $( '#field-section-essential-column' ).toggle( ! isBuiltin || hasNoSubmissionColumn.indexOf( type ) === -1 );
40
41 // The allowed-values textarea doubles as extensions list for file
42 // fields and query parameters for post fields - same keys, same
43 // wording as the form editor widgets.
44 if ( type === 'file' ) {
45 $( '#allowed-values-label' ).text( l10n.allowedExtensionsLabel );
46 $( '#allowed-values-help' ).text( l10n.allowedExtensionsHelp );
47 } else if ( type === 'post-select' || type === 'post-multicheckbox' ) {
48 $( '#allowed-values-label' ).text( l10n.queryParamsLabel );
49 $( '#allowed-values-help' ).text( l10n.queryParamsHelp );
50 } else {
51 $( '#allowed-values-label' ).text( l10n.allowedValuesLabel );
52 $( '#allowed-values-help' ).text( l10n.allowedValuesHelp );
53 }
54
55 // The default-value textarea holds the HTML content for html fields
56 if ( type === 'html' ) {
57 $( '#default-value-label' ).text( l10n.customHtmlLabel );
58 $( '#default-value-help' ).text( l10n.customHtmlHelp );
59 } else {
60 $( '#default-value-label' ).text( l10n.defaultValueLabel );
61 $( '#default-value-help' ).text( l10n.defaultValueHelp );
62 }
63 }
64
65 typeSelect.on( 'change', updateVisibility );
66 updateVisibility();
67
68 // Client-side validation
69 var typesNeedingAllowedValues = [ 'select', 'radio', 'multiselect', 'multicheckbox' ];
70
71 $( '#addtag' ).on( 'submit', function( e ) {
72 var valid = true;
73 var errors = [];
74
75 // Remove previous error highlights and messages
76 $( this ).find( '.form-invalid' ).removeClass( 'form-invalid' );
77 $( this ).prev( '.field-error-message' ).remove();
78
79 // Validate label
80 var $label = $( '#tag-name' );
81 if ( $.trim( $label.val() ) === '' ) {
82 $label.closest( '.form-field' ).addClass( 'form-invalid' );
83 errors.push( l10n.errorLabelRequired );
84 valid = false;
85 }
86
87 // Validate slug (only when adding)
88 var $slug = $( '#tag-slug' );
89 if ( ! $slug.prop( 'disabled' ) ) {
90 var slugValue = $.trim( $slug.val() );
91 if ( slugValue === '' ) {
92 $slug.closest( '.form-field' ).addClass( 'form-invalid' );
93 errors.push( l10n.errorSlugRequired );
94 valid = false;
95 } else {
96 var liveSlugError = slugError( slugValue );
97 if ( liveSlugError !== '' ) {
98 $slug.closest( '.form-field' ).addClass( 'form-invalid' );
99 errors.push( liveSlugError );
100 valid = false;
101 }
102 }
103 }
104
105 // Validate allowed values (when visible and required)
106 var type = typeSelect.val();
107 if ( typesNeedingAllowedValues.indexOf( type ) !== -1 ) {
108 var $allowed = $( '#form-field-allowed-values' );
109 if ( $.trim( $allowed.val() ) === '' ) {
110 $allowed.closest( '.form-field' ).addClass( 'form-invalid' );
111 errors.push( l10n.errorAllowedValuesRequired );
112 valid = false;
113 }
114 }
115
116 if ( ! valid ) {
117 e.preventDefault();
118 // Show error message
119 var errorHtml = '<div class="notice notice-error field-error-message" role="alert"><p>' + errors.join( '</p><p>' ) + '</p></div>';
120 $( this ).before( errorHtml );
121 // Announce for screen readers
122 if ( window.wp && wp.a11y && wp.a11y.speak ) {
123 wp.a11y.speak( errors.join( '. ' ), 'assertive' );
124 }
125 // Focus first invalid field
126 var $firstInvalid = $( this ).find( '.form-invalid:first' );
127 if ( $firstInvalid.length ) {
128 $firstInvalid.find( 'input, textarea, select' ).first().trigger( 'focus' );
129 }
130 }
131 } );
132
133 // Remove error highlight on input
134 $( '#addtag' ).on( 'input change', 'input, textarea, select', function() {
135 $( this ).closest( '.form-field' ).removeClass( 'form-invalid' );
136 } );
137
138 // ------------------------------------------------------------------
139 // Slug suggestion: while adding, the slug follows the label until the
140 // user edits the slug manually (same idea as the core permalink slug).
141 // ------------------------------------------------------------------
142 var $slugInput = $( '#tag-slug' );
143 var slugTouched = $slugInput.prop( 'disabled' ) || $.trim( $slugInput.val() ) !== '';
144 $slugInput.on( 'input', function() {
145 // New slugs are lowercase (the server lowercases them on save, like
146 // core does with term slugs), so show what will actually be stored
147 // instead of letting the input disagree with the result.
148 var typed = $( this ).val();
149 if ( typed !== typed.toLowerCase() ) {
150 var caret = this.selectionStart;
151 $( this ).val( typed.toLowerCase() );
152 if ( caret !== null && typeof this.setSelectionRange === 'function' ) {
153 this.setSelectionRange( caret, caret );
154 }
155 }
156 // A manual edit stops the suggestion, but emptying the slug
157 // re-enables it: an empty slug always follows the label again.
158 slugTouched = $.trim( $( this ).val() ) !== '';
159 } );
160 $( '#tag-name' ).on( 'input', function() {
161 if ( slugTouched ) {
162 return;
163 }
164 var slug = $( this ).val().toLowerCase()
165 .normalize( 'NFD' ).replace( /[\u0300-\u036f]/g, '' ) // fold accents: "à" -> "a"
166 .replace( /[^a-z0-9_-]+/g, '-' )
167 .replace( /^-+|-+$/g, '' )
168 .substring( 0, 70 );
169 $slugInput.val( slug );
170 validateSlugField(); // a suggested slug can collide with an existing field
171 } );
172
173 // ------------------------------------------------------------------
174 // Inline slug validation while adding: mirrors the server-side rules
175 // and also flags a slug already in use, before anything is submitted.
176 // The server-side validation on save stays authoritative.
177 // ------------------------------------------------------------------
178 var existingSlugs = accuaFieldsPage.existingSlugs || [];
179 var $slugFeedback = $( '#slug-feedback' );
180
181 function slugError( slug ) {
182 if ( slug === '' ) {
183 return ''; // emptiness is reported on submit, not while typing
184 }
185 if ( ! /^[a-z0-9_-]+$/i.test( slug ) ) {
186 return l10n.slugInvalidChars;
187 }
188 if ( slug.indexOf( '__' ) === 0 ) {
189 return l10n.slugDoubleUnderscore;
190 }
191 if ( slug.length > 70 ) {
192 return l10n.slugTooLong;
193 }
194 if ( existingSlugs.indexOf( slug ) !== -1 ) {
195 return l10n.slugExists;
196 }
197 return '';
198 }
199
200 function validateSlugField() {
201 if ( $slugInput.prop( 'disabled' ) || ! $slugFeedback.length ) {
202 return true;
203 }
204 var err = slugError( $.trim( $slugInput.val() ) );
205 $slugInput.closest( '.form-field' ).toggleClass( 'form-invalid', err !== '' );
206 $slugFeedback.text( err ).prop( 'hidden', err === '' );
207 return err === '';
208 }
209
210 // Delegated on purpose: the generic error-highlight removal above is
211 // delegated too and bound first, so this re-applies the highlight after
212 // it runs for the same input event.
213 $( '#addtag' ).on( 'input', '#tag-slug', validateSlugField );
214
215 // ------------------------------------------------------------------
216 // Delete confirmation
217 // ------------------------------------------------------------------
218 $( '#delete-field' ).on( 'click', function( e ) {
219 var slug = $( '#addtag input[name="form-field-id"]' ).val() || '';
220 if ( ! window.confirm( l10n.confirmDelete.replace( '%s', slug ) ) ) {
221 e.preventDefault();
222 }
223 } );
224
225 // ------------------------------------------------------------------
226 // Live preview: POSTs the current (unsaved) settings to the
227 // accua_forms_field_preview AJAX action and writes the returned document
228 // into the preview iframe (document.write instead of a real navigation:
229 // no browser history entries, frontend CSS + JS still execute). Text
230 // input is debounced; selects and date pickers refresh immediately.
231 // ------------------------------------------------------------------
232 var $previewFrame = $( '#accua-field-preview' );
233 var $previewWrapper = $( '#accua-field-preview-wrapper' );
234 var $previewPanel = $( '#accua-field-preview-panel' );
235 var previewTimer = null;
236 var previewXhr = null;
237 var previewSettleTimers = [];
238
239 function clearPreviewSettleTimers() {
240 previewSettleTimers.forEach( clearTimeout );
241 previewSettleTimers = [];
242 }
243
244 function previewPayload() {
245 var payload = [
246 { name: 'action', value: 'accua_forms_field_preview' },
247 { name: '_wpnonce', value: accuaFieldsPage.previewNonce }
248 ];
249 $( '#addtag' ).serializeArray().forEach( function( pair ) {
250 // The editor form's own action and nonces must not leak into the
251 // preview request.
252 if ( pair.name === 'action' || pair.name.indexOf( '_wpnonce' ) === 0 || pair.name === '_wp_http_referer' ) {
253 return;
254 }
255 payload.push( pair );
256 } );
257 return payload;
258 }
259
260 function resizePreview() {
261 try {
262 var doc = $previewFrame[ 0 ].contentDocument;
263 if ( doc && doc.body ) {
264 // Neither documentElement.scrollHeight nor the body box can be
265 // trusted: both are stretched to the iframe's own height (the
266 // frontend styles give body 100% height), so the preview could
267 // grow but never shrink back to fit a small field. Measure the
268 // actual content instead - the lowest bottom edge among the
269 // body's children. The +16 leaves room for focus outlines.
270 var win = doc.defaultView;
271 var bottom = 0;
272 Array.prototype.forEach.call( doc.body.children, function ( el ) {
273 var rect = el.getBoundingClientRect();
274 if ( rect.bottom > bottom ) {
275 bottom = rect.bottom;
276 }
277 } );
278 bottom += win.pageYOffset || 0;
279 var needed = Math.ceil( bottom ) + 16;
280 var height = Math.min( 700, Math.max( 80, needed ) );
281 $previewFrame.css( 'height', height + 'px' );
282 // The document is written with overflow hidden so no scrollbar
283 // flashes while the frame animates to this height; scrolling is
284 // only given back when the content exceeds the height cap.
285 doc.documentElement.style.overflowY = needed > 700 ? 'auto' : '';
286 }
287 } catch ( e ) {
288 // Cross-origin (should not happen) - keep the CSS height.
289 }
290 }
291
292 var previewObserver = null;
293
294 // Follow every later content change (inline validation errors appearing on
295 // blur, async widgets settling) so the frame always fits its content. The
296 // body itself is stretched to 100% height, so observe its children (the
297 // form, the response-messages div, the empty-preview note).
298 function observePreviewBody() {
299 try {
300 var frameWin = $previewFrame[ 0 ].contentWindow;
301 if ( previewObserver ) {
302 previewObserver.disconnect();
303 previewObserver = null;
304 }
305 if ( frameWin && frameWin.ResizeObserver && frameWin.document.body ) {
306 previewObserver = new frameWin.ResizeObserver( resizePreview );
307 Array.prototype.forEach.call( frameWin.document.body.children, function ( el ) {
308 previewObserver.observe( el );
309 } );
310 }
311 } catch ( e ) {
312 // No observer - the timed re-measures below still apply.
313 }
314 }
315
316 function refreshPreview() {
317 if ( ! $previewFrame.length ) {
318 return;
319 }
320 // Nothing meaningful to preview until the field has a label.
321 if ( $.trim( $( '#tag-name' ).val() ) === '' ) {
322 $previewPanel.hide();
323 return;
324 }
325 $previewPanel.show();
326 $previewWrapper.addClass( 'accua-form-preview-loading' );
327 if ( previewXhr ) {
328 previewXhr.abort();
329 }
330 // A superseded refresh must not resize against the document this one
331 // is about to replace.
332 clearPreviewSettleTimers();
333 previewXhr = $.post( accuaFieldsPage.ajaxUrl, $.param( previewPayload() ) )
334 .done( function( html ) {
335 var doc = $previewFrame[ 0 ].contentDocument;
336 // The old document's observed nodes are about to be detached -
337 // their final resize must not measure the new, not-yet-styled
338 // content.
339 if ( previewObserver ) {
340 previewObserver.disconnect();
341 previewObserver = null;
342 }
343 // Measuring right after document.write would size the still
344 // unstyled content (the frontend stylesheets are only loading),
345 // grow the frame, then shrink it back once the CSS applies - a
346 // visible jump. Keep the previous height and the loading
347 // overlay until the written document (stylesheets included)
348 // has loaded, then resize once to the final height. The timer
349 // is a safety net in case a subresource hangs and the frame's
350 // load event never fires.
351 var settled = false;
352 var settle = function() {
353 if ( settled ) {
354 return;
355 }
356 settled = true;
357 observePreviewBody();
358 resizePreview();
359 $previewWrapper.removeClass( 'accua-form-preview-loading' );
360 // Async widgets (post-select options, captcha iframes)
361 // settle late - fallback when ResizeObserver is
362 // unavailable (it re-fires on those changes anyway).
363 previewSettleTimers.push( setTimeout( resizePreview, 600 ) );
364 previewSettleTimers.push( setTimeout( resizePreview, 1500 ) );
365 };
366 // Bound on the frame element (not the frame window, which
367 // document.open() resets) and before doc.close(), so the load
368 // event cannot be missed; re-bound on every refresh.
369 $previewFrame.off( 'load' ).one( 'load', settle );
370 doc.open();
371 doc.write( html );
372 doc.close();
373 previewSettleTimers.push( setTimeout( settle, 1500 ) );
374 } )
375 .always( function( dataOrXhr, status ) {
376 if ( status !== 'abort' ) {
377 previewXhr = null;
378 if ( status !== 'success' ) {
379 $previewWrapper.removeClass( 'accua-form-preview-loading' );
380 }
381 }
382 } );
383 }
384
385 function schedulePreviewRefresh() {
386 clearTimeout( previewTimer );
387 previewTimer = setTimeout( refreshPreview, 700 );
388 }
389
390 if ( $previewFrame.length ) {
391 $( '#addtag' ).on( 'input', 'input, textarea', schedulePreviewRefresh );
392 $( '#addtag' ).on( 'change', 'select, input[type="date"]', function() {
393 clearTimeout( previewTimer );
394 refreshPreview();
395 } );
396 // Once the editor form is submitted the page is about to navigate:
397 // a debounced refresh firing in that window would be wasted work
398 // racing the unload (an AJAX render plus a document.write into the
399 // iframe of a disappearing page). If the client-side validation
400 // blocks the submit instead, the next input event simply schedules
401 // a fresh refresh.
402 $( '#addtag' ).on( 'submit', function() {
403 clearTimeout( previewTimer );
404 clearPreviewSettleTimers();
405 if ( previewXhr ) {
406 previewXhr.abort();
407 previewXhr = null;
408 }
409 } );
410 // Hide the panel as soon as the label is cleared (showing again is
411 // handled by the debounced refresh).
412 $( '#tag-name' ).on( 'input', function() {
413 if ( $.trim( $( this ).val() ) === '' ) {
414 clearTimeout( previewTimer );
415 $previewPanel.hide();
416 }
417 } );
418 refreshPreview();
419 }
420 } );
421