From 25f95bfef21d5a9288cae23c42c18320d4435137 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 16 Oct 2015 23:56:12 -0400 Subject: [PATCH 01/76] Enable autocomplete when focusing an autocomplete-ready field Autocomplete fields in the Customizer are not likely to be visible when `fm.autocomplete.enable_autocomplete()` first fires. Rather than adding another FM-specific event, this triggers `enable_autocomplete()` whenever an uninitialized autocomplete input is focused. Bonus: This affects autocomplete fields in widgets and menu items, too. --- js/fieldmanager-autocomplete.js | 10 +++++++--- tests/js/index.html | 8 ++++++++ tests/js/test-fieldmanager.js | 11 +++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/js/fieldmanager-autocomplete.js b/js/fieldmanager-autocomplete.js index 2674395cc4..974f175f7b 100644 --- a/js/fieldmanager-autocomplete.js +++ b/js/fieldmanager-autocomplete.js @@ -1,4 +1,4 @@ -( function( $ ) { +(function( $ ) { fm.autocomplete = { @@ -34,7 +34,7 @@ fm.autocomplete = { custom_data = custom_result; } } - + $.post( ajaxurl, { action: $el.data( 'action' ), fm_context: $el.data( 'context' ), @@ -77,5 +77,9 @@ fm.autocomplete = { $( document ).ready( fm.autocomplete.enable_autocomplete ); $( document ).on( 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab', fm.autocomplete.enable_autocomplete ); +$( document ).on( 'focus', + 'input[class*="fm-autocomplete"]:not(.fm-autocomplete-enabled)', + fm.autocomplete.enable_autocomplete +); -} ) ( jQuery ); +})( jQuery ); diff --git a/tests/js/index.html b/tests/js/index.html index de5a844789..929c42d98a 100644 --- a/tests/js/index.html +++ b/tests/js/index.html @@ -29,10 +29,13 @@ svn + '/src/wp-includes/js/jquery/ui/sortable.js', svn + '/src/wp-includes/js/jquery/ui/draggable.js', svn + '/src/wp-includes/js/jquery/ui/droppable.js', + svn + '/src/wp-includes/js/jquery/ui/menu.js', + svn + '/src/wp-includes/js/jquery/ui/autocomplete.js', ]; // Fieldmanager. scripts.push( '../../js/fieldmanager.js' ); + scripts.push( '../../js/fieldmanager-autocomplete.js' ); // Tests. scripts.push( 'test-fieldmanager.js' ); @@ -99,6 +102,11 @@ --> +
+ + +
+
First
diff --git a/tests/js/test-fieldmanager.js b/tests/js/test-fieldmanager.js index fd39ca54a2..05dbc43d67 100644 --- a/tests/js/test-fieldmanager.js +++ b/tests/js/test-fieldmanager.js @@ -65,6 +65,17 @@ // assert.ok( $( '#di-456' ).is( ':visible' ), "hide display-if value of '456'" ); }); + QUnit.test( 'Autocomplete', function( assert ) { + assert.ok( $( '#ac-visible' ).hasClass( 'fm-autocomplete-enabled' ) ); + + var $invisible = $( '#ac-invisible' ); + assert.notOk( $invisible.hasClass( 'fm-autocomplete-enabled' ) ); + + // Simulate activating autocomplete by focusing an element. + $invisible.show().focus(); + assert.ok( $invisible.hasClass( 'fm-autocomplete-enabled' ) ); + }); + QUnit.test( 'Renumber', function( assert ) { // Reorganize the items in the simple group. var $fieldLast = $( '#renumbered .fm-item' ).last(); From 370d191e502a9d4239cbc97611da779640ba3d41 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 15 Nov 2015 11:15:02 -0500 Subject: [PATCH 02/76] Allow init_sortable() and init_label_macros() to be triggered externally These handlers allow contexts (immediately, the Customizer context) or third-party extensions to reinitialize these features after events not relevant to fieldmanager.js. --- js/fieldmanager.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/fieldmanager.js b/js/fieldmanager.js index d26d8d9657..cd63f1ee5f 100644 --- a/js/fieldmanager.js +++ b/js/fieldmanager.js @@ -200,6 +200,8 @@ $( document ).ready( function () { init_sortable(); $( document ).on( 'fm_activate_tab', init_sortable ); + $( document ).on( 'fm_init_sortable', init_sortable ); + $( document ).on( 'fm_init_label_macros', init_label_macros ); } ); } )( jQuery ); From 490f7a83bf90db6ecb4581508901a4098690e27f Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 15 Nov 2015 11:27:15 -0500 Subject: [PATCH 03/76] Add the basics of a Customizer context --- css/fieldmanager.css | 17 +- fieldmanager.php | 20 +++ js/fieldmanager-customize.js | 170 ++++++++++++++++++ php/class-fieldmanager-customize-control.php | 47 +++++ php/class-fieldmanager-field.php | 11 ++ .../class-fieldmanager-context-customizer.php | 129 +++++++++++++ 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 js/fieldmanager-customize.js create mode 100644 php/class-fieldmanager-customize-control.php create mode 100644 php/context/class-fieldmanager-context-customizer.php diff --git a/css/fieldmanager.css b/css/fieldmanager.css index 74273b8e0f..1c8b3d1b77 100644 --- a/css/fieldmanager.css +++ b/css/fieldmanager.css @@ -305,4 +305,19 @@ a.fm-delete:hover { .form-field .fm-option label, .form-field .fm-checkbox label { display: inline; -} \ No newline at end of file +} + +.wp-customizer .ui-autocomplete { + /* Hoist the autocomplete popup over who-knows-what in the Customizer. */ + z-index: 500000; +} + +.wp-customizer .fmjs-removable .fmjs-drag-icon { + /* Nudge for the Customizer. */ + margin-top: 7px; +} + +.wp-customizer .fmjs-removable .fmjs-drag-icon + .fmjs-removable-element { + /* Nudge for the Customizer. */ + max-width: 90%; +} diff --git a/fieldmanager.php b/fieldmanager.php index 4196314852..ef60de4eb4 100644 --- a/fieldmanager.php +++ b/fieldmanager.php @@ -69,6 +69,11 @@ function fieldmanager_load_class( $class ) { } return fieldmanager_load_file( 'datasource/class-fieldmanager-datasource-' . $class_id . '.php' ); } + + if ( 'Fieldmanager_Customize_Control' === $class ) { + return fieldmanager_load_file( 'class-fieldmanager-customize-control.php' ); + } + return fieldmanager_load_file( 'class-fieldmanager-' . $class_id . '.php', $class ); } @@ -181,6 +186,12 @@ function fm_add_script( $handle, $path, $deps = array(), $ver = false, $in_foote add_action( 'admin_enqueue_scripts', $add_script ); add_action( 'wp_enqueue_scripts', $add_script ); + /* + * Use a later priority because 'customize_controls_enqueue_scripts' will be + * in the middle of firing at the default priority when + * Fieldmanager_Customize_Control::enqueue() calls this. + */ + add_action( 'customize_controls_enqueue_scripts', $add_script, 20 ); } /** @@ -278,6 +289,11 @@ function fm_get_context() { * } */ function fm_calculate_context() { + // Consider the Customizer preview authoritative. + if ( is_customize_preview() ) { + return array( 'customizer', null ); + } + // Safe to use at any point in the load process, and better than URL matching. if ( is_admin() ) { $script = substr( $_SERVER['PHP_SELF'], strrpos( $_SERVER['PHP_SELF'], '/' ) + 1 ); @@ -312,6 +328,10 @@ function fm_calculate_context() { } } + if ( 'customize.php' === $script || is_customize_preview() ) { + return array( 'customizer', null ); + } + switch ( $script ) { // Context = "post". case 'post.php': diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js new file mode 100644 index 0000000000..3af361e747 --- /dev/null +++ b/js/fieldmanager-customize.js @@ -0,0 +1,170 @@ +/* global document, jQuery, wp, _ */ +/** + * Integrate Fieldmanager and the Customizer. + * + * @param {function} $ jQuery + * @param {function} api wp.customize API. + * @param {function} _ Underscore + */ +(function( $, api, _ ) { + 'use strict'; + + /** + * Fires when an .fm-element input triggers a 'change' event. + * + * @param {Event} e Event object. + */ + var onFmElementChange = function( e ) { + reserializeEachControl(); + }; + + /** + * Fires when an .fm-element input triggers a 'keyup' event. + * + * @param {Event} e Event object. + */ + var onFmElementKeyup = function( e ) { + var $target = $( e.target ); + + // Ignore [Escape] and [Enter]. + if ( 27 === e.keyCode || 13 === e.keyCode ) { + return; + } + + // @todo This gets unnecessarily delayed by the _debounce + if ( $target.hasClass( 'fm-autocomplete' ) ) { + // Update an autocomplete setting object when the input's text is deleted. + if ( '' === $target.val() ) { + // See fm.autocomplete.enable_autocomplete() for this tree. + var $targetAutocomplete = $target.siblings( 'input[type=hidden]' ).first(); + + // @todo Risky? Autocomplete hidden fields don't typically get set to value="". + $targetAutocomplete.val( '' ); + + /* + * Don't update when typing into the autocomplete input. The hidden + * field actually contains the value and is handled onFmElementChange(). + */ + } else { + return; + } + } + + reserializeEachControl(); + }; + + /** + * Fires when a Fieldmanager object is dropped while sorting. + * + * @param {Event} e Event object. + * @param {Element} el The sorted element. + */ + var onFmSortableDrop = function( e, el ) { + reserializeEachControl(); + }; + + /** + * Fires when Fieldmanager adds a new element in a repeatable field. + * + * @param {Event} e Event object. + */ + var onFmAddedElement = function( e ) { + reserializeEachControl(); + }; + + /** + * Fires when an item is selected and previewed in a Fieldmanager media field. + * + * @param {Event} e Event object. + * @param {jQuery} $wrapper .media-wrapper jQuery object. + * @param {object} attachment Attachment attributes. + * @param {object} wp Global WordPress JS API. + */ + var onFieldmanagerMediaPreview = function( e, $wrapper, attachment, wp ) { + reserializeEachControl(); + }; + + /** + * Fires after clicking the "Remove" link of a Fieldmanager media field. + * + * @param {Event} e Event object. + */ + var onFmMediaRemoveClick = function( e ) { + reserializeEachControl(); + }; + + /** + * Fires after clicking the "Remove" link of a Fieldmanager repeatable field. + * + * @param {Event} e Event object. + */ + var onFmjsRemoveClick = function( e ) { + reserializeEachControl(); + }; + + /** + * Set the values of all Fieldmanager controls. + */ + var reserializeEachControl = function() { + api.control.each( reserializeControl ); + }; + + /** + * Set a Fieldmanager control to its form values. + * + * @param {Object} control Customizer Control object. + */ + var reserializeControl = function( control ) { + if ( 'fieldmanager' !== control.params.type ) { + return; + } + + control.setting.set( control.container.find( '.fm-element' ).serialize() ); + }; + + /** + * Fires when a Customizer Section expands. + */ + var onSectionContainerExpanded = function() { + /* + * Initialize sortable fields and fields with label macros on any fields + * that just been expanded and are now visible. + */ + $( document ).trigger( 'fm_init_sortable' ); + $( document ).trigger( 'fm_init_label_macros' ); + }; + + /** + * Fires when the Customizer is loaded. + */ + var ready = function() { + var $document = $( document ); + + $document.on( 'change', '.fm-element', onFmElementChange ); + $document.on( 'keyup', '.fm-element', _.debounce( onFmElementKeyup, 500 ) ); + $document.on( 'click', '.fm-media-remove', onFmMediaRemoveClick ); + $document.on( 'click', '.fmjs-remove', onFmjsRemoveClick ); + $document.on( 'fm_sortable_drop', onFmSortableDrop ); + $document.on( 'fieldmanager_media_preview', onFieldmanagerMediaPreview ); + + // @todo Also do this on section add + api.section.each(function( section ) { + section.container.bind( 'expanded', onSectionContainerExpanded ); + }); + + /* + * Hacky, because it always prompts the user to save. Unlike when we + * create individual settings, the field "value" is always going to + * change when it's reserialized. It also ensures defaults are applied + * when the Customizer loads. But if the user saved those changes, the + * defaults would be applied, as opposed to a submenu page, where there + * isn't an AYS prompt. Creating a query string on the PHP side might + * work, but that's even weirder. + */ + reserializeEachControl(); + }; + + if ( typeof api !== 'undefined' ) { + api.bind( 'ready', ready ); + } +})( jQuery, wp.customize, _ ); diff --git a/php/class-fieldmanager-customize-control.php b/php/class-fieldmanager-customize-control.php new file mode 100644 index 0000000000..90d210a265 --- /dev/null +++ b/php/class-fieldmanager-customize-control.php @@ -0,0 +1,47 @@ +fm->element_markup( $this->value() ); + } + } +endif; diff --git a/php/class-fieldmanager-field.php b/php/class-fieldmanager-field.php index 5378940598..5789a83f2e 100644 --- a/php/class-fieldmanager-field.php +++ b/php/class-fieldmanager-field.php @@ -1032,6 +1032,17 @@ public function activate_submenu_page() { _fieldmanager_registry( 'active_submenu', $active_submenu ); } + /** + * Add this field to the Customizer. + * + * @param string|array $args Customizer section title or arguments for the + * section and setting. @see Fieldmanager_Context_Customizer. + */ + public function add_customizer_section( $args ) { + $this->require_base(); + return new Fieldmanager_Context_Customizer( $args, $this ); + } + private function require_base() { if ( !empty( $this->parent ) ) { throw new FM_Developer_Exception( esc_html__( 'You cannot use this method on a subgroup', 'fieldmanager' ) ); diff --git a/php/context/class-fieldmanager-context-customizer.php b/php/context/class-fieldmanager-context-customizer.php new file mode 100644 index 0000000000..d7762a45df --- /dev/null +++ b/php/context/class-fieldmanager-context-customizer.php @@ -0,0 +1,129 @@ + array( 'title' => $args ) ); + } + + $this->args = wp_parse_args( $args, array( + 'section_args' => array(), + 'setting_args' => array(), + ) ); + + $this->fm = $fm; + + add_action( 'customize_register', array( $this, 'customize_register' ) ); + } + + /** + * Fires once WordPress has loaded in the Customizer. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + */ + public function customize_register( $manager ) { + $this->register( $manager ); + /* + * Get the current setting value for Fieldmanager_Field::presave_all() + * before the setting's preview method is called. + * + * WP_Customize_Setting::preview() adds filters to get_option() and + * get_theme_mod() that eventually call the setting's sanitize() method. + * Attempting to call WP_Customize_Setting::value() inside of + * Fieldmanager_Context_Customizer::sanitize_callback() creates an + * infinite loop. + */ + $this->init_current_value( $manager ); + } + + /** + * Filter a Customize setting value in un-slashed form. + * + * @param mixed $value Setting value. + * @param WP_Customize_Setting $setting WP_Customize_Setting instance. + * @return mixed The sanitized setting value. + */ + public function sanitize_callback( $value, $setting ) { + if ( is_string( $value ) && 0 === strpos( $value, $this->fm->name ) ) { + // Parse the query-string version of our values into an array. + parse_str( $value, $value ); + } + + if ( is_array( $value ) && isset( $value[ $this->fm->name ] ) ) { + // If the option name is the top-level array key, get just the value. + $value = $value[ $this->fm->name ]; + } + + // Return the value after Fieldmanager takes a shot at it. + return $this->fm->presave_all( $value, $this->current_value ); + } + + /** + * Create a Customizer section, setting, and control for this field. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + */ + protected function register( $manager ) { + $manager->add_section( $this->fm->name, wp_parse_args( + $this->args['section_args'], + array() + ) ); + + // Set Fieldmanager defaults after parsing the user args, then register the setting. + $setting_args = wp_parse_args( + $this->args['setting_args'], + array( + // Use the capability passed to Fieldmanager_Field::add_customizer_section(). + 'capability' => $manager->get_section( $this->fm->name )->capability, + 'type' => 'option', + ) + ); + $setting_args['default'] = $this->fm->default_value; + $setting_args['sanitize_callback'] = array( $this, 'sanitize_callback' ); + $setting_args['section'] = $this->fm->name; + $manager->add_setting( $this->fm->name, $setting_args ); + + $manager->add_control( new Fieldmanager_Customize_Control( $manager, $this->fm->name, array( + 'fm' => $this->fm, + 'section' => $this->fm->name, + ) ) ); + } + + /** + * Initialize $current_value for the type of Customizer setting. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + */ + protected function init_current_value( $manager ) { + $this->current_value = $manager->get_setting( $this->fm->name )->value(); + } +} From c5fc06ee35814bc67ace65dbc1c089d85b6b8c53 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 22 Nov 2015 13:50:20 -0500 Subject: [PATCH 04/76] Include jQuery Datepicker as Datepicker dependency This helps ensure the UI Datepicker script loads when adding a Datepicker field to the Customizer. --- php/class-fieldmanager-datepicker.php | 2 +- tests/php/test-fieldmanager-script-loading.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/php/class-fieldmanager-datepicker.php b/php/class-fieldmanager-datepicker.php index e404c82aee..21a9d03db0 100644 --- a/php/class-fieldmanager-datepicker.php +++ b/php/class-fieldmanager-datepicker.php @@ -62,7 +62,7 @@ class Fieldmanager_Datepicker extends Fieldmanager_Field { public function __construct( $label, $options = array() ) { wp_enqueue_script( 'jquery-ui-datepicker' ); fm_add_style( 'fm-jquery-ui', 'css/jquery-ui/jquery-ui-1.10.2.custom.min.css' ); - fm_add_script( 'fm_datepicker', 'js/fieldmanager-datepicker.js', array( 'fieldmanager_script' ) ); + fm_add_script( 'fm_datepicker', 'js/fieldmanager-datepicker.js', array( 'fieldmanager_script', 'jquery-ui-datepicker' ) ); parent::__construct( $label, $options ); if ( empty( $this->js_opts ) ) { diff --git a/tests/php/test-fieldmanager-script-loading.php b/tests/php/test-fieldmanager-script-loading.php index b3dc4938c9..6a27003704 100644 --- a/tests/php/test-fieldmanager-script-loading.php +++ b/tests/php/test-fieldmanager-script-loading.php @@ -56,7 +56,7 @@ public function script_data() { return array( array( 'fieldmanager_script', array( 'jquery' ) ), array( 'fm_autocomplete_js', array( 'fieldmanager_script' ) ), - array( 'fm_datepicker', array( 'fieldmanager_script' ) ), + array( 'fm_datepicker', array( 'fieldmanager_script', 'jquery-ui-datepicker' ) ), array( 'fm_draggablepost_js', array() ), array( 'fm_group_tabs_js', array( 'jquery', 'jquery-hoverintent' ) ), array( 'fm_media', array( 'jquery' ) ), From be02e65c9e27ed9a6f01cf61f3549d9f8396aa3d Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 22 Nov 2015 14:48:22 -0500 Subject: [PATCH 05/76] Replace per-field JS handlers with Customizer event This removes the two event handlers that existed only to enable sortable fields and label macros. Instead, we trigger one event when a Customizer section containing a Fieldmanager control expands, and have those features listen for it. This is a little more consistent with other FM features that listen for FM-related events. --- js/fieldmanager-customize.js | 29 +++++++++++++++++------------ js/fieldmanager.js | 5 ++--- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 3af361e747..988ca5d8f5 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -123,15 +123,23 @@ }; /** - * Fires when a Customizer Section expands. + * Trigger an event when a Customizer section with a Fieldmanager control expands. */ - var onSectionContainerExpanded = function() { - /* - * Initialize sortable fields and fields with label macros on any fields - * that just been expanded and are now visible. - */ - $( document ).trigger( 'fm_init_sortable' ); - $( document ).trigger( 'fm_init_label_macros' ); + var bindToSectionsWithFm = function() { + // @todo Also do this on section add + api.section.each(function( section ) { + $.each( section.controls(), function( i, control ) { + if ( 'fieldmanager' !== control.params.type ) { + return; + } + + section.container.bind( 'expanded', function() { + $( document ).trigger( 'fm_customizer_control_section_expanded' ); + }); + + return false; + }); + }); }; /** @@ -147,10 +155,7 @@ $document.on( 'fm_sortable_drop', onFmSortableDrop ); $document.on( 'fieldmanager_media_preview', onFieldmanagerMediaPreview ); - // @todo Also do this on section add - api.section.each(function( section ) { - section.container.bind( 'expanded', onSectionContainerExpanded ); - }); + bindToSectionsWithFm(); /* * Hacky, because it always prompts the user to save. Unlike when we diff --git a/js/fieldmanager.js b/js/fieldmanager.js index cd63f1ee5f..e18122462a 100644 --- a/js/fieldmanager.js +++ b/js/fieldmanager.js @@ -199,9 +199,8 @@ $( document ).ready( function () { init_label_macros(); init_sortable(); - $( document ).on( 'fm_activate_tab', init_sortable ); - $( document ).on( 'fm_init_sortable', init_sortable ); - $( document ).on( 'fm_init_label_macros', init_label_macros ); + $( document ).on( 'fm_activate_tab fm_customizer_control_section_expanded', init_sortable ); + $( document ).on( 'fm_customizer_control_section_expanded', init_label_macros ); } ); } )( jQuery ); From be00ddf829400d01dd8335562aba4479bfe6c13f Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 22 Nov 2015 14:55:17 -0500 Subject: [PATCH 06/76] Fix Datepicker initialization in the Customizer --- css/fieldmanager.css | 7 ++++--- js/fieldmanager-datepicker.js | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/css/fieldmanager.css b/css/fieldmanager.css index 1c8b3d1b77..15e49c0e1f 100644 --- a/css/fieldmanager.css +++ b/css/fieldmanager.css @@ -307,9 +307,10 @@ a.fm-delete:hover { display: inline; } -.wp-customizer .ui-autocomplete { - /* Hoist the autocomplete popup over who-knows-what in the Customizer. */ - z-index: 500000; +.wp-customizer .ui-autocomplete, +.wp-customizer .ui-datepicker { + /* Hoist jQuery UI popups over who-knows-what in the Customizer. */ + z-index: 500000 !important; } .wp-customizer .fmjs-removable .fmjs-drag-icon { diff --git a/js/fieldmanager-datepicker.js b/js/fieldmanager-datepicker.js index cc785253ee..21fc482d1f 100644 --- a/js/fieldmanager-datepicker.js +++ b/js/fieldmanager-datepicker.js @@ -11,5 +11,8 @@ } $( document ).ready( fm.datepicker.add_datepicker ); - $( document ).on( 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab', fm.datepicker.add_datepicker ); + $( document ).on( + 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab fm_customizer_control_section_expanded', + fm.datepicker.add_datepicker + ); } ) ( jQuery ); \ No newline at end of file From a79fe3e284e9668f28f0943e48588c0f8d55dcb0 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 22 Nov 2015 15:10:58 -0500 Subject: [PATCH 07/76] Initialize display-if triggers in the Customizer Moves the initialization logic to a function so it can be called during 'fm_customizer_control_section_expanded'. --- js/fieldmanager.js | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/js/fieldmanager.js b/js/fieldmanager.js index e18122462a..b0ca2e9278 100644 --- a/js/fieldmanager.js +++ b/js/fieldmanager.js @@ -114,6 +114,25 @@ var match_value = function( values, match_string ) { return false; } +/** + * Initializes triggers to conditionally hide or show fields. + */ +var init_display_if = function() { + $( '.display-if' ).each(function() { + var src = $( this ).data( 'display-src' ); + var values = $( this ).data( 'display-value' ).split( ',' ); + var trigger = $( this ).siblings( '.fm-' + src + '-wrapper' ).find( '.fm-element' ); + var val = trigger.val(); + if ( trigger.is( ':radio' ) && trigger.filter( ':checked' ).length ) { + val = trigger.filter( ':checked' ).val(); + } + trigger.addClass( 'display-trigger' ); + if ( ! match_value( values, val ) ) { + $( this ).hide(); + } + }); +}; + fm_add_another = function( $element ) { var el_name = $element.data( 'related-element' ) , limit = $element.data( 'limit' ) - 0 @@ -163,21 +182,6 @@ $( document ).ready( function () { $( '.fm-collapsed > .fm-group:not(.fmjs-proto) > .fm-group-inner' ).hide(); - // Initializes triggers to conditionally hide or show fields - $( '.display-if' ).each( function() { - var src = $( this ).data( 'display-src' ); - var values = $( this ).data( 'display-value' ).split( ',' ); - var trigger = $( this ).siblings( '.fm-' + src + '-wrapper' ).find( '.fm-element' ); - var val = trigger.val(); - if ( trigger.is( ':radio' ) && trigger.filter( ':checked' ).length ) { - val = trigger.filter( ':checked' ).val(); - } - trigger.addClass( 'display-trigger' ); - if ( !match_value( values, val ) ) { - $( this ).hide(); - } - } ); - // Controls the trigger to show or hide fields $( document ).on( 'change', '.display-trigger', function() { var val = $( this ).val().split(','); @@ -198,9 +202,11 @@ $( document ).ready( function () { init_label_macros(); init_sortable(); + init_display_if(); $( document ).on( 'fm_activate_tab fm_customizer_control_section_expanded', init_sortable ); $( document ).on( 'fm_customizer_control_section_expanded', init_label_macros ); + $( document ).on( 'fm_customizer_control_section_expanded', init_display_if ); } ); } )( jQuery ); From c1c66d8c66005c1132a76b056ec69f3c5025ce8d Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 22 Nov 2015 15:47:42 -0500 Subject: [PATCH 08/76] Remove unnecessary variable --- js/fieldmanager-customize.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 988ca5d8f5..8f60f56dd6 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -36,10 +36,8 @@ // Update an autocomplete setting object when the input's text is deleted. if ( '' === $target.val() ) { // See fm.autocomplete.enable_autocomplete() for this tree. - var $targetAutocomplete = $target.siblings( 'input[type=hidden]' ).first(); - // @todo Risky? Autocomplete hidden fields don't typically get set to value="". - $targetAutocomplete.val( '' ); + $target.siblings( 'input[type=hidden]' ).first().val( '' ); /* * Don't update when typing into the autocomplete input. The hidden From 8f39b3fe5df2908d43695cf376196bc8616f6b14 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 22 Nov 2015 15:48:16 -0500 Subject: [PATCH 09/76] Fix unnecessarily debounced autocomplete events --- js/fieldmanager-customize.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 8f60f56dd6..e7aafd9889 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -31,7 +31,6 @@ return; } - // @todo This gets unnecessarily delayed by the _debounce if ( $target.hasClass( 'fm-autocomplete' ) ) { // Update an autocomplete setting object when the input's text is deleted. if ( '' === $target.val() ) { @@ -146,8 +145,17 @@ var ready = function() { var $document = $( document ); + /* + * We debounce() most keyup events to avoid refreshing the Customizer + * preview every single time the user types a letter. But typing into + * the autocomplete input does not itself trigger a refresh -- the only + * time it should affect the preview is when removing an autocomplete + * selection. We allow that to occur normally. + */ + $document.on( 'keyup', '.fm-element:not(.fm-autocomplete)', _.debounce( onFmElementKeyup, 500 ) ); + $document.on( 'keyup', '.fm-autocomplete', onFmElementKeyup ); + $document.on( 'change', '.fm-element', onFmElementChange ); - $document.on( 'keyup', '.fm-element', _.debounce( onFmElementKeyup, 500 ) ); $document.on( 'click', '.fm-media-remove', onFmMediaRemoveClick ); $document.on( 'click', '.fmjs-remove', onFmjsRemoveClick ); $document.on( 'fm_sortable_drop', onFmSortableDrop ); From 980e2b5dc308c0001cbb59c429ea2b2634be5899 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 6 Dec 2015 16:07:45 -0500 Subject: [PATCH 10/76] Remove extra space in string --- php/class-fieldmanager-field.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php/class-fieldmanager-field.php b/php/class-fieldmanager-field.php index 5789a83f2e..c8889952f6 100644 --- a/php/class-fieldmanager-field.php +++ b/php/class-fieldmanager-field.php @@ -843,7 +843,7 @@ public function presave( $value, $current_value = array() ) { foreach ( $this->validate as $func ) { if ( !call_user_func( $func, $value ) ) { $this->_failed_validation( sprintf( - __( 'Input "%1$s" is not valid for field "%2$s" ', 'fieldmanager' ), + __( 'Input "%1$s" is not valid for field "%2$s"', 'fieldmanager' ), (string) $value, $this->label ) ); From 0795c833e45844c946b694f65a652df7bdea9daa Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 6 Dec 2015 16:13:17 -0500 Subject: [PATCH 11/76] Alert users to failed validation during saving This primarily updates Fieldmanager_Field::_failed_validation() so that it returns usable data to the Customizer. The particular error string and the method of alerting users might come in for more updates. --- js/fieldmanager-customize.js | 15 +++++++++++++++ php/class-fieldmanager-field.php | 14 +++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index e7aafd9889..197d65a2dc 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -175,7 +175,22 @@ reserializeEachControl(); }; + /** + * Fires when a Customizer request to save values fails. + * + * @return {Mixed} response The response from the server. + */ + var error = function( response ) { + if ( ! response.fieldmanager ) { + return; + } + + // There isn't yet an official way to signal a save failure, but this mimics the AYS prompt. + alert( response.fieldmanager ); + }; + if ( typeof api !== 'undefined' ) { api.bind( 'ready', ready ); + api.bind( 'error', error ); } })( jQuery, wp.customize, _ ); diff --git a/php/class-fieldmanager-field.php b/php/class-fieldmanager-field.php index c8889952f6..7da5a7f0be 100644 --- a/php/class-fieldmanager-field.php +++ b/php/class-fieldmanager-field.php @@ -1072,12 +1072,16 @@ protected function _failed_validation( $debug_message = '' ) { if ( self::$debug ) { throw new FM_Validation_Exception( $debug_message ); } - else { - wp_die( esc_html( - $debug_message . "\n\n" . - __( "You may be able to use your browser's back button to resolve this error.", 'fieldmanager' ) - ) ); + + if ( is_customize_preview() ) { + // There isn't yet a way to catch this error during a preview refresh. + wp_send_json_error( array( 'fieldmanager' => sprintf( __( 'Error: %s', 'fieldmanager' ), $debug_message ) ) ); } + + wp_die( esc_html( + $debug_message . "\n\n" . + __( "You may be able to use your browser's back button to resolve this error.", 'fieldmanager' ) + ) ); } /** From e017a204e4991d525e117eac43a2e255f8911c4d Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 6 Dec 2015 19:38:12 -0500 Subject: [PATCH 12/76] Bind FM event to Sections as they're added This helps ensure we bind 'fm_customizer_control_section_expanded' to sections that are dynamically added. This also changes the previous behavior of binding to only sections with Fieldmanager controls: If an FM control is dynamically added to a section that had no FM controls, we need to trigger the event when that section expands. Binding to when controls are added, rather than sections, isn't enough because controls can be added without being assigned to sections. Eventually, it might be more efficient to try binding to a section only when a Fieldmanager control is added to it. --- js/fieldmanager-customize.js | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 197d65a2dc..5989e69363 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -120,22 +120,14 @@ }; /** - * Trigger an event when a Customizer section with a Fieldmanager control expands. + * Trigger a Fieldmanager event when a Customizer section expands. + * + * We bind to sections whether or not they have FM controls in case a + * control is added dynamically. */ - var bindToSectionsWithFm = function() { - // @todo Also do this on section add - api.section.each(function( section ) { - $.each( section.controls(), function( i, control ) { - if ( 'fieldmanager' !== control.params.type ) { - return; - } - - section.container.bind( 'expanded', function() { - $( document ).trigger( 'fm_customizer_control_section_expanded' ); - }); - - return false; - }); + var bindToSectionExpanded = function( section ) { + section.container.bind( 'expanded', function() { + $( document ).trigger( 'fm_customizer_control_section_expanded' ); }); }; @@ -161,8 +153,6 @@ $document.on( 'fm_sortable_drop', onFmSortableDrop ); $document.on( 'fieldmanager_media_preview', onFieldmanagerMediaPreview ); - bindToSectionsWithFm(); - /* * Hacky, because it always prompts the user to save. Unlike when we * create individual settings, the field "value" is always going to @@ -189,8 +179,19 @@ alert( response.fieldmanager ); }; + /** + * Fires when a Customizer Section is added. + * + * @param {Object} section Customizer Section object. + */ + var addSection = function( section ) { + // It would be more efficient to do this only when adding an FM control to a section. + bindToSectionExpanded( section ); + }; + if ( typeof api !== 'undefined' ) { api.bind( 'ready', ready ); api.bind( 'error', error ); + api.section.bind( 'add', addSection ); } })( jQuery, wp.customize, _ ); From bd39fe88477f57a943125714489c630ea68014ef Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 6 Dec 2015 21:20:18 -0500 Subject: [PATCH 13/76] Offer suggestion after saving in Customizer fails This follows other FM error messages, e.g. "You may be able to use your browser's back button" and "Please check your code and try again." --- php/class-fieldmanager-field.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php/class-fieldmanager-field.php b/php/class-fieldmanager-field.php index 7da5a7f0be..aca3c31c50 100644 --- a/php/class-fieldmanager-field.php +++ b/php/class-fieldmanager-field.php @@ -1075,7 +1075,7 @@ protected function _failed_validation( $debug_message = '' ) { if ( is_customize_preview() ) { // There isn't yet a way to catch this error during a preview refresh. - wp_send_json_error( array( 'fieldmanager' => sprintf( __( 'Error: %s', 'fieldmanager' ), $debug_message ) ) ); + wp_send_json_error( array( 'fieldmanager' => sprintf( __( 'Error: %s. Please check your settings and save again.', 'fieldmanager' ), $debug_message ) ) ); } wp_die( esc_html( From d6330b4cf31c4691ff721fe2dc51d8fdf9eaeca4 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sat, 2 Jan 2016 16:12:39 -0500 Subject: [PATCH 14/76] Don't reserialize all controls on changes in value Instead, reserialize only those controls containing the changed element. --- js/fieldmanager-customize.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 5989e69363..b7d7d7c8bb 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -15,7 +15,7 @@ * @param {Event} e Event object. */ var onFmElementChange = function( e ) { - reserializeEachControl(); + reserializeControlsContainingElement( e.target ); }; /** @@ -47,7 +47,7 @@ } } - reserializeEachControl(); + reserializeControlsContainingElement( e.target ); }; /** @@ -57,7 +57,7 @@ * @param {Element} el The sorted element. */ var onFmSortableDrop = function( e, el ) { - reserializeEachControl(); + reserializeControlsContainingElement( e.target ); }; /** @@ -66,7 +66,7 @@ * @param {Event} e Event object. */ var onFmAddedElement = function( e ) { - reserializeEachControl(); + reserializeControlsContainingElement( e.target ); }; /** @@ -78,7 +78,7 @@ * @param {object} wp Global WordPress JS API. */ var onFieldmanagerMediaPreview = function( e, $wrapper, attachment, wp ) { - reserializeEachControl(); + reserializeControlsContainingElement( e.target ); }; /** @@ -87,7 +87,7 @@ * @param {Event} e Event object. */ var onFmMediaRemoveClick = function( e ) { - reserializeEachControl(); + reserializeControlsContainingElement( e.target ); }; /** @@ -96,7 +96,7 @@ * @param {Event} e Event object. */ var onFmjsRemoveClick = function( e ) { - reserializeEachControl(); + reserializeControlsContainingElement( e.target ); }; /** @@ -106,6 +106,19 @@ api.control.each( reserializeControl ); }; + /** + * Set the value of any Fieldmanager control with a given element in its container. + * + * @param {Element} el Element to look for. + */ + var reserializeControlsContainingElement = function( el ) { + api.control.each(function( control ) { + if ( control.container.find( el ).length ) { + reserializeControl( control ); + } + }); + }; + /** * Set a Fieldmanager control to its form values. * From 466ce6e9dbed2ff9259f334b44d640a5e0b0fc6c Mon Sep 17 00:00:00 2001 From: David Herrera Date: Mon, 25 Jan 2016 22:27:18 -0500 Subject: [PATCH 15/76] Fix some saving callbacks after d6330b4 - After dropping a sortable field, pass the available element directly to reserializeControlsContainingElement() - Call reserializeEachControl() after removing fields or values, because they no longer exist and so the control doesn't contain them. --- js/fieldmanager-customize.js | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index b7d7d7c8bb..afbcc3c310 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -56,8 +56,8 @@ * @param {Event} e Event object. * @param {Element} el The sorted element. */ - var onFmSortableDrop = function( e, el ) { - reserializeControlsContainingElement( e.target ); + var onFmSortableDrop = function ( e, el ) { + reserializeControlsContainingElement( el ); }; /** @@ -86,18 +86,20 @@ * * @param {Event} e Event object. */ - var onFmMediaRemoveClick = function( e ) { - reserializeControlsContainingElement( e.target ); - }; - - /** - * Fires after clicking the "Remove" link of a Fieldmanager repeatable field. - * - * @param {Event} e Event object. - */ - var onFmjsRemoveClick = function( e ) { - reserializeControlsContainingElement( e.target ); - }; + var onFmMediaRemoveClick = function ( e ) { + // The control no longer contains the element, so reserialize all of them. + reserializeEachControl(); + }; + + /** + * Fires after clicking the "Remove" link of a Fieldmanager repeatable field. + * + * @param {Event} e Event object. + */ + var onFmjsRemoveClick = function ( e ) { + // The control no longer contains the element, so reserialize all of them. + reserializeEachControl(); + }; /** * Set the values of all Fieldmanager controls. From 01893e7ef607daf817f96e8f1369b8f49707ec64 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Mon, 25 Jan 2016 22:32:26 -0500 Subject: [PATCH 16/76] Serialize FM Control values into JS objects When previewing or saving a Fieldmanager field in the Customizer, we now use the jquery.serializeJSON][1] library to serialize the FM control into a JavaScript object. The object is then set as value of the control's setting. This allows us to send an object both to PHP, as part of a 'refresh' setting, and to JS, as part of a 'postMessage' setting. Previously each received a string from jQuery's `serialize()`, which remains the fallback behavior. [1]: https://github.com/marioizquierdo/jquery.serializeJSON --- js/fieldmanager-customize.js | 17 +- .../jquery.serializejson.js | 277 ++++++++++++++++++ .../jquery.serializejson.min.js | 10 + php/class-fieldmanager-customize-control.php | 10 +- 4 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 js/jquery-serializejson/jquery.serializejson.js create mode 100644 js/jquery-serializejson/jquery.serializejson.min.js diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index afbcc3c310..9e8b8d95a2 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -126,12 +126,25 @@ * * @param {Object} control Customizer Control object. */ - var reserializeControl = function( control ) { + var reserializeControl = function ( control ) { + var $element; + var serialized; + var value; + if ( 'fieldmanager' !== control.params.type ) { return; } - control.setting.set( control.container.find( '.fm-element' ).serialize() ); + $element = control.container.find( '.fm-element' ); + + if ( $.serializeJSON ) { + serialized = $element.serializeJSON(); + value = serialized[ control.id ]; + } else { + value = $element.serialize(); + } + + control.setting.set( value ); }; /** diff --git a/js/jquery-serializejson/jquery.serializejson.js b/js/jquery-serializejson/jquery.serializejson.js new file mode 100644 index 0000000000..9cfe797eb1 --- /dev/null +++ b/js/jquery-serializejson/jquery.serializejson.js @@ -0,0 +1,277 @@ +/*! + SerializeJSON jQuery plugin. + https://github.com/marioizquierdo/jquery.serializeJSON + version 2.6.2 (May, 2015) + + Copyright (c) 2012, 2015 Mario Izquierdo + Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) + and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses. +*/ +(function (factory) { + if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof exports === 'object') { // Node/CommonJS + var jQuery = require('jquery'); + module.exports = factory(jQuery); + } else { // Browser globals (zepto supported) + factory(window.jQuery || window.Zepto || window.$); // Zepto supported on browsers as well + } + +}(function ($) { + "use strict"; + + // jQuery('form').serializeJSON() + $.fn.serializeJSON = function (options) { + var serializedObject, formAsArray, keys, type, value, _ref, f, opts; + f = $.serializeJSON; + opts = f.setupOpts(options); // calculate values for options {parseNumbers, parseBoolens, parseNulls} + formAsArray = this.serializeArray(); // array of objects {name, value} + f.readCheckboxUncheckedValues(formAsArray, this, opts); // add {name, value} of unchecked checkboxes if needed + + serializedObject = {}; + $.each(formAsArray, function (i, input) { + keys = f.splitInputNameIntoKeysArray(input.name, opts); + type = keys.pop(); // the last element is always the type ("string" by default) + if (type !== 'skip') { // easy way to skip a value + value = f.parseValue(input.value, type, opts); // string, number, boolean or null + if (opts.parseWithFunction && type === '_') { // allow for custom parsing + value = opts.parseWithFunction(value, input.name); + } + f.deepSet(serializedObject, keys, value, opts); + } + }); + return serializedObject; + }; + + // Use $.serializeJSON as namespace for the auxiliar functions + // and to define defaults + $.serializeJSON = { + + defaultOptions: { + checkboxUncheckedValue: undefined, // to include that value for unchecked checkboxes (instead of ignoring them) + + parseNumbers: false, // convert values like "1", "-2.33" to 1, -2.33 + parseBooleans: false, // convert "true", "false" to true, false + parseNulls: false, // convert "null" to null + parseAll: false, // all of the above + parseWithFunction: null, // to use custom parser, a function like: function(val){ return parsed_val; } + + customTypes: {}, // override defaultTypes + defaultTypes: { + "string": function(str) { return String(str); }, + "number": function(str) { return Number(str); }, + "boolean": function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1; }, + "null": function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1 ? str : null; }, + "array": function(str) { return JSON.parse(str); }, + "object": function(str) { return JSON.parse(str); }, + "auto": function(str) { return $.serializeJSON.parseValue(str, null, {parseNumbers: true, parseBooleans: true, parseNulls: true}); } // try again with something like "parseAll" + }, + + useIntKeysAsArrayIndex: false // name="foo[2]" value="v" => {foo: [null, null, "v"]}, instead of {foo: ["2": "v"]} + }, + + // Merge option defaults into the options + setupOpts: function(options) { + var opt, validOpts, defaultOptions, optWithDefault, parseAll, f; + f = $.serializeJSON; + + if (options == null) { options = {}; } // options ||= {} + defaultOptions = f.defaultOptions || {}; // defaultOptions + + // Make sure that the user didn't misspell an option + validOpts = ['checkboxUncheckedValue', 'parseNumbers', 'parseBooleans', 'parseNulls', 'parseAll', 'parseWithFunction', 'customTypes', 'defaultTypes', 'useIntKeysAsArrayIndex']; // re-define because the user may override the defaultOptions + for (opt in options) { + if (validOpts.indexOf(opt) === -1) { + throw new Error("serializeJSON ERROR: invalid option '" + opt + "'. Please use one of " + validOpts.join(', ')); + } + } + + // Helper to get the default value for this option if none is specified by the user + optWithDefault = function(key) { return (options[key] !== false) && (options[key] !== '') && (options[key] || defaultOptions[key]); }; + + // Return computed options (opts to be used in the rest of the script) + parseAll = optWithDefault('parseAll'); + return { + checkboxUncheckedValue: optWithDefault('checkboxUncheckedValue'), + + parseNumbers: parseAll || optWithDefault('parseNumbers'), + parseBooleans: parseAll || optWithDefault('parseBooleans'), + parseNulls: parseAll || optWithDefault('parseNulls'), + parseWithFunction: optWithDefault('parseWithFunction'), + + typeFunctions: $.extend({}, optWithDefault('defaultTypes'), optWithDefault('customTypes')), + + useIntKeysAsArrayIndex: optWithDefault('useIntKeysAsArrayIndex') + }; + }, + + // Given a string, apply the type or the relevant "parse" options, to return the parsed value + parseValue: function(str, type, opts) { + var typeFunction, f; + f = $.serializeJSON; + + // Parse with a type if available + typeFunction = opts.typeFunctions && opts.typeFunctions[type]; + if (typeFunction) { return typeFunction(str); } // use specific type + + // Otherwise, check if there is any auto-parse option enabled and use it. + if (opts.parseNumbers && f.isNumeric(str)) { return Number(str); } // auto: number + if (opts.parseBooleans && (str === "true" || str === "false")) { return str === "true"; } // auto: boolean + if (opts.parseNulls && str == "null") { return null; } // auto: null + + // If none applies, just return the str + return str; + }, + + isObject: function(obj) { return obj === Object(obj); }, // is it an Object? + isUndefined: function(obj) { return obj === void 0; }, // safe check for undefined values + isValidArrayIndex: function(val) { return /^[0-9]+$/.test(String(val)); }, // 1,2,3,4 ... are valid array indexes + isNumeric: function(obj) { return obj - parseFloat(obj) >= 0; }, // taken from jQuery.isNumeric implementation. Not using jQuery.isNumeric to support old jQuery and Zepto versions + + optionKeys: function(obj) { if (Object.keys) { return Object.keys(obj); } else { var key, keys = []; for(key in obj){ keys.push(key); } return keys;} }, // polyfill Object.keys to get option keys in IE<9 + + // Split the input name in programatically readable keys. + // The last element is always the type (default "_"). + // Examples: + // "foo" => ['foo', '_'] + // "foo:string" => ['foo', 'string'] + // "foo:boolean" => ['foo', 'boolean'] + // "[foo]" => ['foo', '_'] + // "foo[inn][bar]" => ['foo', 'inn', 'bar', '_'] + // "foo[inn[bar]]" => ['foo', 'inn', 'bar', '_'] + // "foo[inn][arr][0]" => ['foo', 'inn', 'arr', '0', '_'] + // "arr[][val]" => ['arr', '', 'val', '_'] + // "arr[][val]:null" => ['arr', '', 'val', 'null'] + splitInputNameIntoKeysArray: function(name, opts) { + var keys, nameWithoutType, type, _ref, f; + f = $.serializeJSON; + _ref = f.extractTypeFromInputName(name, opts); nameWithoutType = _ref[0]; type = _ref[1]; + keys = nameWithoutType.split('['); // split string into array + keys = $.map(keys, function (key) { return key.replace(/\]/g, ''); }); // remove closing brackets + if (keys[0] === '') { keys.shift(); } // ensure no opening bracket ("[foo][inn]" should be same as "foo[inn]") + keys.push(type); // add type at the end + return keys; + }, + + // Returns [name-without-type, type] from name. + // "foo" => ["foo", '_'] + // "foo:boolean" => ["foo", 'boolean'] + // "foo[bar]:null" => ["foo[bar]", 'null'] + extractTypeFromInputName: function(name, opts) { + var match, validTypes, f; + if (match = name.match(/(.*):([^:]+)$/)){ + f = $.serializeJSON; + + validTypes = f.optionKeys(opts ? opts.typeFunctions : f.defaultOptions.defaultTypes); + validTypes.push('skip'); // skip is a special type that makes it easy to remove + if (validTypes.indexOf(match[2]) !== -1) { + return [match[1], match[2]]; + } else { + throw new Error("serializeJSON ERROR: Invalid type " + match[2] + " found in input name '" + name + "', please use one of " + validTypes.join(', ')); + } + } else { + return [name, '_']; // no defined type, then use parse options + } + }, + + // Set a value in an object or array, using multiple keys to set in a nested object or array: + // + // deepSet(obj, ['foo'], v) // obj['foo'] = v + // deepSet(obj, ['foo', 'inn'], v) // obj['foo']['inn'] = v // Create the inner obj['foo'] object, if needed + // deepSet(obj, ['foo', 'inn', '123'], v) // obj['foo']['arr']['123'] = v // + // + // deepSet(obj, ['0'], v) // obj['0'] = v + // deepSet(arr, ['0'], v, {useIntKeysAsArrayIndex: true}) // arr[0] = v + // deepSet(arr, [''], v) // arr.push(v) + // deepSet(obj, ['arr', ''], v) // obj['arr'].push(v) + // + // arr = []; + // deepSet(arr, ['', v] // arr => [v] + // deepSet(arr, ['', 'foo'], v) // arr => [v, {foo: v}] + // deepSet(arr, ['', 'bar'], v) // arr => [v, {foo: v, bar: v}] + // deepSet(arr, ['', 'bar'], v) // arr => [v, {foo: v, bar: v}, {bar: v}] + // + deepSet: function (o, keys, value, opts) { + var key, nextKey, tail, lastIdx, lastVal, f; + if (opts == null) { opts = {}; } + f = $.serializeJSON; + if (f.isUndefined(o)) { throw new Error("ArgumentError: param 'o' expected to be an object or array, found undefined"); } + if (!keys || keys.length === 0) { throw new Error("ArgumentError: param 'keys' expected to be an array with least one element"); } + + key = keys[0]; + + // Only one key, then it's not a deepSet, just assign the value. + if (keys.length === 1) { + if (key === '') { + o.push(value); // '' is used to push values into the array (assume o is an array) + } else { + o[key] = value; // other keys can be used as object keys or array indexes + } + + // With more keys is a deepSet. Apply recursively. + } else { + nextKey = keys[1]; + + // '' is used to push values into the array, + // with nextKey, set the value into the same object, in object[nextKey]. + // Covers the case of ['', 'foo'] and ['', 'var'] to push the object {foo, var}, and the case of nested arrays. + if (key === '') { + lastIdx = o.length - 1; // asume o is array + lastVal = o[lastIdx]; + if (f.isObject(lastVal) && (f.isUndefined(lastVal[nextKey]) || keys.length > 2)) { // if nextKey is not present in the last object element, or there are more keys to deep set + key = lastIdx; // then set the new value in the same object element + } else { + key = lastIdx + 1; // otherwise, point to set the next index in the array + } + } + + // '' is used to push values into the array "array[]" + if (nextKey === '') { + if (f.isUndefined(o[key]) || !$.isArray(o[key])) { + o[key] = []; // define (or override) as array to push values + } + } else { + if (opts.useIntKeysAsArrayIndex && f.isValidArrayIndex(nextKey)) { // if 1, 2, 3 ... then use an array, where nextKey is the index + if (f.isUndefined(o[key]) || !$.isArray(o[key])) { + o[key] = []; // define (or override) as array, to insert values using int keys as array indexes + } + } else { // for anything else, use an object, where nextKey is going to be the attribute name + if (f.isUndefined(o[key]) || !f.isObject(o[key])) { + o[key] = {}; // define (or override) as object, to set nested properties + } + } + } + + // Recursively set the inner object + tail = keys.slice(1); + f.deepSet(o[key], tail, value, opts); + } + }, + + // Fill the formAsArray object with values for the unchecked checkbox inputs, + // using the same format as the jquery.serializeArray function. + // The value of the unchecked values is determined from the opts.checkboxUncheckedValue + // and/or the data-unchecked-value attribute of the inputs. + readCheckboxUncheckedValues: function (formAsArray, $form, opts) { + var selector, $uncheckedCheckboxes, $el, dataUncheckedValue, f; + if (opts == null) { opts = {}; } + f = $.serializeJSON; + + selector = 'input[type=checkbox][name]:not(:checked):not([disabled])'; + $uncheckedCheckboxes = $form.find(selector).add($form.filter(selector)); + $uncheckedCheckboxes.each(function (i, el) { + $el = $(el); + dataUncheckedValue = $el.attr('data-unchecked-value'); + if(dataUncheckedValue) { // data-unchecked-value has precedence over option opts.checkboxUncheckedValue + formAsArray.push({name: el.name, value: dataUncheckedValue}); + } else { + if (!f.isUndefined(opts.checkboxUncheckedValue)) { + formAsArray.push({name: el.name, value: opts.checkboxUncheckedValue}); + } + } + }); + } + + }; + +})); diff --git a/js/jquery-serializejson/jquery.serializejson.min.js b/js/jquery-serializejson/jquery.serializejson.min.js new file mode 100644 index 0000000000..08e07581e3 --- /dev/null +++ b/js/jquery-serializejson/jquery.serializejson.min.js @@ -0,0 +1,10 @@ +/*! + SerializeJSON jQuery plugin. + https://github.com/marioizquierdo/jquery.serializeJSON + version 2.6.2 (May, 2015) + + Copyright (c) 2012, 2015 Mario Izquierdo + Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) + and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses. +*/ +!function(e){if("function"==typeof define&&define.amd)define(["jquery"],e);else if("object"==typeof exports){var n=require("jquery");module.exports=e(n)}else e(window.jQuery||window.Zepto||window.$)}(function(e){"use strict";e.fn.serializeJSON=function(n){var r,t,s,i,a,u,o;return u=e.serializeJSON,o=u.setupOpts(n),t=this.serializeArray(),u.readCheckboxUncheckedValues(t,this,o),r={},e.each(t,function(e,n){s=u.splitInputNameIntoKeysArray(n.name,o),i=s.pop(),"skip"!==i&&(a=u.parseValue(n.value,i,o),o.parseWithFunction&&"_"===i&&(a=o.parseWithFunction(a,n.name)),u.deepSet(r,s,a,o))}),r},e.serializeJSON={defaultOptions:{checkboxUncheckedValue:void 0,parseNumbers:!1,parseBooleans:!1,parseNulls:!1,parseAll:!1,parseWithFunction:null,customTypes:{},defaultTypes:{string:function(e){return String(e)},number:function(e){return Number(e)},"boolean":function(e){var n=["false","null","undefined","","0"];return-1===n.indexOf(e)},"null":function(e){var n=["false","null","undefined","","0"];return-1===n.indexOf(e)?e:null},array:function(e){return JSON.parse(e)},object:function(e){return JSON.parse(e)},auto:function(n){return e.serializeJSON.parseValue(n,null,{parseNumbers:!0,parseBooleans:!0,parseNulls:!0})}},useIntKeysAsArrayIndex:!1},setupOpts:function(n){var r,t,s,i,a,u;u=e.serializeJSON,null==n&&(n={}),s=u.defaultOptions||{},t=["checkboxUncheckedValue","parseNumbers","parseBooleans","parseNulls","parseAll","parseWithFunction","customTypes","defaultTypes","useIntKeysAsArrayIndex"];for(r in n)if(-1===t.indexOf(r))throw new Error("serializeJSON ERROR: invalid option '"+r+"'. Please use one of "+t.join(", "));return i=function(e){return n[e]!==!1&&""!==n[e]&&(n[e]||s[e])},a=i("parseAll"),{checkboxUncheckedValue:i("checkboxUncheckedValue"),parseNumbers:a||i("parseNumbers"),parseBooleans:a||i("parseBooleans"),parseNulls:a||i("parseNulls"),parseWithFunction:i("parseWithFunction"),typeFunctions:e.extend({},i("defaultTypes"),i("customTypes")),useIntKeysAsArrayIndex:i("useIntKeysAsArrayIndex")}},parseValue:function(n,r,t){var s,i;return i=e.serializeJSON,s=t.typeFunctions&&t.typeFunctions[r],s?s(n):t.parseNumbers&&i.isNumeric(n)?Number(n):!t.parseBooleans||"true"!==n&&"false"!==n?t.parseNulls&&"null"==n?null:n:"true"===n},isObject:function(e){return e===Object(e)},isUndefined:function(e){return void 0===e},isValidArrayIndex:function(e){return/^[0-9]+$/.test(String(e))},isNumeric:function(e){return e-parseFloat(e)>=0},optionKeys:function(e){if(Object.keys)return Object.keys(e);var n,r=[];for(n in e)r.push(n);return r},splitInputNameIntoKeysArray:function(n,r){var t,s,i,a,u;return u=e.serializeJSON,a=u.extractTypeFromInputName(n,r),s=a[0],i=a[1],t=s.split("["),t=e.map(t,function(e){return e.replace(/\]/g,"")}),""===t[0]&&t.shift(),t.push(i),t},extractTypeFromInputName:function(n,r){var t,s,i;if(t=n.match(/(.*):([^:]+)$/)){if(i=e.serializeJSON,s=i.optionKeys(r?r.typeFunctions:i.defaultOptions.defaultTypes),s.push("skip"),-1!==s.indexOf(t[2]))return[t[1],t[2]];throw new Error("serializeJSON ERROR: Invalid type "+t[2]+" found in input name '"+n+"', please use one of "+s.join(", "))}return[n,"_"]},deepSet:function(n,r,t,s){var i,a,u,o,l,c;if(null==s&&(s={}),c=e.serializeJSON,c.isUndefined(n))throw new Error("ArgumentError: param 'o' expected to be an object or array, found undefined");if(!r||0===r.length)throw new Error("ArgumentError: param 'keys' expected to be an array with least one element");i=r[0],1===r.length?""===i?n.push(t):n[i]=t:(a=r[1],""===i&&(o=n.length-1,l=n[o],i=c.isObject(l)&&(c.isUndefined(l[a])||r.length>2)?o:o+1),""===a?(c.isUndefined(n[i])||!e.isArray(n[i]))&&(n[i]=[]):s.useIntKeysAsArrayIndex&&c.isValidArrayIndex(a)?(c.isUndefined(n[i])||!e.isArray(n[i]))&&(n[i]=[]):(c.isUndefined(n[i])||!c.isObject(n[i]))&&(n[i]={}),u=r.slice(1),c.deepSet(n[i],u,t,s))},readCheckboxUncheckedValues:function(n,r,t){var s,i,a,u,o;null==t&&(t={}),o=e.serializeJSON,s="input[type=checkbox][name]:not(:checked):not([disabled])",i=r.find(s).add(r.filter(s)),i.each(function(r,s){a=e(s),u=a.attr("data-unchecked-value"),u?n.push({name:s.name,value:u}):o.isUndefined(t.checkboxUncheckedValue)||n.push({name:s.name,value:t.checkboxUncheckedValue})})}}}); \ No newline at end of file diff --git a/php/class-fieldmanager-customize-control.php b/php/class-fieldmanager-customize-control.php index 90d210a265..d757130179 100644 --- a/php/class-fieldmanager-customize-control.php +++ b/php/class-fieldmanager-customize-control.php @@ -26,10 +26,18 @@ class Fieldmanager_Customize_Control extends WP_Customize_Control { * Enqueue control-related scripts and styles. */ public function enqueue() { + wp_register_script( + 'fm-serializejson', + fieldmanager_get_baseurl() . 'js/jquery-serializejson/jquery.serializejson.min.js', + array(), + '2.0.0', + true + ); + fm_add_script( 'fm_customizer', 'js/fieldmanager-customize.js', - array( 'jquery', 'underscore', 'fieldmanager_script', 'customize-controls' ), + array( 'jquery', 'underscore', 'fieldmanager_script', 'customize-controls', 'fm-serializejson' ), '1.0.0', true ); From 96a9041e7e45a6bf3c2ef1fc6d0a987c1d346c1c Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 29 Jan 2016 20:49:24 -0500 Subject: [PATCH 17/76] stripslashes_deep(), use context API when saving stripslashes_deep() matches existing submenu saving behavior. --- php/context/class-fieldmanager-context-customizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php/context/class-fieldmanager-context-customizer.php b/php/context/class-fieldmanager-context-customizer.php index d7762a45df..aaa5de19f8 100644 --- a/php/context/class-fieldmanager-context-customizer.php +++ b/php/context/class-fieldmanager-context-customizer.php @@ -84,7 +84,7 @@ public function sanitize_callback( $value, $setting ) { } // Return the value after Fieldmanager takes a shot at it. - return $this->fm->presave_all( $value, $this->current_value ); + return stripslashes_deep( $this->prepare_data( $this->current_value, $value ) ); } /** From 1155bf5c9f4cfb05645d3a9dce30db65f4e25ec4 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 29 Jan 2016 20:52:04 -0500 Subject: [PATCH 18/76] Pass context, not field, to Customize control This lets the context manage the field object, rather than passing the field to both the context and the control. It also lets us use the context's own API for rendering instead of echoing it ourselves. --- php/class-fieldmanager-customize-control.php | 8 ++++---- .../class-fieldmanager-context-customizer.php | 13 ++++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/php/class-fieldmanager-customize-control.php b/php/class-fieldmanager-customize-control.php index d757130179..cbf1f6eb9d 100644 --- a/php/class-fieldmanager-customize-control.php +++ b/php/class-fieldmanager-customize-control.php @@ -9,11 +9,11 @@ */ class Fieldmanager_Customize_Control extends WP_Customize_Control { /** - * The field object to use. Supply via `$args['fm']`. + * The Fieldmanager context controlling this field. * - * @var Fieldmanager_Field + * @var Fieldmanager_Context */ - protected $fm; + protected $context; /** * The control 'type', used in scripts to identify FM controls. @@ -49,7 +49,7 @@ public function enqueue() { * @see Fieldmanager_Field::element_markup(). */ public function render_content() { - echo $this->fm->element_markup( $this->value() ); + $this->context->render_field(); } } endif; diff --git a/php/context/class-fieldmanager-context-customizer.php b/php/context/class-fieldmanager-context-customizer.php index aaa5de19f8..c1fd685dfe 100644 --- a/php/context/class-fieldmanager-context-customizer.php +++ b/php/context/class-fieldmanager-context-customizer.php @@ -65,6 +65,17 @@ public function customize_register( $manager ) { $this->init_current_value( $manager ); } + /** + * Exposes Fieldmanager_Context::render_field() for the control to call. + * + * @param array $args Unused. + */ + public function render_field( $args = array() ) { + parent::render_field( array( + 'data' => $this->current_value, + ) ); + } + /** * Filter a Customize setting value in un-slashed form. * @@ -113,8 +124,8 @@ protected function register( $manager ) { $manager->add_setting( $this->fm->name, $setting_args ); $manager->add_control( new Fieldmanager_Customize_Control( $manager, $this->fm->name, array( - 'fm' => $this->fm, 'section' => $this->fm->name, + 'context' => $this, ) ) ); } From 176460cba96ef3ad18d506135e2613e30fa744de Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 29 Jan 2016 20:56:50 -0500 Subject: [PATCH 19/76] Ensure Customize settings accept null default value --- .../class-fieldmanager-context-customizer.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/php/context/class-fieldmanager-context-customizer.php b/php/context/class-fieldmanager-context-customizer.php index c1fd685dfe..3b0daa65ad 100644 --- a/php/context/class-fieldmanager-context-customizer.php +++ b/php/context/class-fieldmanager-context-customizer.php @@ -118,10 +118,20 @@ protected function register( $manager ) { 'type' => 'option', ) ); - $setting_args['default'] = $this->fm->default_value; $setting_args['sanitize_callback'] = array( $this, 'sanitize_callback' ); $setting_args['section'] = $this->fm->name; - $manager->add_setting( $this->fm->name, $setting_args ); + $setting = $manager->add_setting( $this->fm->name, $setting_args ); + + /* + * If the field default is null, the setting constructor won't detect it + * Adding the setting as an ID first allows the filters in + * WP_Customize_Manager::add_setting() to run; adding it again as a + * WP_Customize_Setting instance replaces the first without re-running + * them. An alternate approach could be for FM to instatiate its own + * WP_Customize_Setting extension. + */ + $setting->default = $this->fm->default_value; + $manager->add_setting( $setting ); $manager->add_control( new Fieldmanager_Customize_Control( $manager, $this->fm->name, array( 'section' => $this->fm->name, From 43b320ae7b68d7de7a4fa8b8b185a23d631466dc Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 29 Jan 2016 21:16:53 -0500 Subject: [PATCH 20/76] Trigger an event after an FM RTE loads TinyMCE --- js/richtext.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/js/richtext.js b/js/richtext.js index fc6aca88fa..e3d50dd248 100644 --- a/js/richtext.js +++ b/js/richtext.js @@ -36,6 +36,16 @@ try { if ( 'html' !== fm.richtextarea.mode_enabled( this ) ) { + tinyMCEPreInit.mceInit[ ed_id ].setup = function ( ed ) { + ed.on( 'init', function( args ) { + /** + * Event after TinyMCE loads for an Fieldmanager_RichTextArea. + * + * @var {Object} TinyMCE instance. + */ + $( document ).trigger( 'fm_richtext_init', ed ); + }); + }; tinymce.init( tinyMCEPreInit.mceInit[ ed_id ] ); $( this ).closest( '.wp-editor-wrap' ).on( 'click.wp-editor', function() { if ( this.id ) { From 3c13c568ed7cb2a2711d053a5f7a856c89e78340 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 29 Jan 2016 21:18:54 -0500 Subject: [PATCH 21/76] Check for getUserSetting() before calling it --- js/richtext.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/richtext.js b/js/richtext.js index e3d50dd248..6f3cd15e2d 100644 --- a/js/richtext.js +++ b/js/richtext.js @@ -138,7 +138,10 @@ setTimeout( fm.richtextarea.reset_core_editor_mode, 50 ); } ); - var core_editor_state = getUserSetting( 'editor' ); + var core_editor_state; + if ( typeof getUserSetting === 'function' ) { + core_editor_state = getUserSetting( 'editor' ); + } /** * If the main editor's state changes, note that change. From 8066242aed6b421d3003a63be2ef991533914356 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 29 Jan 2016 21:19:27 -0500 Subject: [PATCH 22/76] Add support for RichTextAreas in the Customizer --- js/fieldmanager-customize.js | 28 +++++++++++++++++--- php/class-fieldmanager-customize-control.php | 2 +- php/class-fieldmanager-richtextarea.php | 26 ++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 9e8b8d95a2..f0fbaae652 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -9,6 +9,13 @@ (function( $, api, _ ) { 'use strict'; + /** + * Debounce interval between looking for changes after a 'keyup'. + * + * @type {Number} + */ + var keyupDebounceInterval = 500; + /** * Fires when an .fm-element input triggers a 'change' event. * @@ -74,13 +81,26 @@ * * @param {Event} e Event object. * @param {jQuery} $wrapper .media-wrapper jQuery object. - * @param {object} attachment Attachment attributes. - * @param {object} wp Global WordPress JS API. + * @param {Object} attachment Attachment attributes. + * @param {Object} wp Global WordPress JS API. */ var onFieldmanagerMediaPreview = function( e, $wrapper, attachment, wp ) { reserializeControlsContainingElement( e.target ); }; + /** + * Fires after TinyMCE initializes in a Fieldmanager richtext field. + * + * @param {Event} e Event object. + * @param {Object} ed TinyMCE instance. + */ + var onFmRichtextInit = function( e, ed ) { + ed.on( 'keyup AddUndo', _.debounce( function () { + ed.save(); + reserializeControlsContainingElement( document.getElementById( ed.id ) ); + }, keyupDebounceInterval ) ); + }; + /** * Fires after clicking the "Remove" link of a Fieldmanager media field. * @@ -156,6 +176,7 @@ var bindToSectionExpanded = function( section ) { section.container.bind( 'expanded', function() { $( document ).trigger( 'fm_customizer_control_section_expanded' ); + fm.richtextarea.add_rte_to_visible_textareas(); }); }; @@ -172,7 +193,7 @@ * time it should affect the preview is when removing an autocomplete * selection. We allow that to occur normally. */ - $document.on( 'keyup', '.fm-element:not(.fm-autocomplete)', _.debounce( onFmElementKeyup, 500 ) ); + $document.on( 'keyup', '.fm-element:not(.fm-autocomplete)', _.debounce( onFmElementKeyup, keyupDebounceInterval ) ); $document.on( 'keyup', '.fm-autocomplete', onFmElementKeyup ); $document.on( 'change', '.fm-element', onFmElementChange ); @@ -180,6 +201,7 @@ $document.on( 'click', '.fmjs-remove', onFmjsRemoveClick ); $document.on( 'fm_sortable_drop', onFmSortableDrop ); $document.on( 'fieldmanager_media_preview', onFieldmanagerMediaPreview ); + $document.on( 'fm_richtext_init', onFmRichtextInit ); /* * Hacky, because it always prompts the user to save. Unlike when we diff --git a/php/class-fieldmanager-customize-control.php b/php/class-fieldmanager-customize-control.php index cbf1f6eb9d..c701176d14 100644 --- a/php/class-fieldmanager-customize-control.php +++ b/php/class-fieldmanager-customize-control.php @@ -37,7 +37,7 @@ public function enqueue() { fm_add_script( 'fm_customizer', 'js/fieldmanager-customize.js', - array( 'jquery', 'underscore', 'fieldmanager_script', 'customize-controls', 'fm-serializejson' ), + array( 'jquery', 'underscore', 'editor', 'quicktags', 'fieldmanager_script', 'customize-controls', 'fm-serializejson' ), '1.0.0', true ); diff --git a/php/class-fieldmanager-richtextarea.php b/php/class-fieldmanager-richtextarea.php index ea3755bab6..c694acd078 100644 --- a/php/class-fieldmanager-richtextarea.php +++ b/php/class-fieldmanager-richtextarea.php @@ -74,6 +74,13 @@ class Fieldmanager_RichTextArea extends Fieldmanager_Field { */ protected $edit_config = false; + /** + * Whether this class is hooked into the Customizer to print editor scripts. + * + * @var bool + */ + public static $has_registered_customize_scripts = false; + /** * Construct defaults for this field. * @@ -113,6 +120,7 @@ public function form_element( $value = '' ) { 'textarea_name' => $this->get_form_name(), 'editor_class' => 'fm-element fm-richtext', 'tinymce' => array( 'wp_skip_init' => true ), + 'teeny' => is_customize_preview(), ) ); if ( $proto ) { @@ -252,6 +260,24 @@ protected function add_editor_filters() { // removing whatever it added. remove_filter( 'the_editor_content', 'wp_htmledit_pre' ); remove_filter( 'the_editor_content', 'wp_richedit_pre' ); + + if ( ! self::$has_registered_customize_scripts ) { + add_action( 'customize_controls_print_footer_scripts', array( $this, 'customize_controls_print_footer_scripts' ), 50 ); + self::$has_registered_customize_scripts = true; + } + } + + /** + * Print Customizer control scripts in the footer. + */ + public function customize_controls_print_footer_scripts() { + if ( class_exists( '_WP_Editors' ) ) { + if ( false === has_action( 'customize_controls_print_footer_scripts', array( '_WP_Editors', 'editor_js' ) ) ) { + // Print the necessary JS for an RTE, unless we can't or suspect it's already there. + _WP_Editors::editor_js(); + _WP_Editors::enqueue_scripts(); + } + } } /** From 591ae7b58a68516c43f3456328c5dd1920f5be79 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 29 Jan 2016 22:29:56 -0500 Subject: [PATCH 23/76] Don't reserialize each control on 'ready' This takes a slightly more limited approach to reserializing controls so that their default values take effect. --- js/fieldmanager-customize.js | 48 ++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index f0fbaae652..34ca0c3835 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -168,15 +168,36 @@ }; /** - * Trigger a Fieldmanager event when a Customizer section expands. + * Fires when a Customizer Section expands. * - * We bind to sections whether or not they have FM controls in case a - * control is added dynamically. + * @param {Object} section Customizer Section object. */ - var bindToSectionExpanded = function( section ) { - section.container.bind( 'expanded', function() { - $( document ).trigger( 'fm_customizer_control_section_expanded' ); + var onSectionExpanded = function( section ) { + /* + * Trigger a Fieldmanager event when a Customizer section expands. + * + * We bind to sections whether or not they have FM controls in case a + * control is added dynamically. + */ + $( document ).trigger( 'fm_customizer_control_section_expanded' ); + + if ( fm.richtextarea ) { fm.richtextarea.add_rte_to_visible_textareas(); + } + + /* + * Reserialize any Fieldmanager controls in this section with null + * values. We assume null indicates nothing has been saved to the + * database, so we want to make sure default values take effect in the + * preview and are submitted on save as they would be in other contexts. + */ + _.each( section.controls(), function ( control ) { + if ( + control.settings.default && + null === control.settings.default.get() + ) { + reserializeControl( control ); + } }); }; @@ -202,17 +223,6 @@ $document.on( 'fm_sortable_drop', onFmSortableDrop ); $document.on( 'fieldmanager_media_preview', onFieldmanagerMediaPreview ); $document.on( 'fm_richtext_init', onFmRichtextInit ); - - /* - * Hacky, because it always prompts the user to save. Unlike when we - * create individual settings, the field "value" is always going to - * change when it's reserialized. It also ensures defaults are applied - * when the Customizer loads. But if the user saved those changes, the - * defaults would be applied, as opposed to a submenu page, where there - * isn't an AYS prompt. Creating a query string on the PHP side might - * work, but that's even weirder. - */ - reserializeEachControl(); }; /** @@ -236,7 +246,9 @@ */ var addSection = function( section ) { // It would be more efficient to do this only when adding an FM control to a section. - bindToSectionExpanded( section ); + section.container.bind( 'expanded', function () { + onSectionExpanded( section ); + } ); }; if ( typeof api !== 'undefined' ) { From 1d6148cf518660ed8ccad68890716504461834bd Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sat, 30 Jan 2016 21:33:00 -0500 Subject: [PATCH 24/76] Expose setting functions in the global fm object --- js/fieldmanager-customize.js | 127 ++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 61 deletions(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 34ca0c3835..a6d871eba0 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -1,14 +1,65 @@ -/* global document, jQuery, wp, _ */ +/* global document, jQuery, wp, _, fm */ /** * Integrate Fieldmanager and the Customizer. * * @param {function} $ jQuery - * @param {function} api wp.customize API. + * @param {function} api Customizer API. * @param {function} _ Underscore + * @param {Object} fm Fieldmanager API. */ -(function( $, api, _ ) { +(function( $, api, _, fm ) { 'use strict'; + fm.customize = { + /** + * Set the values of all Fieldmanager controls. + */ + setEachControl: function () { + api.control.each( this.setControl ); + }, + + /** + * Set the value of any Fieldmanager control with a given element in its container. + * + * @param {Element} el Element to look for. + */ + setControlsContainingElement: function ( el ) { + var that = this; + + api.control.each(function( control ) { + if ( control.container.find( el ).length ) { + that.setControl( control ); + } + }); + }, + + /** + * Set a Fieldmanager setting to its control's form values. + * + * @param {Object} control Customizer Control object. + */ + setControl: function ( control ) { + var $element; + var serialized; + var value; + + if ( 'fieldmanager' !== control.params.type ) { + return; + } + + $element = control.container.find( '.fm-element' ); + + if ( $.serializeJSON ) { + serialized = $element.serializeJSON(); + value = serialized[ control.id ]; + } else { + value = $element.serialize(); + } + + control.setting.set( value ); + }, + }; + /** * Debounce interval between looking for changes after a 'keyup'. * @@ -22,7 +73,7 @@ * @param {Event} e Event object. */ var onFmElementChange = function( e ) { - reserializeControlsContainingElement( e.target ); + fm.customize.setControlsContainingElement( e.target ); }; /** @@ -54,7 +105,7 @@ } } - reserializeControlsContainingElement( e.target ); + fm.customize.setControlsContainingElement( e.target ); }; /** @@ -64,7 +115,7 @@ * @param {Element} el The sorted element. */ var onFmSortableDrop = function ( e, el ) { - reserializeControlsContainingElement( el ); + fm.customize.setControlsContainingElement( el ); }; /** @@ -73,7 +124,7 @@ * @param {Event} e Event object. */ var onFmAddedElement = function( e ) { - reserializeControlsContainingElement( e.target ); + fm.customize.setControlsContainingElement( e.target ); }; /** @@ -85,7 +136,7 @@ * @param {Object} wp Global WordPress JS API. */ var onFieldmanagerMediaPreview = function( e, $wrapper, attachment, wp ) { - reserializeControlsContainingElement( e.target ); + fm.customize.setControlsContainingElement( e.target ); }; /** @@ -97,7 +148,7 @@ var onFmRichtextInit = function( e, ed ) { ed.on( 'keyup AddUndo', _.debounce( function () { ed.save(); - reserializeControlsContainingElement( document.getElementById( ed.id ) ); + fm.customize.setControlsContainingElement( document.getElementById( ed.id ) ); }, keyupDebounceInterval ) ); }; @@ -107,8 +158,8 @@ * @param {Event} e Event object. */ var onFmMediaRemoveClick = function ( e ) { - // The control no longer contains the element, so reserialize all of them. - reserializeEachControl(); + // The control no longer contains the element, so set all of them. + fm.customize.setEachControl(); }; /** @@ -117,56 +168,10 @@ * @param {Event} e Event object. */ var onFmjsRemoveClick = function ( e ) { - // The control no longer contains the element, so reserialize all of them. - reserializeEachControl(); + // The control no longer contains the element, so set all of them. + fm.customize.setEachControl(); }; - /** - * Set the values of all Fieldmanager controls. - */ - var reserializeEachControl = function() { - api.control.each( reserializeControl ); - }; - - /** - * Set the value of any Fieldmanager control with a given element in its container. - * - * @param {Element} el Element to look for. - */ - var reserializeControlsContainingElement = function( el ) { - api.control.each(function( control ) { - if ( control.container.find( el ).length ) { - reserializeControl( control ); - } - }); - }; - - /** - * Set a Fieldmanager control to its form values. - * - * @param {Object} control Customizer Control object. - */ - var reserializeControl = function ( control ) { - var $element; - var serialized; - var value; - - if ( 'fieldmanager' !== control.params.type ) { - return; - } - - $element = control.container.find( '.fm-element' ); - - if ( $.serializeJSON ) { - serialized = $element.serializeJSON(); - value = serialized[ control.id ]; - } else { - value = $element.serialize(); - } - - control.setting.set( value ); - }; - /** * Fires when a Customizer Section expands. * @@ -196,7 +201,7 @@ control.settings.default && null === control.settings.default.get() ) { - reserializeControl( control ); + fm.customize.setControl( control ); } }); }; @@ -256,4 +261,4 @@ api.bind( 'error', error ); api.section.bind( 'add', addSection ); } -})( jQuery, wp.customize, _ ); +})( jQuery, wp.customize, _, fm ); From 5d51f6e4cd43b6cc22883b665d8e20e653542b0b Mon Sep 17 00:00:00 2001 From: David Herrera Date: Thu, 4 Feb 2016 17:13:03 -0500 Subject: [PATCH 25/76] Include fm-customize in script tests --- php/class-fieldmanager-customize-control.php | 2 +- tests/php/test-fieldmanager-script-loading.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/php/class-fieldmanager-customize-control.php b/php/class-fieldmanager-customize-control.php index c701176d14..5b9ebaab6c 100644 --- a/php/class-fieldmanager-customize-control.php +++ b/php/class-fieldmanager-customize-control.php @@ -35,7 +35,7 @@ public function enqueue() { ); fm_add_script( - 'fm_customizer', + 'fm-customize', 'js/fieldmanager-customize.js', array( 'jquery', 'underscore', 'editor', 'quicktags', 'fieldmanager_script', 'customize-controls', 'fm-serializejson' ), '1.0.0', diff --git a/tests/php/test-fieldmanager-script-loading.php b/tests/php/test-fieldmanager-script-loading.php index f3c79f027c..63b309a779 100644 --- a/tests/php/test-fieldmanager-script-loading.php +++ b/tests/php/test-fieldmanager-script-loading.php @@ -11,6 +11,9 @@ class Test_Fieldmanager_Script_Loading extends WP_UnitTestCase { public function setUp() { parent::setUp(); + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + $customize_manager = new WP_Customize_Manager(); + // Spoof is_admin() for fm_add_script(). $this->screen = get_current_screen(); set_current_screen( 'dashboard-user' ); @@ -36,6 +39,13 @@ public function setUp() { new Fieldmanager_RichTextArea( 'Test' ); new Fieldmanager_Colorpicker( 'Test' ); + $control = new Fieldmanager_Customize_Control( + $customize_manager, + 'test', + array( 'context' => $this->getMock( 'Fieldmanager_Context' ) ) + ); + $control->enqueue(); + do_action( 'wp_enqueue_scripts' ); do_action( 'admin_enqueue_scripts' ); } @@ -69,6 +79,7 @@ public function script_data() { array( 'fm_select_js', array() ), array( 'grid', array() ), array( 'fm_colorpicker', array( 'jquery', 'wp-color-picker' ) ), + array( 'fm-customize', array( 'jquery', 'underscore', 'editor', 'quicktags', 'fieldmanager_script', 'customize-controls', 'fm-serializejson' ) ), ); } From 7c991b8dd77f1ff1300f3b1ccce49ba30e9345d0 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 7 Feb 2016 18:26:16 -0500 Subject: [PATCH 26/76] Add support for Colorpickers in the Customizer --- js/fieldmanager-colorpicker.js | 23 +++++++++++++++++++++-- js/fieldmanager-customize.js | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/js/fieldmanager-colorpicker.js b/js/fieldmanager-colorpicker.js index 4d6fc278f8..19fbde69ab 100644 --- a/js/fieldmanager-colorpicker.js +++ b/js/fieldmanager-colorpicker.js @@ -2,9 +2,28 @@ fm.colorpicker = { init: function() { - $( '.fm-colorpicker-popup:visible' ).wpColorPicker(); + $( '.fm-colorpicker-popup:visible' ).wpColorPicker({ + change: function ( e, ui ) { + // Make sure the input's value attribute also changes. + $( this ).attr( 'value', ui.color.toString() ); + fm.colorpicker.triggerUpdateEvent( this ); + }, + clear: function () { + // Make sure the input's value attribute also changes. + $( this ).attr( 'value', '' ); + fm.colorpicker.triggerUpdateEvent( this ); + }, + }); + }, + triggerUpdateEvent: function ( el ) { + /** + * Trigger a common event for a value 'change' or 'clear'. + * + * @var {Element} Colorpicker element. + */ + $( document ).trigger( 'fm_colorpicker_update', el ); } - } + }; $( document ).ready( fm.colorpicker.init ); $( document ).on( 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab', fm.colorpicker.init ); diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index a6d871eba0..b68e55a3d8 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -152,6 +152,16 @@ }, keyupDebounceInterval ) ); }; + /** + * Fires after a Fieldmanager colorpicker field updates. + * + * @param {Event} e Event object. + * @param {Element} el Colorpicker element. + */ + var onFmColorpickerUpdate = function( e, el ) { + fm.customize.setControlsContainingElement( el ); + }; + /** * Fires after clicking the "Remove" link of a Fieldmanager media field. * @@ -190,6 +200,10 @@ fm.richtextarea.add_rte_to_visible_textareas(); } + if ( fm.colorpicker ) { + fm.colorpicker.init(); + } + /* * Reserialize any Fieldmanager controls in this section with null * values. We assume null indicates nothing has been saved to the @@ -228,6 +242,7 @@ $document.on( 'fm_sortable_drop', onFmSortableDrop ); $document.on( 'fieldmanager_media_preview', onFieldmanagerMediaPreview ); $document.on( 'fm_richtext_init', onFmRichtextInit ); + $document.on( 'fm_colorpicker_update', onFmColorpickerUpdate ); }; /** From 9e98884dfefc194088c49648abdc16fd435f94f3 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Wed, 10 Feb 2016 22:27:28 -0500 Subject: [PATCH 27/76] Introduce Fieldmanager_Customize_Setting The new object sets the default value, sanitize callback, and type where those were previously passed as constructor arguments. we also extend the _preview_filter() so we can call value() during the default sanitizing callback, fetching the current option value for Fieldmanager_Field::presave_all(), without creating an infinite loop. Being able to call value() means we can remove the Fieldmanager_Context_Customizer::current_value logic, which didn't account for newly saved values from the Customizer anyway. Also includes a bit of restructuring to make the context and the objects it creates easier to extend. Adds PHP tests for new classes. --- fieldmanager.php | 4 + php/class-fieldmanager-customize-control.php | 23 +- php/class-fieldmanager-customize-setting.php | 68 ++++ .../class-fieldmanager-context-customizer.php | 117 ++++--- tests/php/bootstrap.php | 2 + .../test-fieldmanager-context-customizer.php | 303 ++++++++++++++++++ .../test-fieldmanager-customize-control.php | 92 ++++++ .../test-fieldmanager-customize-setting.php | 78 +++++ .../php/test-fieldmanager-script-loading.php | 6 +- ...s-fieldmanager-customizer-unittestcase.php | 15 + 10 files changed, 641 insertions(+), 67 deletions(-) create mode 100644 php/class-fieldmanager-customize-setting.php create mode 100644 tests/php/test-fieldmanager-context-customizer.php create mode 100644 tests/php/test-fieldmanager-customize-control.php create mode 100644 tests/php/test-fieldmanager-customize-setting.php create mode 100644 tests/php/testcase/class-fieldmanager-customizer-unittestcase.php diff --git a/fieldmanager.php b/fieldmanager.php index c758108f24..81bcd2592c 100644 --- a/fieldmanager.php +++ b/fieldmanager.php @@ -74,6 +74,10 @@ function fieldmanager_load_class( $class ) { return fieldmanager_load_file( 'class-fieldmanager-customize-control.php' ); } + if ( 'Fieldmanager_Customize_Setting' === $class ) { + return fieldmanager_load_file( 'class-fieldmanager-customize-setting.php' ); + } + return fieldmanager_load_file( 'class-fieldmanager-' . $class_id . '.php', $class ); } diff --git a/php/class-fieldmanager-customize-control.php b/php/class-fieldmanager-customize-control.php index 5b9ebaab6c..c3ce9db806 100644 --- a/php/class-fieldmanager-customize-control.php +++ b/php/class-fieldmanager-customize-control.php @@ -22,6 +22,23 @@ class Fieldmanager_Customize_Control extends WP_Customize_Control { */ public $type = 'fieldmanager'; + /** + * Constructor. + * + * @param WP_Customize_Manager $manager + * @param string $id Control ID. + * @param array $args Control arguments, including $context. + */ + public function __construct( $manager, $id, $args = array() ) { + parent::__construct( $manager, $id, $args ); + + if ( ! ( $this->context instanceof Fieldmanager_Context_Customizer ) && FM_DEBUG ) { + throw new FM_Developer_Exception( + __( 'Fieldmanager_Customize_Control requires a Fieldmanager_Context_Customizer', 'fieldmanager' ) + ); + } + } + /** * Enqueue control-related scripts and styles. */ @@ -46,10 +63,10 @@ public function enqueue() { /** * Render the control's content. * - * @see Fieldmanager_Field::element_markup(). + * @see Fieldmanager_Context::render_field(). */ - public function render_content() { - $this->context->render_field(); + protected function render_content() { + $this->context->render_field( array( 'data' => $this->value() ) ); } } endif; diff --git a/php/class-fieldmanager-customize-setting.php b/php/class-fieldmanager-customize-setting.php new file mode 100644 index 0000000000..f0629388bb --- /dev/null +++ b/php/class-fieldmanager-customize-setting.php @@ -0,0 +1,68 @@ +context = $args['context']; + + // Set the default without checking isset() to support null values. + $this->default = $this->context->fm->default_value; + + // Sanitize with the context. + $this->sanitize_callback = array( $this->context, 'sanitize_callback' ); + + // Use the Fieldmanager submenu default. + $this->type = 'option'; + } elseif ( FM_DEBUG ) { + throw new FM_Developer_Exception( __( 'Fieldmanager_Customize_Setting requires a Fieldmanager_Context_Customizer', 'fieldmanager' ) ); + } + + parent::__construct( $manager, $id, $args ); + } + + /** + * Filter non-multidimensional theme mods and options. + * + * Settings created with the Customizer context are non-multidimensional + * by default. If you create your own multidimensional settings, you + * might need to extend _multidimensional_preview_filter() accordingly. + * + * @param mixed $original Old value. + * @return mixed New or old value. + */ + public function _preview_filter( $original ) { + /* + * Don't continue to the parent _preview_filter() while sanitizing. + * _preview_filter() eventually calls sanitize_callback(), which + * calls Fieldmanager_Context_Customizer::sanitize_callback(), which + * calls WP_Customize_Setting::value(), which ends up back here. + */ + if ( doing_filter( "customize_sanitize_{$this->id}" ) ) { + return $original; + } + + return parent::_preview_filter( $original ); + } + } +endif; diff --git a/php/context/class-fieldmanager-context-customizer.php b/php/context/class-fieldmanager-context-customizer.php index 3b0daa65ad..78cfc48d09 100644 --- a/php/context/class-fieldmanager-context-customizer.php +++ b/php/context/class-fieldmanager-context-customizer.php @@ -10,24 +10,21 @@ */ class Fieldmanager_Context_Customizer extends Fieldmanager_Context { /** - * The value of the setting before any changes are submitted via the Customizer. - * - * @see Fieldmanager_Field::presave_all(). + * @var string|array $args { + * The title to use with the {@see WP_Customize_Section}, or arrays of + * arguments to construct Customizer objects. * - * @var mixed + * @type array $section_args Arguments for constructing a WP_Customize_Section. + * @type array $setting_args Arguments for constructing a {@see Fieldmanager_Customize_Setting}. + * @type array $control_args Arguments for constructing a {@see Fieldmanager_Customize_Control}. + * } */ - protected $current_value; + protected $args; /** * Constructor. * - * @param string|array $args { - * The title to use with the {@see WP_Customize_Section}, or arrays of - * arguments to construct the Customizer section and setting. - * - * @type array $section_args Arguments for constructing a WP_Customize_Section. - * @type array $setting_args Arguments for constructing a {@see WP_Customize_Setting}. - * } + * @param string|array $args Section title or Customizer object arguments. * @param Fieldmanager_Field $fm Field object to add to the Customizer. */ public function __construct( $args, $fm ) { @@ -38,6 +35,7 @@ public function __construct( $args, $fm ) { $this->args = wp_parse_args( $args, array( 'section_args' => array(), 'setting_args' => array(), + 'control_args' => array(), ) ); $this->fm = $fm; @@ -52,17 +50,6 @@ public function __construct( $args, $fm ) { */ public function customize_register( $manager ) { $this->register( $manager ); - /* - * Get the current setting value for Fieldmanager_Field::presave_all() - * before the setting's preview method is called. - * - * WP_Customize_Setting::preview() adds filters to get_option() and - * get_theme_mod() that eventually call the setting's sanitize() method. - * Attempting to call WP_Customize_Setting::value() inside of - * Fieldmanager_Context_Customizer::sanitize_callback() creates an - * infinite loop. - */ - $this->init_current_value( $manager ); } /** @@ -71,9 +58,7 @@ public function customize_register( $manager ) { * @param array $args Unused. */ public function render_field( $args = array() ) { - parent::render_field( array( - 'data' => $this->current_value, - ) ); + return parent::render_field( $args ); } /** @@ -89,13 +74,13 @@ public function sanitize_callback( $value, $setting ) { parse_str( $value, $value ); } - if ( is_array( $value ) && isset( $value[ $this->fm->name ] ) ) { + if ( is_array( $value ) && array_key_exists( $this->fm->name, $value ) ) { // If the option name is the top-level array key, get just the value. $value = $value[ $this->fm->name ]; } // Return the value after Fieldmanager takes a shot at it. - return stripslashes_deep( $this->prepare_data( $this->current_value, $value ) ); + return stripslashes_deep( $this->prepare_data( $setting->value(), $value ) ); } /** @@ -104,47 +89,53 @@ public function sanitize_callback( $value, $setting ) { * @param WP_Customize_Manager $manager WP_Customize_Manager instance. */ protected function register( $manager ) { - $manager->add_section( $this->fm->name, wp_parse_args( - $this->args['section_args'], - array() - ) ); - - // Set Fieldmanager defaults after parsing the user args, then register the setting. - $setting_args = wp_parse_args( - $this->args['setting_args'], - array( - // Use the capability passed to Fieldmanager_Field::add_customizer_section(). - 'capability' => $manager->get_section( $this->fm->name )->capability, - 'type' => 'option', - ) - ); - $setting_args['sanitize_callback'] = array( $this, 'sanitize_callback' ); - $setting_args['section'] = $this->fm->name; - $setting = $manager->add_setting( $this->fm->name, $setting_args ); + $this->register_section( $manager ); + $this->register_setting( $manager ); + $this->register_control( $manager ); + } - /* - * If the field default is null, the setting constructor won't detect it - * Adding the setting as an ID first allows the filters in - * WP_Customize_Manager::add_setting() to run; adding it again as a - * WP_Customize_Setting instance replaces the first without re-running - * them. An alternate approach could be for FM to instatiate its own - * WP_Customize_Setting extension. - */ - $setting->default = $this->fm->default_value; - $manager->add_setting( $setting ); + /** + * Add a Customizer section for this field. + * + * @param WP_Customize_Manager $manager + * @return WP_Customize_Section Section object, where supported. + */ + protected function register_section( $manager ) { + return $manager->add_section( $this->fm->name, $this->args['section_args'] ); + } - $manager->add_control( new Fieldmanager_Customize_Control( $manager, $this->fm->name, array( - 'section' => $this->fm->name, - 'context' => $this, - ) ) ); + /** + * Add a Customizer setting for this field. + * + * @param WP_Customize_Manager $manager + * @return Fieldmanager_Customize_Setting Setting object, where supported. + */ + protected function register_setting( $manager ) { + return $manager->add_setting( + new Fieldmanager_Customize_Setting( $manager, $this->fm->name, wp_parse_args( + $this->args['setting_args'], + array( + 'context' => $this, + ) + ) ) + ); } /** - * Initialize $current_value for the type of Customizer setting. + * Add a Customizer control for this field. * - * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + * @param WP_Customize_Manager $manager + * @return Fieldmanager_Customize_Control Control object, where supported. */ - protected function init_current_value( $manager ) { - $this->current_value = $manager->get_setting( $this->fm->name )->value(); + protected function register_control( $manager ) { + return $manager->add_control( + new Fieldmanager_Customize_Control( $manager, $this->fm->name, wp_parse_args( + $this->args['control_args'], + array( + 'section' => $this->fm->name, + 'context' => $this, + ) + ) ) + ); } } diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php index 2face6a065..04469800fd 100644 --- a/tests/php/bootstrap.php +++ b/tests/php/bootstrap.php @@ -25,6 +25,8 @@ function _manually_load_plugin() { require $_tests_dir . '/includes/bootstrap.php'; +require dirname( __FILE__ ) . '/testcase/class-fieldmanager-customizer-unittestcase.php'; + /** * Is the current version of WordPress at least ... ? * diff --git a/tests/php/test-fieldmanager-context-customizer.php b/tests/php/test-fieldmanager-context-customizer.php new file mode 100644 index 0000000000..8176034710 --- /dev/null +++ b/tests/php/test-fieldmanager-context-customizer.php @@ -0,0 +1,303 @@ +field = new Fieldmanager_TextField( array( 'name' => 'foo' ) ); + } + + function test_bare_section() { + new Fieldmanager_Context_Customizer( array(), $this->field ); + $this->register(); + $this->assertInstanceOf( 'WP_Customize_Section', $this->manager->get_section( $this->field->name ) ); + } + + function test_section_title() { + $title = rand_str(); + + new Fieldmanager_Context_Customizer( $title, $this->field ); + $this->register(); + $this->assertSame( $title, $this->manager->get_section( $this->field->name )->title ); + } + + function test_section_args() { + $title = rand_str(); + $priority = rand( 0, 100 ); + + new Fieldmanager_Context_Customizer( array( + 'section_args' => array( + 'title' => $title, + 'priority' => $priority, + ), + ), $this->field ); + + $this->register(); + + $actual = $this->manager->get_section( $this->field->name ); + $this->assertSame( $title, $actual->title ); + $this->assertSame( $priority, $actual->priority ); + } + + function test_bare_setting() { + new Fieldmanager_Context_Customizer( 'Foo', $this->field ); + $this->register(); + $this->assertInstanceOf( 'Fieldmanager_Customize_Setting', $this->manager->get_setting( $this->field->name ) ); + } + + function test_setting_args() { + $capability = 'edit_thing'; + $default = rand_str(); + + new Fieldmanager_Context_Customizer( array( + 'setting_args' => array( + 'capability' => $capability, + 'default' => $default + ), + ), $this->field ); + + $this->register(); + + $actual = $this->manager->get_setting( $this->field->name ); + $this->assertSame( $capability, $actual->capability ); + $this->assertSame( $default, $actual->default ); + } + + function test_bare_control() { + new Fieldmanager_Context_Customizer( 'Foo', $this->field ); + + $this->register(); + + $actual = $this->manager->get_control( $this->field->name ); + $this->assertInstanceOf( 'Fieldmanager_Customize_Control', $actual ); + $this->assertSame( $this->field->name, $actual->section ); + $this->assertInstanceOf( 'Fieldmanager_Customize_Setting', $actual->settings['default'] ); + } + + function test_control_args() { + $label = rand_str(); + $section = rand_str(); + + new Fieldmanager_Context_Customizer( array( + 'control_args' => array( + 'label' => $label, + 'section' => $section, + ), + ), $this->field ); + + $this->register(); + + $actual = $this->manager->get_control( $this->field->name ); + $this->assertSame( $section, $actual->section ); + $this->assertSame( $label, $actual->label ); + } + + function test_multiple_args() { + $title = rand_str(); + $theme_supports = rand_str(); + $sanitize_callback = 'absint'; + $type = 'theme_mod'; + $input_attrs = array( rand_str(), rand_str() ); + + new Fieldmanager_Context_Customizer( array( + 'section_args' => array( + 'title' => $title, + 'theme_supports' => $theme_supports, + ), + 'setting_args' => array( + 'sanitize_callback' => $sanitize_callback, + 'type' => $type, + ), + 'control_args' => array( + 'input_attrs' => $input_attrs, + ), + ), $this->field ); + + $this->register(); + + $section = $this->manager->get_section( $this->field->name ); + $this->assertSame( $title, $section->title ); + $this->assertSame( $theme_supports, $section->theme_supports ); + + $setting = $this->manager->get_setting( $this->field->name ); + $this->assertSame( $sanitize_callback, $setting->sanitize_callback ); + $this->assertSame( $type, $setting->type ); + + $control = $this->manager->get_control( $this->field->name ); + $this->assertSame( $input_attrs, $control->input_attrs ); + } + + /** + * @expectedException FM_Exception + */ + function test_sanitize_string() { + $value = rand_str(); + + $context = new Fieldmanager_Context_Customizer( 'Foo', $this->field ); + $this->register(); + $setting = $this->manager->get_setting( $this->field->name ); + + $this->assertSame( $value, $context->sanitize_callback( $value, $setting ) ); + $this->assertSame( $value, $context->sanitize_callback( "{$this->field->name}={$value}", $setting ) ); + $context->sanitize_callback( array( 'Not', 'a', 'string' ), $setting ); + } + + function test_sanitize_group() { + $fm = new Fieldmanager_Group( array( + 'name' => 'option_fields', + 'limit' => 0, + 'children' => array( + 'repeatable_group' => new Fieldmanager_Group( array( + 'limit' => 0, + 'children' => array( + 'text' => new Fieldmanager_Textfield( 'Text Field' ), + 'autocomplete' => new Fieldmanager_Autocomplete( 'Autocomplete', array( 'datasource' => new Fieldmanager_Datasource_Post() ) ), + 'local_data' => new Fieldmanager_Autocomplete( 'Autocomplete without ajax', array( 'datasource' => new Fieldmanager_Datasource( array( 'options' => array() ) ) ) ), + 'textarea' => new Fieldmanager_TextArea( 'TextArea' ), + 'media' => new Fieldmanager_Media( 'Media File' ), + 'checkbox' => new Fieldmanager_Checkbox( 'Checkbox' ), + 'radios' => new Fieldmanager_Radios( 'Radio Buttons', array( 'options' => array( 'One', 'Two', 'Three' ) ) ), + 'select' => new Fieldmanager_Select( 'Select Dropdown', array( 'options' => array( 'One', 'Two', 'Three' ) ) ), + 'richtextarea' => new Fieldmanager_RichTextArea( 'Rich Text Area' ) + ) + ) ) + ) + ) ); + + $in_as_json = array( + 0 => array( + 'repeatable_group' => array( + 0 => array( + 'text' => 'abcd', + 'autocomplete' => '26', + 'local_data' => '', + 'textarea' => '', + 'media' => '', + 'select' => '', + 'richtextarea' => '', + ), + 1 => array( + 'text' => '', + 'autocomplete' => '', + 'local_data' => '', + 'textarea' => '123456', + 'media' => '30', + 'select' => '', + 'richtextarea' => '', + ), + 'proto' => array( + 'text' => '', + 'autocomplete' => '', + 'local_data' => '', + 'textarea' => '', + 'media' => '', + 'select' => '', + 'richtextarea' => '', + ), + ), + ), + 1 => array( + 'repeatable_group' => array( + 0 => array( + 'text' => '', + 'autocomplete' => '', + 'local_data' => '', + 'textarea' => '', + 'media' => '', + 'checkbox' => '1', + 'radios' => 'Two', + 'select' => 'Three', + 'richtextarea' => 'Proin mi arcu, porttitor vel tellus vel, lobortis suscipit risus. Quisque consectetur eu arcu in commodo.', + ), + 'proto' => array( + 'text' => '', + 'autocomplete' => '', + 'local_data' => '', + 'textarea' => '', + 'media' => '', + 'select' => '', + 'richtextarea' => '', + ), + ), + ), + 'proto' => array( + 'repeatable_group' => array( + 0 => array( + 'text' => '', + 'autocomplete' => '', + 'local_data' => '', + 'local_data' => '', + 'textarea' => '', + 'media' => '', + 'checkbox' => '1', + 'select' => '', + 'richtextarea' => '', + ), + 'proto' => array( + 'text' => '', + 'autocomplete' => '', + 'local_data' => '', + 'local_data' => '', + 'textarea' => '', + 'media' => '', + 'select' => '', + 'richtextarea' => '', + ), + ) + ), + ); + + $in_as_serialized = "option_fields%5Bproto%5D%5Brepeatable_group%5D%5Bproto%5D%5Btext%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5Bproto%5D%5Bautocomplete%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5Bproto%5D%5Blocal_data%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5Bproto%5D%5Btextarea%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5Bproto%5D%5Bmedia%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5Bproto%5D%5Bselect%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5Bproto%5D%5Brichtextarea%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Btext%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Bautocomplete%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Blocal_data%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Btextarea%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Bmedia%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Bcheckbox%5D=1&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Bradios%5D=One&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Bselect%5D=&option_fields%5Bproto%5D%5Brepeatable_group%5D%5B0%5D%5Brichtextarea%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5Bproto%5D%5Btext%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5Bproto%5D%5Bautocomplete%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5Bproto%5D%5Blocal_data%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5Bproto%5D%5Btextarea%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5Bproto%5D%5Bmedia%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5Bproto%5D%5Bselect%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5Bproto%5D%5Brichtextarea%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Btext%5D=abcd&option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Bautocomplete%5D=26&option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Blocal_data%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Btextarea%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Bmedia%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Bselect%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Brichtextarea%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B1%5D%5Btext%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B1%5D%5Bautocomplete%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B1%5D%5Blocal_data%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B1%5D%5Btextarea%5D=123456&option_fields%5B0%5D%5Brepeatable_group%5D%5B1%5D%5Bmedia%5D=30&option_fields%5B0%5D%5Brepeatable_group%5D%5B1%5D%5Bselect%5D=&option_fields%5B0%5D%5Brepeatable_group%5D%5B1%5D%5Brichtextarea%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5Bproto%5D%5Btext%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5Bproto%5D%5Bautocomplete%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5Bproto%5D%5Blocal_data%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5Bproto%5D%5Btextarea%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5Bproto%5D%5Bmedia%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5Bproto%5D%5Bselect%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5Bproto%5D%5Brichtextarea%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Btext%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Bautocomplete%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Blocal_data%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Btextarea%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Bmedia%5D=&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Bcheckbox%5D=1&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Bradios%5D=Two&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Bselect%5D=Three&option_fields%5B1%5D%5Brepeatable_group%5D%5B0%5D%5Brichtextarea%5D=%3Cstrong%3EProin+mi+arcu%2C+porttitor+vel+tellus+vel%2C+lobortis+suscipit+risus.%3C%2Fstrong%3E+Quisque+consectetur+eu+arcu+in+commodo."; + + $expected = array( + array( + 'repeatable_group' => array( + array( + 'text' => 'abcd', + 'autocomplete' => 26, + ), + array( + 'textarea' => '123456', + 'media' => 30, + ), + ), + ), + array( + 'repeatable_group' => array( + array( + 'checkbox' => '1', + 'radios' => 'Two', + 'select' => 'Three', + 'richtextarea' => '

Proin mi arcu, porttitor vel tellus vel, lobortis suscipit risus. Quisque consectetur eu arcu in commodo.

+', + ), + ), + ), + ); + + $context = new Fieldmanager_Context_Customizer( 'Foo', $fm ); + $this->register(); + $setting = $this->manager->get_setting( $fm->name ); + + $this->assertSame( $expected, $context->sanitize_callback( $in_as_json, $setting ) ); + $this->assertSame( $expected, $context->sanitize_callback( $in_as_serialized, $setting ) ); + } + + function test_sanitize_stripslashes() { + $context = new Fieldmanager_Context_Customizer( 'Foo', $this->field ); + $this->register(); + $this->assertSame( 'Foo "bar" baz', $context->sanitize_callback( 'Foo \"bar\" baz', $this->manager->get_setting( $this->field->name ) ) ); + } + + + function test_render_field() { + $field = $this->getMockBuilder( 'Fieldmanager_Textfield' )->disableOriginalConstructor()->getMock(); + $context = new Fieldmanager_Context_Customizer( 'Foo', $field ); + + $field->expects( $this->once() )->method( 'element_markup' ); + $context->render_field( array( 'echo' => false ) ); + } +} diff --git a/tests/php/test-fieldmanager-customize-control.php b/tests/php/test-fieldmanager-customize-control.php new file mode 100644 index 0000000000..e281ba0217 --- /dev/null +++ b/tests/php/test-fieldmanager-customize-control.php @@ -0,0 +1,92 @@ +mock_context = $this->getMockBuilder( 'Fieldmanager_Context_Customizer' ) + ->disableOriginalConstructor() + ->getMock(); + } + + function test_parent_construction() { + $priority = 99; + + $actual = new Fieldmanager_Customize_Control( $this->manager, 'foo', array( + 'context' => $this->mock_context, + 'priority' => $priority, + ) ); + + $this->assertSame( $priority, $actual->priority ); + } + + /** + * @expectedException FM_Developer_Exception + */ + function test_invalid_construction() { + new Fieldmanager_Customize_Control( $this->manager, 'foo', array() ); + } + + function test_type() { + $control = new Fieldmanager_Customize_Control( $this->manager, rand_str(), array( 'context' => $this->mock_context ) ); + $this->assertSame( 'fieldmanager', $control->type ); + } + + /** + * Tests for scripts registered with wp_register_script(). + */ + function test_register_scripts() { + $before = wp_scripts()->registered; + + $control = new Fieldmanager_Customize_Control( $this->manager, rand_str(), array( 'context' => $this->mock_context ) ); + $control->enqueue(); + + $after = wp_scripts()->registered; + $this->assertSame( 1, ( count( $after ) - count( $before ) ) ); + } + + /** + * Tests for closures hooked with fm_add_script(). + */ + function test_fm_add_scripts() { + global $wp_filter; + + // Spoof is_admin(). + $screen = get_current_screen(); + set_current_screen( 'dashboard-user' ); + + $before = $wp_filter['customize_controls_enqueue_scripts']; + + $control = new Fieldmanager_Customize_Control( $this->manager, rand_str(), array( 'context' => $this->mock_context ) ); + $control->enqueue(); + + $after = $wp_filter['customize_controls_enqueue_scripts']; + $this->assertSame( 1, ( count( $after ) - count( $before ) ) ); + + $GLOBALS['current_screen'] = $screen; + } + + function test_render_content() { + $name = rand_str(); + $value = rand_str(); + + $context = new Fieldmanager_Context_Customizer( array( + // Bypass capability checks. + 'section_args' => array( 'capability' => 'exist' ), + 'setting_args' => array( 'capability' => 'exist' ), + ), new Fieldmanager_Textfield( array( 'name' => $name ) ) ); + + update_option( $name, $value ); + $this->register(); + + $actual = $this->manager->get_control( $name )->get_content(); + $this->assertContains( 'fm-element', $actual ); + $this->assertContains( sprintf( 'name="%s"', $name ), $actual ); + // Slip in a test that the option value is also used. + $this->assertContains( sprintf( 'value="%s"', $value ), $actual ); + } +} diff --git a/tests/php/test-fieldmanager-customize-setting.php b/tests/php/test-fieldmanager-customize-setting.php new file mode 100644 index 0000000000..70152109c9 --- /dev/null +++ b/tests/php/test-fieldmanager-customize-setting.php @@ -0,0 +1,78 @@ +field = new Fieldmanager_TextField( array( 'name' => 'foo' ) ); + $this->context = new Fieldmanager_Context_Customizer( array(), $this->field ); + } + + function test_construction() { + $actual = new Fieldmanager_Customize_Setting( $this->manager, $this->field->name, array( + 'context' => $this->context, + ) ); + + $this->assertInstanceOf( 'Fieldmanager_Customize_Setting', $actual ); + $this->assertSame( $this->field->default_value, $actual->default ); + $this->assertSame( array( $this->context, 'sanitize_callback' ), $actual->sanitize_callback ); + $this->assertSame( 'option', $actual->type ); + } + + /** + * Test that properties set by default by Fieldmanager_Customize_Setting can be overridden. + */ + function test_parent_construction() { + $actual = new Fieldmanager_Customize_Setting( $this->manager, $this->field->name, array( + 'context' => $this->context, + 'default' => 123456, + 'sanitize_callback' => 'absint', + 'type' => 'bar', + ) ); + + $this->assertInstanceOf( 'Fieldmanager_Customize_Setting', $actual ); + $this->assertSame( 123456, $actual->default ); + $this->assertSame( 'absint', $actual->sanitize_callback ); + $this->assertSame( 'bar', $actual->type ); + } + + /** + * @expectedException FM_Developer_Exception + */ + function test_invalid_construction() { + new Fieldmanager_Customize_Setting( $this->manager, $this->field->name, array() ); + } + + function test_preview_filter() { + global $wp_current_filter; + $_current_filter = $wp_current_filter; + + $original = rand_str(); + $preview = rand_str(); + + update_option( $this->field->name, $original ); + + // Spoof a POSTed value so the manager thinks this is a preview. + $this->manager->set_post_value( $this->field->name, $preview ); + + $setting = new Fieldmanager_Customize_Setting( $this->manager, $this->field->name, array( + 'context' => $this->context, + ) ); + + // Verify the preview filter was added. + $this->assertNotFalse( $setting->preview() ); + // Verify that the option value is filtered to return the previewed value. + $this->assertSame( $preview, $setting->_preview_filter( $original ) ); + + // Spoof the current filter. + $wp_current_filter = array( "customize_sanitize_{$this->field->name}" ); + + // Verify that the option value is not filtered with the previewed value. + $this->assertSame( $original, $setting->_preview_filter( $original ) ); + + $wp_current_filter = $_current_filter; + } +} diff --git a/tests/php/test-fieldmanager-script-loading.php b/tests/php/test-fieldmanager-script-loading.php index 63b309a779..8853e166ea 100644 --- a/tests/php/test-fieldmanager-script-loading.php +++ b/tests/php/test-fieldmanager-script-loading.php @@ -42,7 +42,11 @@ public function setUp() { $control = new Fieldmanager_Customize_Control( $customize_manager, 'test', - array( 'context' => $this->getMock( 'Fieldmanager_Context' ) ) + array( + 'context' => $this->getMockBuilder( 'Fieldmanager_Context_Customizer' ) + ->disableOriginalConstructor() + ->getMock() + ) ); $control->enqueue(); diff --git a/tests/php/testcase/class-fieldmanager-customizer-unittestcase.php b/tests/php/testcase/class-fieldmanager-customizer-unittestcase.php new file mode 100644 index 0000000000..239ce57bba --- /dev/null +++ b/tests/php/testcase/class-fieldmanager-customizer-unittestcase.php @@ -0,0 +1,15 @@ +manager = new WP_Customize_Manager(); + } + + + function register() { + do_action( 'customize_register', $this->manager ); + } +} From 9b2a851a3856c2d76f47a72f4aafe95cfca32b2a Mon Sep 17 00:00:00 2001 From: David Herrera Date: Wed, 10 Feb 2016 22:46:58 -0500 Subject: [PATCH 28/76] Document more of Fieldmanager_Context_Customizer tests --- .../test-fieldmanager-context-customizer.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/php/test-fieldmanager-context-customizer.php b/tests/php/test-fieldmanager-context-customizer.php index 8176034710..65d4140d39 100644 --- a/tests/php/test-fieldmanager-context-customizer.php +++ b/tests/php/test-fieldmanager-context-customizer.php @@ -10,12 +10,14 @@ function setUp() { $this->field = new Fieldmanager_TextField( array( 'name' => 'foo' ) ); } + // Test that a section is created even without constructor args. function test_bare_section() { new Fieldmanager_Context_Customizer( array(), $this->field ); $this->register(); $this->assertInstanceOf( 'WP_Customize_Section', $this->manager->get_section( $this->field->name ) ); } + // Test that a section is created even with a title string. function test_section_title() { $title = rand_str(); @@ -24,6 +26,7 @@ function test_section_title() { $this->assertSame( $title, $this->manager->get_section( $this->field->name )->title ); } + // Test that a section is created with constructor args. function test_section_args() { $title = rand_str(); $priority = rand( 0, 100 ); @@ -42,12 +45,14 @@ function test_section_args() { $this->assertSame( $priority, $actual->priority ); } + // Test that a setting is created even without constructor args. function test_bare_setting() { new Fieldmanager_Context_Customizer( 'Foo', $this->field ); $this->register(); $this->assertInstanceOf( 'Fieldmanager_Customize_Setting', $this->manager->get_setting( $this->field->name ) ); } + // Test that a setting is created with constructor args. function test_setting_args() { $capability = 'edit_thing'; $default = rand_str(); @@ -66,6 +71,7 @@ function test_setting_args() { $this->assertSame( $default, $actual->default ); } + // Test that a control is created even without constructor args. function test_bare_control() { new Fieldmanager_Context_Customizer( 'Foo', $this->field ); @@ -77,6 +83,7 @@ function test_bare_control() { $this->assertInstanceOf( 'Fieldmanager_Customize_Setting', $actual->settings['default'] ); } + // Test that a control is created with constructor args. function test_control_args() { $label = rand_str(); $section = rand_str(); @@ -95,6 +102,7 @@ function test_control_args() { $this->assertSame( $label, $actual->label ); } + // Test that multiple objects are created with constructor args. function test_multiple_args() { $title = rand_str(); $theme_supports = rand_str(); @@ -131,6 +139,9 @@ function test_multiple_args() { } /** + * Test that a textfield is sanitized the same way when the value is passed + * as a bare string and a query string. + * * @expectedException FM_Exception */ function test_sanitize_string() { @@ -145,6 +156,10 @@ function test_sanitize_string() { $context->sanitize_callback( array( 'Not', 'a', 'string' ), $setting ); } + /** + * Test that a group is sanitized the same way when the value is passed as + * an array or a query string. + */ function test_sanitize_group() { $fm = new Fieldmanager_Group( array( 'name' => 'option_fields', @@ -286,6 +301,7 @@ function test_sanitize_group() { $this->assertSame( $expected, $context->sanitize_callback( $in_as_serialized, $setting ) ); } + // Make sure sanitizing strips slashes. function test_sanitize_stripslashes() { $context = new Fieldmanager_Context_Customizer( 'Foo', $this->field ); $this->register(); @@ -293,6 +309,7 @@ function test_sanitize_stripslashes() { } + // Make sure the context's rendering method calls the field rendering method. function test_render_field() { $field = $this->getMockBuilder( 'Fieldmanager_Textfield' )->disableOriginalConstructor()->getMock(); $context = new Fieldmanager_Context_Customizer( 'Foo', $field ); From ac92831d8119956c4f01bb44cbf337a50b80c4a4 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 19 Feb 2016 09:41:42 -0500 Subject: [PATCH 29/76] Expose the target selector in fm.customize This should make it easier to change the selector if someone happens to be overriding the class. --- js/fieldmanager-customize.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index b68e55a3d8..2aea4106d1 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -11,6 +11,13 @@ 'use strict'; fm.customize = { + /** + * jQuery selector targeting all elements to include in a Fieldmanager setting value. + * + * @type {String} + */ + targetSelector: '.fm-element', + /** * Set the values of all Fieldmanager controls. */ @@ -47,7 +54,7 @@ return; } - $element = control.container.find( '.fm-element' ); + $element = control.container.find( this.targetSelector ); if ( $.serializeJSON ) { serialized = $element.serializeJSON(); From 738d1edd73c277c1898eae4076ab04c351b55b5f Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 19 Feb 2016 09:44:14 -0500 Subject: [PATCH 30/76] Fix reference to 'this' in setEachControl() --- js/fieldmanager-customize.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 2aea4106d1..42472f8d91 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -22,7 +22,11 @@ * Set the values of all Fieldmanager controls. */ setEachControl: function () { - api.control.each( this.setControl ); + var that = this; + + api.control.each(function( control ) { + that.setControl( control ); + }); }, /** From fa1c3502b2f49a22401b7c2017bb45a09d8efa7d Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 19 Feb 2016 09:45:04 -0500 Subject: [PATCH 31/76] Check for serializeJSON() on found element itself As opposed to whether it exists in the abstract. --- js/fieldmanager-customize.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 42472f8d91..4a425614f0 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -60,7 +60,7 @@ $element = control.container.find( this.targetSelector ); - if ( $.serializeJSON ) { + if ( $element.serializeJSON ) { serialized = $element.serializeJSON(); value = serialized[ control.id ]; } else { From 4005cf3e0be898a5c43a285306fc75eb45ceba4f Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 19 Feb 2016 09:47:47 -0500 Subject: [PATCH 32/76] Return the control, not nothing, after setControl() --- js/fieldmanager-customize.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/fieldmanager-customize.js b/js/fieldmanager-customize.js index 4a425614f0..4b0b6f4721 100644 --- a/js/fieldmanager-customize.js +++ b/js/fieldmanager-customize.js @@ -48,6 +48,7 @@ * Set a Fieldmanager setting to its control's form values. * * @param {Object} control Customizer Control object. + * @return {Object} The updated Control. */ setControl: function ( control ) { var $element; @@ -67,7 +68,7 @@ value = $element.serialize(); } - control.setting.set( value ); + return control.setting.set( value ); }, }; From affc2ce75e5e228f43afdca4f3d6e03919457f63 Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 19 Feb 2016 14:19:12 -0500 Subject: [PATCH 33/76] Update to QUnit 1.21.0 --- tests/js/index.html | 4 +- .../{qunit-1.19.0.css => qunit-1.21.0.css} | 24 +- .../{qunit-1.19.0.js => qunit-1.21.0.js} | 712 +++++++++++------- 3 files changed, 456 insertions(+), 284 deletions(-) rename tests/js/vendor/{qunit-1.19.0.css => qunit-1.21.0.css} (89%) rename tests/js/vendor/{qunit-1.19.0.js => qunit-1.21.0.js} (89%) diff --git a/tests/js/index.html b/tests/js/index.html index 929c42d98a..aa421377b9 100644 --- a/tests/js/index.html +++ b/tests/js/index.html @@ -5,8 +5,8 @@ Fieldmanager QUnit Test Suite - - + +