diff --git a/css/fieldmanager.css b/css/fieldmanager.css index aaa1353630..98c57c4b4c 100644 --- a/css/fieldmanager.css +++ b/css/fieldmanager.css @@ -379,3 +379,34 @@ a.fm-delete:hover { .form-field .fm-checkbox label { display: inline; } + +.wp-customizer .ui-autocomplete, +.wp-customizer .ui-datepicker { + /* Hoist jQuery UI popups over who-knows-what in the Customizer. */ + z-index: 500000 !important; +} + +.customize-control input.fm-datepicker-popup { + /* Override Customizer defaults; datepickers have multiple inputs on one line. */ + width: 100px; +} + +.customize-control input.fm-datepicker-time { + /* Override Customizer defaults; datepickers have multiple inputs on one line. */ + width: 30px; +} + +.fm-datepicker-time-wrapper select { + /* Override Customizer defaults; datepickers have multiple inputs on one line. */ + min-width: 0; +} + +.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: 80%; +} diff --git a/fieldmanager.php b/fieldmanager.php index 5216658668..8bfb028267 100644 --- a/fieldmanager.php +++ b/fieldmanager.php @@ -70,6 +70,14 @@ 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' ); + } + + if ( 'Fieldmanager_Customize_Setting' === $class ) { + return fieldmanager_load_file( 'class-fieldmanager-customize-setting.php' ); + } + if ( 0 === strpos( $class, 'Fieldmanager_Util' ) ) { return fieldmanager_load_file( 'util/class-fieldmanager-util-' . $class_id . '.php' ); } @@ -250,8 +258,12 @@ function fm_get_context( $recalculate = false ) { function fm_calculate_context() { $calculated_context = array( null, null ); + if ( is_customize_preview() ) { + $calculated_context = array( 'customize', null ); + } + // Safe to use at any point in the load process, and better than URL matching. - if ( is_admin() ) { + if ( empty( $calculated_context[0] ) && is_admin() ) { $script = substr( $_SERVER['PHP_SELF'], strrpos( $_SERVER['PHP_SELF'], '/' ) + 1 ); /* @@ -284,7 +296,12 @@ function fm_calculate_context() { } } + if ( 'customize.php' === $script || is_customize_preview() ) { + $calculated_context = array( 'customize', null ); + } + if ( empty( $calculated_context[0] ) ) { + switch ( $script ) { // Context = "post". case 'post.php': diff --git a/js/fieldmanager-autocomplete.js b/js/fieldmanager-autocomplete.js index b6630f06f8..2719072023 100644 --- a/js/fieldmanager-autocomplete.js +++ b/js/fieldmanager-autocomplete.js @@ -1,4 +1,4 @@ -( function( $ ) { +(function( $ ) { fm.autocomplete = { @@ -84,5 +84,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/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 new file mode 100644 index 0000000000..4624a7b454 --- /dev/null +++ b/js/fieldmanager-customize.js @@ -0,0 +1,256 @@ +/* global document, jQuery, wp, _, fm */ +/** + * Integrate Fieldmanager and the Customizer. + * + * @param {function} $ jQuery + * @param {function} api Customizer API. + * @param {function} _ Underscore + * @param {Object} fm Fieldmanager API. + */ +(function( $, api, _, fm ) { + '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. + */ + setEachControl: function () { + var that = this; + + api.control.each(function( control ) { + that.setControl( control ); + }); + }, + + /** + * 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. + * @return {Object} The updated Control. + */ + setControl: function ( control ) { + var $element; + var serialized; + var value; + + if ( 'fieldmanager' !== control.params.type ) { + return; + } + + if ( ! control.setting ) { + return; + } + + $element = control.container.find( this.targetSelector ); + + if ( $element.serializeJSON ) { + serialized = $element.serializeJSON(); + value = serialized[ control.id ]; + } else { + value = $element.serialize(); + } + + return control.setting.set( value ); + }, + }; + + /** + * Fires when an .fm-element input triggers a 'change' event. + * + * @param {Event} e Event object. + */ + var onFmElementChange = function( e ) { + fm.customize.setControlsContainingElement( e.target ); + }; + + /** + * 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; + } + + if ( $target.hasClass( 'fm-autocomplete' ) ) { + /* + * Don't update when typing into the autocomplete input. The hidden + * field actually contains the value and is handled onFmElementChange(). + */ + return; + } + + fm.customize.setControlsContainingElement( e.target ); + }; + + /** + * 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 ) { + fm.customize.setControlsContainingElement( el ); + }; + + /** + * Fires when Fieldmanager adds a new element in a repeatable field. + * + * @param {Event} e Event object. + */ + var onFmAddedElement = function( e ) { + fm.customize.setControlsContainingElement( e.target ); + }; + + /** + * 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 ) { + fm.customize.setControlsContainingElement( 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', function () { + ed.save(); + fm.customize.setControlsContainingElement( document.getElementById( ed.id ) ); + } ); + }; + + /** + * 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. + * + * @param {Event} e Event object. + */ + var onFmMediaRemoveClick = function ( e ) { + // The control no longer contains the element, so set all of them. + fm.customize.setEachControl(); + }; + + /** + * 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 set all of them. + fm.customize.setEachControl(); + }; + + /** + * Fires when a Customizer Section expands. + * + * @param {Object} section Customizer Section object. + */ + 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_customize_control_section_expanded' ); + + if ( fm.richtextarea ) { + 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 + * 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() + ) { + fm.customize.setControl( control ); + } + }); + }; + + /** + * Fires when the Customizer is loaded. + */ + var ready = function() { + var $document = $( document ); + + $document.on( 'keyup', '.fm-element', onFmElementKeyup ); + $document.on( 'change', '.fm-element', onFmElementChange ); + $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 ); + $document.on( 'fm_richtext_init', onFmRichtextInit ); + $document.on( 'fm_colorpicker_update', onFmColorpickerUpdate ); + }; + + /** + * 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. + section.container.bind( 'expanded', function () { + onSectionExpanded( section ); + } ); + }; + + if ( typeof api !== 'undefined' ) { + api.bind( 'ready', ready ); + api.section.bind( 'add', addSection ); + } +})( jQuery, wp.customize, _, fm ); diff --git a/js/fieldmanager-datepicker.js b/js/fieldmanager-datepicker.js index cc785253ee..24cacd2666 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 ); -} ) ( jQuery ); \ No newline at end of file + $( document ).on( + 'fm_collapsible_toggle fm_added_element fm_displayif_toggle fm_activate_tab fm_customize_control_section_expanded', + fm.datepicker.add_datepicker + ); +} ) ( jQuery ); diff --git a/js/fieldmanager.js b/js/fieldmanager.js index 482199b576..ade2dafdde 100644 --- a/js/fieldmanager.js +++ b/js/fieldmanager.js @@ -272,7 +272,11 @@ $( document ).ready( function () { init_label_macros(); init_sortable(); - $( document ).on( 'fm_activate_tab', init_sortable ); + $( document ).on( 'fm_activate_tab fm_customize_control_section_expanded', init_sortable ); + $( document ).on( 'fm_customize_control_section_expanded', init_label_macros ); + $( document ).on( 'fm_customize_control_section_expanded', function () { + $( '.display-if' ).each( fm.init_display_if ); + } ); } ); } )( jQuery ); 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/js/richtext.js b/js/richtext.js index 0b6ad0cc3a..fe559dc8a5 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 ) { @@ -133,7 +143,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. diff --git a/php/class-fieldmanager-customize-control.php b/php/class-fieldmanager-customize-control.php new file mode 100644 index 0000000000..d3d66a4f93 --- /dev/null +++ b/php/class-fieldmanager-customize-control.php @@ -0,0 +1,88 @@ +context instanceof Fieldmanager_Context_Customize ) && FM_DEBUG ) { + throw new FM_Developer_Exception( + __( 'Fieldmanager_Customize_Control requires a Fieldmanager_Context_Customize', 'fieldmanager' ) + ); + } + } + + /** + * 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-customize', + 'js/fieldmanager-customize.js', + array( 'jquery', 'underscore', 'editor', 'quicktags', 'fieldmanager_script', 'customize-controls', 'fm-serializejson' ), + '1.0.0', + true + ); + } + + /** + * Render the control's content. + * + * @see Fieldmanager_Context::render_field(). + * @see WP_Customize_Control::render_content(). + */ + protected function render_content() { + ?> + + label ) ) : ?> + label ); ?> + + + description ) ) : ?> + description ); ?> + + + 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..aa3ae5e211 --- /dev/null +++ b/php/class-fieldmanager-customize-setting.php @@ -0,0 +1,74 @@ +context = $args['context']; + + // Set the default without checking isset() (as in WP_Customize_Setting) to support null values. + $this->default = $this->context->fm->default_value; + + // Validate and sanitize with the context. + $this->validate_callback = array( $this->context, 'validate_callback' ); + $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_Customize', '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 + * or validating. _preview_filter() eventually calls + * sanitize_callback() and validate_callback(), which calls the + * hooks to those methods in Fieldmanager_Context_Customize, which + * calls WP_Customize_Setting::value(), which ends up back here. + */ + if ( doing_filter( "customize_sanitize_{$this->id}" ) || doing_filter( "customize_validate_{$this->id}" ) ) { + return $original; + } + + return parent::_preview_filter( $original ); + } + } +endif; diff --git a/php/class-fieldmanager-field.php b/php/class-fieldmanager-field.php index c35271525a..0abad30a98 100644 --- a/php/class-fieldmanager-field.php +++ b/php/class-fieldmanager-field.php @@ -868,7 +868,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 ) ); @@ -1103,6 +1103,24 @@ public function activate_submenu_page() { _fieldmanager_registry( 'active_submenu', $active_submenu ); } + /** + * Add this field to the Customizer. + * + * @param string|array $args @see Fieldmanager_Context_Customize. Pass a + * string to add a Customizer section for this field that uses the + * string for its title and context defaults for the remaining + * arguments. Or, pass a full array of arguments for the context. + */ + public function add_to_customizer( $args = array() ) { + $this->require_base(); + + if ( is_string( $args ) ) { + $args = array( 'section_args' => array( 'title' => $args ) ); + } + + return new Fieldmanager_Context_Customize( $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' ) ); @@ -1131,8 +1149,7 @@ public function _unauthorized_access( $debug_message = '' ) { protected function _failed_validation( $debug_message = '' ) { if ( self::$debug ) { throw new FM_Validation_Exception( $debug_message ); - } - else { + } 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' ) diff --git a/php/class-fieldmanager-richtextarea.php b/php/class-fieldmanager-richtextarea.php index 1230b69d9a..b04be3ba79 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. * @@ -119,6 +126,10 @@ public function form_element( $value = '' ) { add_filter( 'the_editor', array( $this, 'add_proto_id' ) ); } + if ( ! isset( $settings['teeny'] ) ) { + $settings['teeny'] = is_customize_preview(); + } + if ( ! isset( $settings['default_editor'] ) ) { $settings['default_editor'] = 'tinymce'; } elseif ( 'cookie' == $settings['default_editor'] ) { @@ -252,6 +263,25 @@ 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 ) { + // This action must fire after settings are exported in WP_Customize_Manager::customize_pane_settings(). + add_action( 'customize_controls_print_footer_scripts', array( $this, 'customize_controls_print_footer_scripts' ), 1001 ); + 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(); + } + } } /** diff --git a/php/context/class-fieldmanager-context-customize.php b/php/context/class-fieldmanager-context-customize.php new file mode 100644 index 0000000000..ab77bf5a78 --- /dev/null +++ b/php/context/class-fieldmanager-context-customize.php @@ -0,0 +1,284 @@ +args = wp_parse_args( $args, array( + 'section_args' => false, + 'setting_args' => array(), + 'control_args' => array(), + ) ); + + $this->fm = $fm; + + add_action( 'customize_register', array( $this, 'customize_register' ), 100 ); + } + + /** + * 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 ); + } + + /** + * Exposes Fieldmanager_Context::render_field() for the control to call. + * + * @param array $args Unused. + */ + public function render_field( $args = array() ) { + return parent::render_field( $args ); + } + + /** + * Filter the validity of a Customize setting value. + * + * Amend the `$validity` object via its `WP_Error::add()` method. + * + * @see WP_Customize_Setting::validate(). + * + * @param WP_Error $validity Filtered from `true` to `WP_Error` when invalid. + * @param mixed $value Value of the setting. + * @param WP_Customize_Setting $setting WP_Customize_Setting instance. + */ + public function validate_callback( $validity, $value, $setting ) { + $value = $this->parse_field_query_string( $value ); + + // Start assuming calls to wp_die() signal Fieldmanager validation errors. + $this->start_handling_wp_die(); + + try { + $this->prepare_data( $setting->value(), $value ); + } catch ( Exception $e ) { + if ( ! is_wp_error( $validity ) ) { + $validity = new WP_Error(); + } + + /* + * Handle all exceptions Fieldmanager might generate, but use the + * message from only validation exceptions, which are more + * user-friendly. For others, use the generic message from + * WP_Customize_Setting::validate(). + */ + $message = ( $e instanceof FM_Validation_Exception ) ? $e->getMessage() : __( 'Invalid value.', 'fieldmanager' ); + + // @see https://core.trac.wordpress.org/ticket/37890 for the use of array( $value ). + $validity->add( 'fieldmanager', $message, array( $value ) ); + } + + // Resume normal wp_die() handling. + $this->stop_handling_wp_die(); + + return $validity; + } + + /** + * 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 ) { + $value = $this->parse_field_query_string( $value ); + + // Run the validation routine in case we need to reject the value. + $validity = $this->validate_callback( true, $value, $setting ); + + if ( is_wp_error( $validity ) ) { + /* + * The 'customize_save_validation_before' action was added with the + * Customizer's validation framework. If it fires, assume it's safe + * to return a WP_Error to indicate invalid values. Returning null + * is a backwards-compatible way to reject a value from + * WP_Customize_Setting::sanitize(). See + * https://core.trac.wordpress.org/ticket/34893. + */ + return ( did_action( 'customize_save_validation_before' ) ) ? $validity : null; + } + + // Return the value after Fieldmanager takes a shot at it. + return stripslashes_deep( $this->prepare_data( $setting->value(), $value ) ); + } + + /** + * Filter the callback for killing WordPress execution. + * + * Fieldmanager calls wp_die() to signal some errors, but messages passed to + * wp_die() are not automatically displayed in the Customizer. This filter + * should return a callback that throws the message passed to wp_die() as an + * exception, which the default validation callback in this context can + * catch and convert to a WP_Error. + * + * @return callable Callback function name. + */ + public function on_filter_wp_die_handler() { + /* + * Side effect: We don't want execution to stop, so remove all other + * filters because they presumably assume the opposite. See, e.g., + * WP_Customize_Manager::remove_preview_signature(). + */ + remove_all_filters( current_filter() ); + + // Return the new callback. + return array( $this, 'wp_die_handler' ); + } + + /** + * Handle wp_die() by throwing an exception instead of killing execution. + * + * @throws FM_Validation_Exception With the message passed to wp_die(). + * + * @param string|WP_Error $message Error message or WP_Error object. + * @param string $title Optional. Error title. + * @param string|array $args Optional. Arguments to control behavior. + */ + public function wp_die_handler( $message, $title, $args ) { + if ( is_wp_error( $message ) ) { + $message = $message->get_error_message(); + } + + /* + * Modify $message in two ways that follow from our assumption that + * Fieldmanager generated this wp_die(): Remove the blank lines and + * "back button" message, and unescape HTML. + */ + throw new FM_Validation_Exception( preg_replace( '#\n\n.*?$#', '', htmlspecialchars_decode( $message, ENT_QUOTES ) ) ); + } + + /** + * Create a Customizer section, setting, and control for this field. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + */ + protected function register( $manager ) { + $this->register_section( $manager ); + $this->register_setting( $manager ); + $this->register_control( $manager ); + } + + /** + * Add a Customizer section for this field. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + * @return WP_Customize_Section|void Section object, where supported, if created. + */ + protected function register_section( $manager ) { + if ( false === $this->args['section_args'] ) { + return; + } + + return $manager->add_section( $this->fm->name, $this->args['section_args'] ); + } + + /** + * Add a Customizer setting for this field. + * + * By default, Fieldmanager registers one setting for a group and sends all + * of the group values from the Customizer, rather than individual settings + * for its children, so sanitization and validation routines can access the + * full group data. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + * @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, + ) + ) ) + ); + } + + /** + * Add a Customizer control for this field. + * + * @param WP_Customize_Manager $manager WP_Customize_Manager instance. + * @return Fieldmanager_Customize_Control Control object, where supported. + */ + 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, + ) + ) ) + ); + } + + /** + * Decode form element values for this field from a URL-encoded string. + * + * @param mixed $value Value to parse. + * @return mixed + */ + protected function parse_field_query_string( $value ) { + 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 ) && 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 $value; + } + + /** + * Add filters that convert calls to wp_die() into exceptions. + * + * @return bool Whether the filters were added. + */ + protected function start_handling_wp_die() { + return ( + add_filter( 'wp_die_ajax_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + && add_filter( 'wp_die_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + ); + } + + /** + * Remove filters that convert calls to wp_die() into exceptions. + * + * @return bool Whether the filters were removed. + */ + protected function stop_handling_wp_die() { + return ( + remove_filter( 'wp_die_ajax_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + && remove_filter( 'wp_die_handler', array( $this, 'on_filter_wp_die_handler' ), 0 ) + ); + } +} diff --git a/php/util/class-fieldmanager-util-assets.php b/php/util/class-fieldmanager-util-assets.php index 02b6c5f473..071fb24718 100644 --- a/php/util/class-fieldmanager-util-assets.php +++ b/php/util/class-fieldmanager-util-assets.php @@ -148,6 +148,13 @@ protected function hook_enqueue() { if ( ! $this->hooked ) { add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + + /* + * Use a later priority because 'customize_controls_enqueue_scripts' + * will, by default, be firing at the default priority when + * Fieldmanager_Customize_Control::enqueue() adds scripts. + */ + add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_assets' ), 20 ); $this->hooked = true; } } diff --git a/tests/js/index.html b/tests/js/index.html index 7ac93d00b3..263c56201d 100644 --- a/tests/js/index.html +++ b/tests/js/index.html @@ -23,16 +23,27 @@ scripts = [ svn + '/src/wp-includes/js/jquery/jquery.js', + svn + '/src/wp-includes/js/underscore.min.js', + svn + '/src/wp-includes/js/backbone.min.js', + svn + '/src/wp-includes/js/wp-util.js', svn + '/src/wp-includes/js/jquery/ui/core.js', svn + '/src/wp-includes/js/jquery/ui/widget.js', svn + '/src/wp-includes/js/jquery/ui/mouse.js', 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', + svn + '/src/wp-includes/js/customize-base.js', + svn + '/src/wp-includes/js/customize-models.js', + svn + '/src/wp-admin/js/customize-controls.js', ]; // Fieldmanager. scripts.push( '../../js/fieldmanager.js' ); + scripts.push( '../../js/fieldmanager-autocomplete.js' ); + scripts.push( '../../js/jquery-serializejson/jquery.serializejson.min.js' ); + scripts.push( '../../js/fieldmanager-customize.js' ); // Tests. scripts.push( 'test-fieldmanager.js' ); @@ -136,6 +147,19 @@
+
+ + +
+ +
+ Remove + Remove + + + +
+
First
diff --git a/tests/js/test-fieldmanager.js b/tests/js/test-fieldmanager.js index b2ae5dffc4..c74f2e5ebf 100644 --- a/tests/js/test-fieldmanager.js +++ b/tests/js/test-fieldmanager.js @@ -1,4 +1,9 @@ +/* global wp */ + (function( QUnit, $ ) { + function randStr() { + return Math.random().toString( 36 ).replace( '0.', '' ); + } QUnit.begin(function( details ) { // Don't hide the fixture div by default: FM relies on visibility. @@ -93,6 +98,17 @@ assert.ok( ! $( '#di-when-trigger-length-is-zero' ).hasClass( 'display-trigger' ) ); }); + 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(); @@ -124,4 +140,345 @@ assert.equal( $( '[name="mytest[2][mysubtest][0]"]' ).text(), 'Delta', "Corresponding name attributes and text values in the reordered top-level group" ); }); + QUnit.module( 'Customizer', function ( hooks ) { + QUnit.module( 'fm.customize API', function ( hooks ) { + QUnit.test( 'setControl sets Fieldmanager control setting', function ( assert ) { + var initialValue = randStr(); + + var setting = new wp.customize.Setting( randStr(), initialValue, { + transport: 'noop', + previewer: wp.customize.previewer, + }); + + var control = new wp.customize.Control( randStr(), { + setting: setting, + params: { + settings: { 'default': setting.id }, + type: 'fieldmanager', + }, + }); + + assert.equal( fm.customize.setControl( control ), setting, 'Should return setting' ); + assert.notEqual( setting.get(), initialValue, 'Setting value should change' ); + }); + + QUnit.test( 'setControl ignores non-Fieldmanager controls', function ( assert ) { + var value = randStr(); + var settingId = randStr(); + + var setting = new wp.customize.Setting( settingId, value, { + transport: 'noop', + previewer: wp.customize.previewer + }); + + var control = new wp.customize.Control( randStr(), { + setting: setting, + params: { + content: $( '
  • ' ).append( + $( '' ) + .addClass( 'fm-element' ) + .attr( 'type', 'text' ) + .attr( 'name', settingId ) + ), + settings: { 'default': setting.id }, + type: 'text', + }, + }); + + assert.notOk( fm.customize.setControl( control ), 'Should not return setting' ); + assert.equal( setting.get(), value, 'Setting value should not change' ); + }); + + QUnit.test( 'setControl serializes only values in targetSelector', function ( assert ) { + var settingId = randStr(); + var settingValue = randStr(); + var fmElementValue = randStr(); + var notFmElementValue = randStr(); + + var markup = $( '
  • ' ).append( + $( '' ) + .addClass( 'fm-element' ) + .attr( 'name', settingId ) + .attr( 'value', fmElementValue ), + $( '' ) + .addClass( 'not-fm-element' ) + .attr( 'name', 'bar' ) + .attr( 'value', notFmElementValue ) + ); + + var setting = new wp.customize.Setting( settingId, settingValue, { + transport: 'noop', + previewer: wp.customize.previewer, + }); + + var control = new wp.customize.Control( settingId, { + setting: setting, + params: { + content: markup, + settings: { 'default': setting.id }, + type: 'fieldmanager', + } + }); + + assert.ok( fm.customize.setControl( control ), 'successfully returned setting' ); + assert.ok( -1 !== setting.get().indexOf( fmElementValue ), 'setting now contains "fm-element" value' ); + assert.ok( -1 === setting.get().indexOf( notFmElementValue ), 'setting does not contain "not-fm-element" value' ); + }); + + QUnit.test( 'setControl sets value with serializeJSON()', function ( assert ) { + var settingId = 'option_fields'; + var textValue = randStr(); + + var expected = { + 0: { + 'repeatable_group': { + 0: { + 'text': textValue, + } + } + } + }; + + var setting = new wp.customize.Setting( settingId, '', { + transport: 'noop', + previewer: wp.customize.previewer, + }); + + var control = new wp.customize.Control( settingId, { + setting: setting, + params: { + content: $( '
  • ' ).append( + $( '' ) + .addClass( 'fm-element' ) + .attr( 'name', 'option_fields[0][repeatable_group][0][text]' ) + .attr( 'value', textValue ) + ), + settings: { 'default': setting.id }, + type: 'fieldmanager', + } + }); + + fm.customize.setControl( control ); + assert.deepEqual( setting.get(), expected ); + }); + + QUnit.test( 'setControl falls back to serialize()', function ( assert ) { + var plugin = $.fn.serializeJSON; + $.fn.serializeJSON = undefined; + + var settingId = 'option_fields'; + var textValue = randStr(); + + var expected = 'option_fields%5B0%5D%5Brepeatable_group%5D%5B0%5D%5Btext%5D=' + textValue; + + var setting = new wp.customize.Setting( settingId, '', { + transport: 'noop', + previewer: wp.customize.previewer, + }); + + var control = new wp.customize.Control( settingId, { + setting: setting, + params: { + content: $( '
  • ' ).append( + $( '' ) + .addClass( 'fm-element' ) + .attr( 'name', 'option_fields[0][repeatable_group][0][text]' ) + .attr( 'value', textValue ) + ), + settings: { 'default': setting.id }, + type: 'fieldmanager', + } + }); + + fm.customize.setControl( control ); + assert.strictEqual( setting.get(), expected ); + + $.fn.serializeJSON = plugin; + }); + + QUnit.test( 'setControlsContainingElement() sets only controls containing element', function ( assert ) { + var initialValue = randStr(); + var newValue = randStr(); + var id1 = randStr(); + var id2 = randStr(); + + var setting1 = wp.customize.create( id1, id1, initialValue, { + transport: 'noop', + previewer: wp.customize.previewer, + } ); + + var $element1 = $( '' ) + .addClass( 'fm-element' ) + .attr( 'name', id1 ) + .attr( 'value', newValue ); + + var control1 = wp.customize.control.create( id1, id1, { + setting: setting1, + params: { + content: $( '
  • ' ).append( $element1 ), + settings: { 'default': setting1.id }, + type: 'fieldmanager', + }, + }); + + var setting2 = wp.customize.create( id2, id2, initialValue, { + transport: 'noop', + previewer: wp.customize.previewer, + }); + + var control2 = wp.customize.control.create( id2, id2, { + setting: setting2, + params: { + content: $( '
  • ' ).append( + $( '' ) + .addClass( 'fm-element' ) + .attr( 'name', id2 ) + .attr( 'value', newValue ) + ), + settings: { 'default': setting2.id }, + type: 'fieldmanager', + } + }); + + fm.customize.setControlsContainingElement( $element1 ); + assert.equal( setting1.get(), newValue, 'First setting value was in the control and should change' ); + assert.equal( setting2.get(), initialValue, 'Second setting value was not in the control and should not change' ); + }); + + QUnit.test( 'setEachControl should set each control', function ( assert ) { + var id1 = randStr(); + var initialValue1 = randStr(); + var newValue1 = randStr(); + + var id2 = randStr(); + var initialValue2 = randStr(); + var newValue2 = randStr(); + + var setting1 = wp.customize.create( id1, id1, initialValue1, { + transport: 'noop', + previewer: wp.customize.previewer, + } ); + + wp.customize.control.create( id1, id1, { + setting: setting1, + params: { + content: $( '
  • ' ).append( + $( '' ) + .addClass( 'fm-element' ) + .attr( 'name', id1 ) + .attr( 'value', newValue1 ) + ), + settings: { 'default': setting1.id }, + type: 'fieldmanager', + }, + }); + + var setting2 = wp.customize.create( id2, id2, initialValue2, { + transport: 'noop', + previewer: wp.customize.previewer, + }); + + wp.customize.control.create( id2, id2, { + setting: setting2, + params: { + content: $( '
  • ' ).append( + $( '' ) + .addClass( 'fm-element' ) + .attr( 'name', id2 ) + .attr( 'value', newValue2 ) + ), + settings: { 'default': setting2.id }, + type: 'fieldmanager', + } + }); + + fm.customize.setEachControl(); + assert.equal( setting1.get(), newValue1, 'First setting value changed' ); + assert.equal( setting2.get(), newValue2, 'Second setting value changed' ); + }); + }); + + QUnit.module( 'Events', function ( hooks ) { + var initialValue = 'Wrong'; + var expected = 'First'; + var id = 'customize-text'; + + hooks.beforeEach(function( assert ) { + var setting = wp.customize.create( id, id, initialValue, { + transport: 'noop', + previewer: wp.customize.previewer, + }); + + wp.customize.control.create( id, id, { + setting: setting, + params: { + content: $( document ).find( '#customizer-events' ), + settings: { 'default': setting.id }, + type: 'fieldmanager', + } + }); + + wp.customize.trigger( 'ready' ); + }); + + hooks.afterEach(function( assert ) { + wp.customize.remove( id ); + wp.customize.control.remove( id ); + }); + + function assertStrictEqualSettingValue( assert ) { + assert.strictEqual( wp.customize.instance( id ).get(), expected, 'Setting value updated' ); + } + + QUnit.test( '.fm-element keyup', function ( assert ) { + var done = assert.async(); + $( document ).find( '#customizer-events' ).find( '.fm-element' ).trigger( 'keyup' ); + // Account for the _.debounce() attached to this event. + setTimeout(function() { + assertStrictEqualSettingValue( assert ); + done(); + }, 500 ); + }); + + QUnit.test( '.fm-autocomplete keyup', function ( assert ) { + $( document ).find( '#customizer-events' ).find( '.fm-autocomplete' ).trigger( 'keyup' ); + // This in itself should *not* trigger a change. + assert.strictEqual( wp.customize.instance( id ).get(), initialValue, 'Setting value unchanged' ); + }); + + QUnit.test( '.fm-element change', function ( assert ) { + $( document ).find( '#customizer-events' ).find( '.fm-element' ).trigger( 'change' ); + assertStrictEqualSettingValue( assert ); + }); + + QUnit.test( '.fm-media-remove click ', function ( assert ) { + $( document ).find( '#customizer-events').find( '.fm-media-remove' ).trigger( 'click' ); + assertStrictEqualSettingValue( assert ); + }); + + QUnit.test( '.fmjs-remove click ', function ( assert ) { + $( document ).find( '#customizer-events').find( '.fmjs-remove' ).trigger( 'click' ); + assertStrictEqualSettingValue( assert ); + }); + + QUnit.test( 'fm_sortable_drop', function ( assert ) { + $( document ).trigger( 'fm_sortable_drop', $( document ).find( '#customizer-events' ).find( '.fm-element' )[0] ); + assertStrictEqualSettingValue( assert ); + }); + + QUnit.test( 'fieldmanager_media_preview', function ( assert ) { + $( document ).find( '#customizer-events' ).find( '.fm-element' ).trigger( 'fieldmanager_media_preview' ); + assertStrictEqualSettingValue( assert ); + }); + + // Needs a test with TinyMCE. + // QUnit.test( 'fm_richtext_init', function ( assert ) { + // }); + + QUnit.test( 'fm_colorpicker_update', function ( assert ) { + $( document ).trigger( 'fm_colorpicker_update', $( document ).find( '#customizer-events' ).find( '.fm-element' )[0] ); + assertStrictEqualSettingValue( assert ); + }); + }); + }); })( window.QUnit, window.jQuery ); diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php index 5caba83fd2..851ec4c507 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-customize-unittestcase.php'; + /** * Is the current version of WordPress at least ... ? * diff --git a/tests/php/test-fieldmanager-context-customize.php b/tests/php/test-fieldmanager-context-customize.php new file mode 100644 index 0000000000..9322be6393 --- /dev/null +++ b/tests/php/test-fieldmanager-context-customize.php @@ -0,0 +1,416 @@ +field = new Fieldmanager_TextField( array( 'name' => 'foo' ) ); + $this->old_debug = Fieldmanager_Field::$debug; + } + + function tearDown() { + Fieldmanager_Field::$debug = $this->old_debug; + } + + /** + * Data provider for running a test with field debugging on and off. + */ + function data_field_debug() { + return array( + array( true ), + array( false ), + ); + } + + // Test that no section is created if no section args are passed. + function test_no_section() { + new Fieldmanager_Context_Customize( array(), $this->field ); + $this->assertEmpty( $this->manager->get_section( $this->field->name ) ); + } + + // Test that a section is created even with empty constructor args. + function test_bare_section() { + new Fieldmanager_Context_Customize( array( 'section_args' => array() ), $this->field ); + $this->register(); + $this->assertInstanceOf( 'WP_Customize_Section', $this->manager->get_section( $this->field->name ) ); + } + + // Test that a section is created with a title string. + function test_section_title() { + $title = rand_str(); + + new Fieldmanager_Context_Customize( array( 'section_args' => array( 'title' => $title ) ), $this->field ); + $this->register(); + $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 ); + + new Fieldmanager_Context_Customize( 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 ); + } + + // Test that a setting is created even without constructor args. + function test_bare_setting() { + new Fieldmanager_Context_Customize( '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(); + + new Fieldmanager_Context_Customize( 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 ); + } + + // Test that a control is created even without constructor args. + function test_bare_control() { + new Fieldmanager_Context_Customize( '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'] ); + } + + // Test that a control is created with constructor args. + function test_control_args() { + $label = rand_str(); + $section = rand_str(); + + new Fieldmanager_Context_Customize( 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 ); + } + + // Test that multiple objects are created with constructor args. + 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_Customize( 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 ); + } + + // Make sure validating passes a valid value passed as a bare string and as a query string. + function test_validate_valid_value() { + $this->field->validate = array( 'is_numeric' ); + + $context = new Fieldmanager_Context_Customize( 'Foo', $this->field ); + $this->register(); + $setting = $this->manager->get_setting( $this->field->name ); + + $validity = rand_str(); + $this->assertSame( $validity, $context->validate_callback( $validity, rand( 1, 100 ), $setting ) ); + $this->assertSame( $validity, $context->validate_callback( $validity, "{$this->field->name}=" . rand( 1, 100 ), $setting ) ); + } + + /** + * Make sure validating fails an invalid value (with both FM debug settings) + * passed as a bare string and as a query string. + * + * @dataProvider data_field_debug + */ + function test_validate_invalid_value( $debug ) { + Fieldmanager_Field::$debug = $debug; + + $this->field->validate = array( 'is_numeric' ); + + $context = new Fieldmanager_Context_Customize( 'Foo', $this->field ); + $this->register(); + $setting = $this->manager->get_setting( $this->field->name ); + + $message = ( $debug ) ? __( 'Failed with $debug = true', 'fieldmanager' ) : __( 'Failed with $debug = false', 'fieldmanager' ); + + $this->assertWPError( + $context->validate_callback( rand_str(), rand_str(), $setting ), + $message + ); + + $this->assertWPError( + $context->validate_callback( rand_str(), "{$this->field->name}=" . rand_str(), $setting ), + $message + ); + } + + /** + * Test that a textfield is sanitized the same way when the value is passed + * as a bare string and a query string. + */ + function test_sanitize_string() { + $value = rand_str(); + + $context = new Fieldmanager_Context_Customize( '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 ) ); + $this->assertNull( $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', + '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_Customize( '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 ) ); + } + + // Make sure sanitizing strips slashes. + function test_sanitize_stripslashes() { + $context = new Fieldmanager_Context_Customize( 'Foo', $this->field ); + $this->register(); + $this->assertSame( 'Foo "bar" baz', $context->sanitize_callback( 'Foo \"bar\" baz', $this->manager->get_setting( $this->field->name ) ) ); + } + + /** + * Make sure sanitizing returns a WP_Error on an invalid value. + * + * @dataProvider data_field_debug + */ + function test_sanitize_invalid_value( $debug ) { + global $wp_actions; + + Fieldmanager_Field::$debug = $debug; + $this->field->validate = array( 'is_numeric' ); + + $context = new Fieldmanager_Context_Customize( 'Foo', $this->field ); + $this->register(); + do_action( 'customize_save_validation_before' ); + + $this->assertWPError( $context->sanitize_callback( rand_str(), $this->manager->get_setting( $this->field->name ) ) ); + + // "Revert" to not affect future did_action() checks. + $wp_actions['customize_save_validation_before']--; + } + + /** + * Make sure sanitizing returns null on an invalid value where we don't have Customizer validation. + * + * @dataProvider data_field_debug + */ + function test_sanitize_invalid_value_backcompat( $debug ) { + Fieldmanager_Field::$debug = $debug; + $this->field->validate = array( 'is_numeric' ); + + $context = new Fieldmanager_Context_Customize( 'Foo', $this->field ); + $this->register(); + + $this->assertNull( $context->sanitize_callback( rand_str(), $this->manager->get_setting( $this->field->name ) ) ); + } + + // 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_Customize( '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..56e02acf1c --- /dev/null +++ b/tests/php/test-fieldmanager-customize-control.php @@ -0,0 +1,76 @@ +mock_context = $this->getMockBuilder( 'Fieldmanager_Context_Customize' ) + ->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 ) ) ); + } + + function test_render_content() { + $name = rand_str(); + $value = rand_str(); + $label = rand_str(); + $description = rand_str(); + + $context = new Fieldmanager_Context_Customize( array( + // Bypass capability checks. + 'section_args' => array( 'capability' => 'exist' ), + 'setting_args' => array( 'capability' => 'exist' ), + 'control_args' => array( 'label' => $label, 'description' => $description ), + ), 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 ); + $this->assertRegExp( '#<([^ ]+)[^>]*?>' . $label . '#', $actual ); + $this->assertRegExp( '#<([^ ]+)[^>]*?>' . $description . '#', $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..10c4ab476e --- /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_Customize( 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 d44cf07fcc..fba97b0d0d 100644 --- a/tests/php/test-fieldmanager-script-loading.php +++ b/tests/php/test-fieldmanager-script-loading.php @@ -9,6 +9,9 @@ class Test_Fieldmanager_Script_Loading extends Fieldmanager_Assets_Unit_Test_Cas public function setUp() { parent::setUp(); + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + $customize_manager = new WP_Customize_Manager(); + // Instantiate field classes that register scripts. new Fieldmanager_Autocomplete( 'Test', array( 'datasource' => new Fieldmanager_Datasource_Post ) ); new Fieldmanager_Datepicker( 'Test' ); @@ -19,6 +22,16 @@ public function setUp() { new Fieldmanager_Select( 'Test' ); new Fieldmanager_RichTextArea( 'Test' ); new Fieldmanager_Colorpicker( 'Test' ); + $control = new Fieldmanager_Customize_Control( + $customize_manager, + 'test', + array( + 'context' => $this->getMockBuilder( 'Fieldmanager_Context_Customize' ) + ->disableOriginalConstructor() + ->getMock() + ) + ); + $control->enqueue(); do_action( 'wp_enqueue_scripts' ); do_action( 'admin_enqueue_scripts' ); @@ -44,6 +57,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' ) ), ); } diff --git a/tests/php/testcase/class-fieldmanager-customize-unittestcase.php b/tests/php/testcase/class-fieldmanager-customize-unittestcase.php new file mode 100644 index 0000000000..cc5b55d21b --- /dev/null +++ b/tests/php/testcase/class-fieldmanager-customize-unittestcase.php @@ -0,0 +1,14 @@ +manager = new WP_Customize_Manager(); + } + + function register() { + do_action( 'customize_register', $this->manager ); + } +}