diff --git a/.eslintignore b/.eslintignore index 6b5adc8..0cbe75b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,6 @@ **/*.min.js **/node_modules/** **/vendor/** +/tests/js/lib/** **/*.browserified.js /bower_components/** diff --git a/.jscsrc b/.jscsrc index 29ae93e..ee25807 100644 --- a/.jscsrc +++ b/.jscsrc @@ -1,10 +1,15 @@ { "preset": "wordpress", + "requireCamelCaseOrUpperCaseIdentifiers": { + "ignoreProperties": true, + "allExcept": [ "Shortcode_UI" ] + }, "excludeFiles": [ "**/*.min.js", "**/*.jsx", "**/node_modules/**", "**/vendor/**", + "**/tests/**", "bower_components/**" ] } diff --git a/.jshintignore b/.jshintignore index b84ca0d..6a041e1 100644 --- a/.jshintignore +++ b/.jshintignore @@ -3,3 +3,4 @@ **/vendor/** **/*.jsx /bower_components/** +/tests/js/** diff --git a/.travis.yml b/.travis.yml index 39a9473..999efab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,6 +21,7 @@ install: - source $DEV_LIB_PATH/travis.install.sh script: + - npm test - source $DEV_LIB_PATH/travis.script.sh after_script: diff --git a/composer.json b/composer.json index f17f22f..d854e77 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "xwp/wp-js-widgets", "description": "The next generation of widgets in Core (Widgets 3.0), embracing JS for UI and powering the Widgets REST API.", - "version": "0.3.0", + "version": "0.4.0", "type": "wordpress-plugin", "keywords": [ "customizer", "widgets", "rest-api" ], "homepage": "https://github.com/xwp/wp-js-widgets/", diff --git a/core-adapter-widgets/archives/class.php b/core-adapter-widgets/archives/class.php index 70e4a9d..362db76 100644 --- a/core-adapter-widgets/archives/class.php +++ b/core-adapter-widgets/archives/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Archives extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-archive'; + /** * WP_JS_Widget_Archives constructor. * diff --git a/core-adapter-widgets/archives/form.js b/core-adapter-widgets/archives/form.js index fb24a29..0621a96 100644 --- a/core-adapter-widgets/archives/form.js +++ b/core-adapter-widgets/archives/form.js @@ -13,9 +13,7 @@ wp.widgets.formConstructor.archives = (function() { * * @constructor */ - ArchivesWidgetForm = wp.widgets.Form.extend( { - id_base: 'archives' - } ); + ArchivesWidgetForm = wp.widgets.Form.extend( {} ); if ( 'undefined' !== typeof module ) { module.exports = ArchivesWidgetForm; diff --git a/core-adapter-widgets/calendar/class.php b/core-adapter-widgets/calendar/class.php index 1579979..0daa738 100644 --- a/core-adapter-widgets/calendar/class.php +++ b/core-adapter-widgets/calendar/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Calendar extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-calendar'; + /** * WP_JS_Widget_Calendar constructor. * diff --git a/core-adapter-widgets/categories/class.php b/core-adapter-widgets/categories/class.php index 156db5d..e26b73a 100644 --- a/core-adapter-widgets/categories/class.php +++ b/core-adapter-widgets/categories/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Categories extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-category'; + /** * WP_JS_Widget_Categories constructor. * @@ -164,7 +171,7 @@ public function get_categories_list( $args ) { public function get_rest_response_links( $response, $request, $controller ) { $links = array(); - $links['wp:term'] = array(); + $links['item'] = array(); foreach ( $response->data['terms'] as $term_id ) { $term = get_term( (int) $term_id ); if ( empty( $term ) || is_wp_error( $term ) ) { @@ -178,7 +185,7 @@ public function get_rest_response_links( $response, $request, $controller ) { $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; $base = sprintf( '/wp/v2/%s', $rest_base ); - $links['wp:term'][] = array( + $links['item'][] = array( 'href' => rest_url( trailingslashit( $base ) . $term_id ), 'embeddable' => true, 'taxonomy' => $term->taxonomy, diff --git a/core-adapter-widgets/categories/form.js b/core-adapter-widgets/categories/form.js index a852d4e..d198c97 100644 --- a/core-adapter-widgets/categories/form.js +++ b/core-adapter-widgets/categories/form.js @@ -13,9 +13,7 @@ wp.widgets.formConstructor.categories = (function() { * * @constructor */ - CategoriesWidgetForm = wp.widgets.Form.extend( { - id_base: 'categories' - } ); + CategoriesWidgetForm = wp.widgets.Form.extend( {} ); if ( 'undefined' !== typeof module ) { module.exports = CategoriesWidgetForm; diff --git a/core-adapter-widgets/meta/class.php b/core-adapter-widgets/meta/class.php index 04ba2c6..8bdd42a 100644 --- a/core-adapter-widgets/meta/class.php +++ b/core-adapter-widgets/meta/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Meta extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-wordpress'; + /** * WP_JS_Widget_Meta constructor. * @@ -22,7 +29,6 @@ public function __construct( JS_Widgets_Plugin $plugin, WP_Widget_Meta $adapted_ parent::__construct( $plugin, $adapted_widget ); } - /** * Get instance schema properties. * diff --git a/core-adapter-widgets/nav_menu/class.php b/core-adapter-widgets/nav_menu/class.php index dcd02bb..c514735 100644 --- a/core-adapter-widgets/nav_menu/class.php +++ b/core-adapter-widgets/nav_menu/class.php @@ -14,6 +14,13 @@ */ class WP_JS_Widget_Nav_Menu extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-menu'; + /** * WP_JS_Widget_Nav_Menu constructor. * @@ -101,7 +108,7 @@ public function render_form_template() { ), ) ); ?> -

+

diff --git a/core-adapter-widgets/nav_menu/form.js b/core-adapter-widgets/nav_menu/form.js index e7301a5..c4020c7 100644 --- a/core-adapter-widgets/nav_menu/form.js +++ b/core-adapter-widgets/nav_menu/form.js @@ -101,7 +101,7 @@ wp.widgets.formConstructor.nav_menu = (function( api, $ ) { initialize: function initialize( properties ) { var form = this; wp.widgets.Form.prototype.initialize.call( form, properties ); - _.bindAll( form, 'updateForm', 'handleEditButtonClick' ); + _.bindAll( form, 'updateForm', 'handleEditButtonClick', 'updateEditButtonVisibility' ); if ( _.isObject( form.config.nav_menus ) && 0 === classProps.navMenuCollection.length ) { _.each( form.config.nav_menus, function( name, id ) { @@ -120,10 +120,12 @@ wp.widgets.formConstructor.nav_menu = (function( api, $ ) { var form = this; wp.widgets.Form.prototype.render.call( form ); NavMenuWidgetForm.navMenuCollection.on( 'update change', form.updateForm ); + form.model.bind( form.updateEditButtonVisibility ); form.container.find( 'button.edit' ).on( 'click', form.handleEditButtonClick ); form.noMenusMessage = form.container.find( '.no-menus-message' ); form.menuSelection = form.container.find( '.menu-selection' ); form.updateForm(); + form.updateEditButtonVisibility(); }, /** @@ -135,6 +137,7 @@ wp.widgets.formConstructor.nav_menu = (function( api, $ ) { var form = this; form.container.find( 'button.edit' ).off( 'click', form.handleEditButtonClick ); NavMenuWidgetForm.navMenuCollection.off( 'update change', form.updateForm ); + form.model.unbind( form.updateEditButtonVisibility ); form.noMenusMessage = null; form.menuSelection = null; wp.widgets.Form.prototype.destruct.call( form ); @@ -182,6 +185,17 @@ wp.widgets.formConstructor.nav_menu = (function( api, $ ) { select.val( NavMenuWidgetForm.navMenuCollection.has( currentValue.nav_menu ) ? currentValue.nav_menu : 0 ); form.noMenusMessage.toggle( 0 === NavMenuWidgetForm.navMenuCollection.length ); form.menuSelection.toggle( 0 !== NavMenuWidgetForm.navMenuCollection.length ); + }, + + /** + * Update the visibility of the edit button based on whether a menu is selected. + * + * @returns {void} + */ + updateEditButtonVisibility: function updateEditButtonVisibility() { + var form = this, button; + button = form.container.find( '.edit-menu' ); + button.toggle( NavMenuWidgetForm.navMenuCollection.length > 0 && form.getValue().nav_menu > 0 ); } }, classProps ); diff --git a/core-adapter-widgets/pages/class.php b/core-adapter-widgets/pages/class.php index e09c3cf..3f33c86 100644 --- a/core-adapter-widgets/pages/class.php +++ b/core-adapter-widgets/pages/class.php @@ -14,6 +14,13 @@ class WP_JS_Widget_Pages extends WP_Adapter_JS_Widget { const ID_LIST_PATTERN = '\d+(,\s*\d+)*'; + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-admin-page'; + /** * WP_JS_Widget_Pages constructor. * @@ -180,7 +187,7 @@ public function prepare_item_for_response( $instance, $request ) { public function get_rest_response_links( $response, $request, $controller ) { $links = array(); - $links['wp:page'] = array(); + $links['item'] = array(); foreach ( $response->data['pages'] as $post_id ) { $post = get_post( $post_id ); if ( empty( $post ) ) { @@ -194,7 +201,7 @@ public function get_rest_response_links( $response, $request, $controller ) { $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; $base = sprintf( '/wp/v2/%s', $rest_base ); - $links['wp:page'][] = array( + $links['item'][] = array( 'href' => rest_url( trailingslashit( $base ) . $post_id ), 'embeddable' => true, 'post_type' => $post->post_type, diff --git a/core-adapter-widgets/pages/form.js b/core-adapter-widgets/pages/form.js index 5a63922..6f344da 100644 --- a/core-adapter-widgets/pages/form.js +++ b/core-adapter-widgets/pages/form.js @@ -81,8 +81,8 @@ wp.widgets.formConstructor.pages = (function( api ) { excludeIds.push( id ); } } ); + form.model._value.exclude = excludeIds; } - form.model._value.exclude = excludeIds; form.syncedProperties.exclude = form.createSyncedPropertyValue( form.model, 'exclude' ); } diff --git a/core-adapter-widgets/recent-comments/class.php b/core-adapter-widgets/recent-comments/class.php index 8361326..d7ed745 100644 --- a/core-adapter-widgets/recent-comments/class.php +++ b/core-adapter-widgets/recent-comments/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Recent_Comments extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-admin-comments'; + /** * WP_JS_Widget_Recent_Comments constructor. * @@ -94,9 +101,9 @@ public function prepare_item_for_response( $instance, $request ) { public function get_rest_response_links( $response, $request, $controller ) { $links = array(); - $links['wp:comment'] = array(); + $links['item'] = array(); foreach ( $response->data['comments'] as $comment_id ) { - $links['wp:comment'][] = array( + $links['item'][] = array( 'href' => rest_url( "/wp/v2/comments/$comment_id" ), 'embeddable' => true, ); diff --git a/core-adapter-widgets/recent-posts/class.php b/core-adapter-widgets/recent-posts/class.php index 74732c7..92f29e5 100644 --- a/core-adapter-widgets/recent-posts/class.php +++ b/core-adapter-widgets/recent-posts/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Recent_Posts extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-admin-post'; + /** * WP_JS_Widget_Recent_Posts constructor. * @@ -104,7 +111,7 @@ public function prepare_item_for_response( $instance, $request ) { public function get_rest_response_links( $response, $request, $controller ) { $links = array(); - $links['wp:post'] = array(); + $links['item'] = array(); foreach ( $response->data['posts'] as $post_id ) { $post = get_post( $post_id ); if ( empty( $post ) ) { @@ -118,7 +125,7 @@ public function get_rest_response_links( $response, $request, $controller ) { $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; $base = sprintf( '/wp/v2/%s', $rest_base ); - $links['wp:post'][] = array( + $links['item'][] = array( 'href' => rest_url( trailingslashit( $base ) . $post_id ), 'embeddable' => true, 'post_type' => $post->post_type, diff --git a/core-adapter-widgets/rss/class.php b/core-adapter-widgets/rss/class.php index e984949..3ba22ea 100644 --- a/core-adapter-widgets/rss/class.php +++ b/core-adapter-widgets/rss/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_RSS extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-rss'; + /** * WP_JS_Widget_RSS constructor. * diff --git a/core-adapter-widgets/search/class.php b/core-adapter-widgets/search/class.php index 7c32e2f..a37efaf 100644 --- a/core-adapter-widgets/search/class.php +++ b/core-adapter-widgets/search/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Search extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-search'; + /** * WP_JS_Widget_Search constructor. * diff --git a/core-adapter-widgets/tag_cloud/class.php b/core-adapter-widgets/tag_cloud/class.php index 8651547..81bc6d1 100644 --- a/core-adapter-widgets/tag_cloud/class.php +++ b/core-adapter-widgets/tag_cloud/class.php @@ -12,6 +12,13 @@ */ class WP_JS_Widget_Tag_Cloud extends WP_Adapter_JS_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-tagcloud'; + /** * WP_JS_Widget_Tag_Cloud constructor. * @@ -28,7 +35,9 @@ public function __construct( JS_Widgets_Plugin $plugin, WP_Widget_Tag_Cloud $ada * @return array Schema. */ public function get_item_schema() { - $taxonomies = get_taxonomies( array( 'show_tagcloud' => true ), 'names' ); + $taxonomies = get_taxonomies( array( + 'show_tagcloud' => true, + ), 'names' ); if ( ! get_option( 'link_manager_enabled' ) ) { unset( $taxonomies['link_category'] ); } @@ -80,7 +89,9 @@ public function validate_taxonomy( $taxonomy, $request, $param ) { if ( true !== $validity ) { return $validity; } - if ( 0 === count( get_taxonomies( array( 'show_tagcloud' => true ), 'names' ) ) ) { + if ( 0 === count( get_taxonomies( array( + 'show_tagcloud' => true, + ), 'names' ) ) ) { return new WP_Error( 'no_tagcloud_taxonomies', __( 'The tag cloud will not be displayed since there are no taxonomies that support the tag cloud widget.', 'default' ) ); } return true; @@ -133,7 +144,10 @@ public function prepare_item_for_response( $instance, $request ) { $tags = get_terms( $tag_cloud_args['taxonomy'], - array_merge( $tag_cloud_args, array( 'orderby' => 'count', 'order' => 'DESC' ) ) // Always query top tags. + array_merge( $tag_cloud_args, array( + 'orderby' => 'count', + 'order' => 'DESC', + ) ) // Always query top tags. ); // @todo should these not be _links? @@ -174,7 +188,9 @@ public function render_form_template() { $this->render_title_form_field_template( array( 'placeholder' => $item_schema['title']['properties']['raw']['default'], ) ); - $taxonomies = get_taxonomies( array( 'show_tagcloud' => true ), 'object' ); + $taxonomies = get_taxonomies( array( + 'show_tagcloud' => true, + ), 'object' ); if ( ! get_option( 'link_manager_enabled' ) ) { unset( $taxonomies['link_category'] ); } @@ -184,7 +200,7 @@ public function render_form_template() { $taxonomy_choices[ $taxonomy->name ] = $taxonomy->label; } $this->render_form_field_template( array( - 'name' => 'taxonomy', + 'field' => 'taxonomy', 'label' => __( 'Taxonomy:', 'default' ), 'type' => 'select', 'choices' => $taxonomy_choices, diff --git a/core-adapter-widgets/text/class.php b/core-adapter-widgets/text/class.php index dc8c0c1..2b68e95 100644 --- a/core-adapter-widgets/text/class.php +++ b/core-adapter-widgets/text/class.php @@ -19,6 +19,13 @@ class WP_JS_Widget_Text extends WP_Adapter_JS_Widget { */ public $adapted_widget; + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-text'; + /** * Get instance schema properties. * diff --git a/css/widget-form.css b/css/widget-form.css index 28aa0c6..19448e5 100644 --- a/css/widget-form.css +++ b/css/widget-form.css @@ -60,3 +60,54 @@ .js-widget-form-notifications-container .notice-warning.notice-alt { background-color: #fff8e5; } + +.js-widget-form-shortcode-ui input[type="text"], +.js-widget-form-shortcode-ui input[type="password"], +.js-widget-form-shortcode-ui input[type="color"], +.js-widget-form-shortcode-ui input[type="date"], +.js-widget-form-shortcode-ui input[type="datetime"], +.js-widget-form-shortcode-ui input[type="datetime-local"], +.js-widget-form-shortcode-ui input[type="email"], +.js-widget-form-shortcode-ui input[type="month"], +.js-widget-form-shortcode-ui input[type="number"], +.js-widget-form-shortcode-ui input[type="search"], +.js-widget-form-shortcode-ui input[type="tel"], +.js-widget-form-shortcode-ui input[type="text"], +.js-widget-form-shortcode-ui input[type="time"], +.js-widget-form-shortcode-ui input[type="url"], +.js-widget-form-shortcode-ui input[type="week"], +.js-widget-form-shortcode-ui select, +.js-widget-form-shortcode-ui textarea { + width: 25em; + padding: 3px 5px; + font-size: 14px; +} + +.js-widget-form-shortcode-ui input[type="checkbox"], +.js-widget-form-shortcode-ui input[type="radio"] { + border: 1px solid #b4b9be; + color: #555; +} +.js-widget-form-shortcode-ui input[type="checkbox"]:focus, +.js-widget-form-shortcode-ui input[type="radio"]:focus { + border-color: #5b9dd9; +} +.js-widget-form-shortcode-ui input[type="checkbox"] + label, +.js-widget-form-shortcode-ui input[type="radio"] + label { + display: inline; + clear: none; +} + +.js-widget-form-shortcode-ui label { + color: #555d66; +} + +.js-widget-form-shortcode-ui .select2-container { + max-width: 26.92em; /* 25em * 14px = 350px => 350px / 13px = 26.92em */ +} + +/* Shortcake style fixes */ +.edit-shortcode-form .js-widget-form-notifications-container label { + clear: none; + display: inline; +} diff --git a/dev-lib b/dev-lib index c061303..4bc43e4 160000 --- a/dev-lib +++ b/dev-lib @@ -1 +1 @@ -Subproject commit c061303c696a327861604fc781c7585c76d24ade +Subproject commit 4bc43e4b00a3f5f2d1dd54cb61538ba445438c03 diff --git a/js-widgets.php b/js-widgets.php index 13b446a..9172d2e 100644 --- a/js-widgets.php +++ b/js-widgets.php @@ -3,8 +3,8 @@ * Plugin Name: JS Widgets * Description: The next generation of widgets in core, embracing JS for UI and powering the Widgets REST API. * Plugin URI: https://github.com/xwp/wp-js-widgets/ - * Version: 0.3.0 - * Author: Weston Ruter, XWP + * Version: 0.4.0 + * Author: XWP * Author URI: https://make.xwp.co/ * License: GPLv2+ * diff --git a/js/customize-js-widgets.js b/js/customize-js-widgets.js index 45d7c80..2122332 100644 --- a/js/customize-js-widgets.js +++ b/js/customize-js-widgets.js @@ -3,7 +3,7 @@ /* eslint-disable complexity */ /* eslint consistent-this: [ "error", "control" ] */ -wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused-vars +wp.customize.JSWidgets = (function( wp, api, $, _ ) { // eslint-disable-line no-unused-vars 'use strict'; var component = {}, originalInitialize; @@ -16,7 +16,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @returns {void} */ component.init = function initComponent() { - component.extendWidgetControl(); + component.extendWidgetControl( api.Widgets.WidgetControl ); // Handle (re-)adding a (previously-removed) control. api.control.bind( 'add', function( addedControl ) { @@ -46,9 +46,10 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- /** * Inject WidgetControl instances with our component.WidgetControl method overrides. * - * @returns {void} + * @param {wp.Customize.Widgets.WidgetControl} WidgetControl The constructor function to modify + * @returns {WidgetControl} The constructor function with a modified prototype */ - component.extendWidgetControl = function extendWidgetControl() { + component.extendWidgetControl = function extendWidgetControl( WidgetControl ) { /** * Initialize JS widget control. @@ -57,7 +58,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @param {object} options Control options. * @returns {void} */ - api.Widgets.WidgetControl.prototype.initialize = function initializeWidgetControl( id, options ) { + WidgetControl.prototype.initialize = function initializeWidgetControl( id, options ) { var control = this, isJsWidget; isJsWidget = options.params.widget_id_base && 'undefined' !== typeof wp.widgets.formConstructor[ options.params.widget_id_base ]; if ( isJsWidget ) { @@ -67,6 +68,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- originalInitialize.call( control, id, options ); } }; + return WidgetControl; }; /** @@ -83,11 +85,11 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- /** * Initialize. * - * @param {string} id - * @param {object} options - * @param {object} options.params - * @param {string} options.params.widget_id - * @param {string} options.params.widget_id_base + * @param {string} id The widget id + * @param {object} options The options (see below) + * @param {object} options.params The params (see below) + * @param {string} options.params.widget_id The widget id + * @param {string} options.params.widget_id_base The widget id_base * @param {string} [options.params.type] - Must be 'widget_form'. * @param {string} [options.params.content] - This may be supplied by addWidget, but it will not be read since the form is constructed dynamically. * @param {string} [options.params.widget_control] - Handled internally, if supplied, an error will be thrown. @@ -232,7 +234,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * Submit the widget form via Ajax and get back the updated instance, * along with the new widget control form to render. * - * @param {object} [args] + * @param {object} [args] The args to update the widget (see below) * @param {Object|null} [args.instance=null] When the model changes, the instance is sent here; otherwise, the inputs from the form are used * @param {Function|null} [args.complete=null] Function which is called when the request finishes. Context is bound to the control. First argument is any error. Following arguments are for success. * @returns {void} @@ -259,6 +261,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * * @deprecated * @private + * @return {void} */ _getInputs: function _getInputs() { throw new Error( 'The _getInputs method should not be called for customize widget instances.' ); @@ -271,6 +274,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * * @deprecated * @private + * @return {void} */ _getInputsSignature: function _getInputsSignature() { throw new Error( 'The _getInputsSignature method should not be called for customize widget instances.' ); @@ -283,6 +287,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * * @deprecated * @private + * @return {void} */ _getInputState: function _getInputState() { throw new Error( 'The _getInputState method should not be called for customize widget instances.' ); @@ -295,6 +300,7 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * * @deprecated * @private + * @return {void} */ _setInputState: function _setInputState() { throw new Error( 'The _setInputState method should not be called for customize widget instances.' ); @@ -310,4 +316,4 @@ wp.customize.JSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- return component; -})( wp.customize, jQuery ); +})( wp, wp.customize, jQuery, _ ); diff --git a/js/shortcode-ui-view-widget-form-field.js b/js/shortcode-ui-view-widget-form-field.js new file mode 100644 index 0000000..a02153e --- /dev/null +++ b/js/shortcode-ui-view-widget-form-field.js @@ -0,0 +1,47 @@ +/* global wp, JSON, Shortcode_UI */ +/* eslint-disable strict */ +/* eslint consistent-this: [ "error", "view" ] */ +/* eslint-disable complexity */ + +Shortcode_UI.views.widgetFormField = (function( sui ) { + 'use strict'; + + /** + * Widget form. + * + * @class + */ + return sui.views.editAttributeField.extend( { + + events: {}, + + /** + * Render. + * + * @return {sui.views.editAttributeField} View. + */ + render: function() { + var view = this, FormConstructor, instanceValue, form; + + if ( ! view.shortcode.get( 'widgetType' ) || ! wp.widgets.formConstructor[ view.shortcode.get( 'widgetType' ) ] ) { + throw new Error( 'Unable to determine the widget type.' ); + } + + view.$el.addClass( 'js-widget-form-shortcode-ui' ); + instanceValue = new wp.customize.Value( view.getValue() ? JSON.parse( view.getValue() ) : {} ); + FormConstructor = wp.widgets.formConstructor[ view.shortcode.get( 'widgetType' ) ]; + form = new FormConstructor( { + model: instanceValue, + container: view.$el + } ); + instanceValue.bind( function( instanceData ) { + view.setValue( JSON.stringify( instanceData ) ); + } ); + form.render(); + + view.triggerCallbacks(); + return view; + } + } ); + +})( Shortcode_UI ); diff --git a/js/widget-form.js b/js/widget-form.js index 5630f8d..2dee2f6 100644 --- a/js/widget-form.js +++ b/js/widget-form.js @@ -1,6 +1,6 @@ -/* global wp, console */ +/* global wp, console, module */ /* eslint-disable strict */ -/* eslint consistent-this: [ "error", "form" ] */ +/* eslint consistent-this: [ "error", "form", "setting" ] */ /* eslint-disable complexity */ if ( ! wp.widgets ) { @@ -10,7 +10,7 @@ if ( ! wp.widgets.formConstructor ) { wp.widgets.formConstructor = {}; } -wp.widgets.Form = (function( api, $ ) { +wp.widgets.Form = (function( api, $, _ ) { 'use strict'; /** @@ -31,97 +31,22 @@ wp.widgets.Form = (function( api, $ ) { * Initialize. * * @param {object} properties Properties. - * @param {string} properties.id_base The widget ID base (aka type). * @param {wp.customize.Value} properties.model The Value or Setting instance containing the widget instance data object. - * @param {Element|jQuery} properties.container The Value or Setting instance containing the widget instance data object. + * @param {string|Element|jQuery} properties.container The selector string or DOM element in which to render this Form. * @param {object} properties.config Form config. * @return {void} */ initialize: function initialize( properties ) { - var form = this, args, previousValidate; - - args = _.extend( - { - model: null, - container: null, - config: ! _.isEmpty( form.config ) ? _.clone( form.config ) : { - form_template_id: '', - notifications_template_id: '', - l10n: {}, - default_instance: {} - } - }, - properties ? _.clone( properties ) : {} - ); - - if ( ! args.model || ! args.model.extended || ! args.model.extended( api.Value ) ) { - throw new Error( 'Missing model property which must be a Value or Setting instance.' ); - } + var form = this; - _.extend( form, args ); - form.setting = args.model; // @todo Deprecate 'setting' name in favor of 'model'? + _.extend( form, getValidatedFormProperties( form.config, properties ) ); - if ( form.model.notifications ) { - form.notifications = form.model.notifications; - } else { - form.notifications = new api.Values({ defaultConstructor: api.Notification }); - } + form.setting = form.model; // @todo Deprecate 'setting' name in favor of 'model'? + form.notifications = form.model.notifications || new api.Values( { defaultConstructor: api.Notification } ); form.renderNotifications = _.bind( form.renderNotifications, form ); - form.container = $( form.container ); - if ( 0 === form.container.length ) { - throw new Error( 'Missing container property as Element or jQuery.' ); - } - - previousValidate = form.model.validate; - - /** - * Validate the instance data. - * - * @todo In order for returning an error/notification to work properly, api._handleSettingValidities needs to only remove notification errors that are no longer valid which are fromServer: - * - * @param {object} value Instance value. - * @returns {object|Error|wp.customize.Notification} Sanitized instance value or error/notification. - */ - form.model.validate = function validate( value ) { - var setting = this, newValue, oldValue, error, code, notification; // eslint-disable-line consistent-this - newValue = _.extend( {}, form.config.default_instance, value ); - oldValue = _.extend( {}, setting() ); - - newValue = previousValidate.call( setting, newValue ); - - newValue = form.sanitize( newValue, oldValue ); - if ( newValue instanceof Error ) { - error = newValue; - code = 'invalidValue'; - notification = new api.Notification( code, { - message: error.message, - type: 'error' - } ); - } else if ( newValue instanceof api.Notification ) { - notification = newValue; - } - // If sanitize method returned an error/notification, block setting u0date. - if ( notification ) { - newValue = null; - } - - // Remove all existing notifications added via sanitization since only one can be returned. - form.notifications.each( function iterateNotifications( iteratedNotification ) { - if ( iteratedNotification.viaWidgetFormSanitizeReturn && ( ! notification || notification.code !== iteratedNotification.code ) ) { - form.notifications.remove( iteratedNotification.code ); - } - } ); - - // Add the new notification. - if ( notification ) { - notification.viaWidgetFormSanitizeReturn = true; - form.notifications.add( notification.code, notification ); - } - - return newValue; - }; + assertValidForm( form ); }, /** @@ -138,44 +63,31 @@ wp.widgets.Form = (function( api, $ ) { * @returns {void} */ renderNotifications: _.debounce( function renderNotifications() { - var form = this, container, notifications, hasError = false; + this.renderNotificationsToContainer(); + } ), + + renderNotificationsToContainer: function renderNotificationsToContainer() { + var form = this, container, notifications, templateFunction; container = form.getNotificationsContainerElement(); if ( ! container || ! container.length ) { return; } - notifications = []; - form.notifications.each( function( notification ) { - notifications.push( notification ); - if ( 'error' === notification.type ) { - hasError = true; - } - - if ( ! notification.hasA11ySpoken ) { - - // @todo In the context of the Customizer, this presently will end up getting spoken twice due to wp.customize.Control also rendering it. - wp.a11y.speak( notification.message, 'assertive' ); - notification.hasA11ySpoken = true; - } - } ); + notifications = getArrayFromValues( form.notifications ); - if ( 0 === notifications.length ) { - container.stop().slideUp( 'fast' ); - } else { - container.stop().slideDown( 'fast', null, function() { - $( this ).css( 'height', 'auto' ); + toggleContainer( container, notifications.length > 0 ) + .then( function() { + if ( notifications.length > 0 ) { + container.css( 'height', 'auto' ); + } } ); - } + form.container.toggleClass( 'has-error', notifications.filter( isNotificationError ).length > 0 ); + form.container.toggleClass( 'has-notifications', notifications.length > 0 ); - if ( ! form._notificationsTemplate ) { - form._notificationsTemplate = wp.template( form.config.notifications_template_id ); - } + notifications.map( speakNotification ); - form.container.toggleClass( 'has-notifications', 0 !== notifications.length ); - form.container.toggleClass( 'has-error', hasError ); - container.empty().append( $.trim( - form._notificationsTemplate( { notifications: notifications, altNotice: Boolean( form.altNotice ) } ) - ) ); - } ), + templateFunction = getNotificationsTemplate( form ); + renderMarkupToContainer( container, templateFunction( { notifications: notifications, altNotice: Boolean( form.altNotice ) } ) ); + }, /** * Get the element inside of a form's container that contains the notifications. @@ -189,6 +101,34 @@ wp.widgets.Form = (function( api, $ ) { return form.container.find( '.js-widget-form-notifications-container:first' ); }, + /** + * Validate the instance data. + * + * @todo In order for returning an error/notification to work properly, api._handleSettingValidities needs to only remove notification errors that are no longer valid which are fromServer: + * + * @param {object} value Instance value. + * @returns {object|Error|wp.customize.Notification} Sanitized instance value or error/notification. + */ + validate: function validate( value ) { + var form = this, newValue, oldValue; + oldValue = form.model.get(); + newValue = form.sanitize( value, oldValue ); + + // Remove all existing notifications added via sanitization since only one can be returned. + removeSanitizeNotifications( form.notifications ); + + // If sanitize method returned an error/notification, block setting update and add a notification + if ( newValue instanceof Error ) { + newValue = new api.Notification( 'invalidValue', { message: newValue.message, type: 'error' } ); + } + if ( newValue instanceof api.Notification ) { + addSanitizeNotification( form, newValue ); + return null; + } + + return newValue; + }, + /** * Sanitize the instance data. * @@ -201,11 +141,7 @@ wp.widgets.Form = (function( api, $ ) { if ( _.isUndefined( oldInstance ) ) { throw new Error( 'Expected oldInstance' ); } - instance = _.extend( {}, form.config.default_instance, newInstance ); - - if ( ! instance.title ) { - instance.title = ''; - } + instance = _.extend( {}, newInstance ); // Warn about markup in title. code = 'markupTitleInvalid'; @@ -223,7 +159,9 @@ wp.widgets.Form = (function( api, $ ) { * Trim per sanitize_text_field(). * Protip: This prevents the widget partial from refreshing after adding a space or adding a new paragraph. */ - instance.title = $.trim( instance.title ); + if ( instance.title ) { + instance.title = $.trim( instance.title ); + } return instance; }, @@ -240,7 +178,7 @@ wp.widgets.Form = (function( api, $ ) { return _.extend( {}, form.config.default_instance, - form.model() || {} + form.model.get() || {} ); }, @@ -253,9 +191,12 @@ wp.widgets.Form = (function( api, $ ) { * @returns {void} */ setState: function setState( props ) { - var form = this, value; - value = _.extend( form.getValue(), props || {} ); - form.model.set( value ); + var form = this, validated; + validated = form.validate( _.extend( {}, form.model.get(), props ) ); + if ( ! validated || validated instanceof Error || validated instanceof api.Notification ) { + return; + } + form.model.set( validated ); }, /** @@ -272,15 +213,15 @@ wp.widgets.Form = (function( api, $ ) { * @returns {object} Property value instance. */ createSyncedPropertyValue: function createSyncedPropertyValue( root, property ) { - var propertyValue, rootChangeListener, propertyChangeListener; + var form = this, propertyValue, rootChangeListener, propertyChangeListener; - propertyValue = new api.Value( root.get()[ property ] ); + propertyValue = new api.Value( form.getValue()[ property ] ); // Sync changes to the property back to the root value. propertyChangeListener = function( newPropertyValue ) { - var rootValue = _.clone( root.get() ); - rootValue[ property ] = newPropertyValue; - root.set( rootValue ); + var newState = {}; + newState[ property ] = newPropertyValue; + form.setState( newState ); }; propertyValue.bind( propertyChangeListener ); @@ -395,4 +336,169 @@ wp.widgets.Form = (function( api, $ ) { } }); -} )( wp.customize, jQuery ); + /** + * Return an Array of an api.Values object's values + * + * @param {wp.customize.Values} values An instance of api.Values + * @return {Array} An array of api.Value objects + */ + function getArrayFromValues( values ) { + var ary = []; + values.each( function( value ) { + ary.push( value ); + } ); + return ary; + } + + /** + * Return true if the Notification is an error + * + * @param {wp.customize.Notification} notification An instance of api.Notification + * @return {Boolean} True if the `type` of the Notification is 'error' + */ + function isNotificationError( notification ) { + return 'error' === notification.type; + } + + /** + * Hide or show a DOM node using jQuery animation + * + * @param {jQuery} container The jQuery object to hide or show + * @param {Boolean} showContainer True to show the node, or false to hide it + * @return {Deferred} A promise that is resolved when the animation is complete + */ + function toggleContainer( container, showContainer ) { + var deferred = $.Deferred(); + if ( showContainer ) { + container.stop().slideDown( 'fast', null, function() { + deferred.resolve(); + } ); + } else { + container.stop().slideUp( 'fast', null, deferred.resolve ); + } + return deferred; + } + + /** + * Speak a Notification using wp.a11y + * + * Will only speak a Notification once, so if passed a Notification that has already been spoken, this is a noop. + * + * @param {Notification} notification The Notification to speak + * @return {void} + */ + function speakNotification( notification ) { + if ( ! notification.hasA11ySpoken ) { + + // @todo In the context of the Customizer, this presently will end up getting spoken twice due to wp.customize.Control also rendering it. + wp.a11y.speak( notification.message, 'assertive' ); + notification.hasA11ySpoken = true; + } + } + + /** + * Return the template function for rendering Notifications + * + * @param {Form} widgetForm The instance of the Form whose template to fetch + * @return {Function} The template function + */ + function getNotificationsTemplate( widgetForm ) { + if ( ! widgetForm._notificationsTemplate ) { + widgetForm._notificationsTemplate = wp.template( widgetForm.config.notifications_template_id ); + } + return widgetForm._notificationsTemplate; + } + + /** + * Replace the markup of a DOM node container + * + * @param {jQuery} container The DOM node which will be replaced by the markup + * @param {string} markup The markup to apply to the container + * @return {void} + */ + function renderMarkupToContainer( container, markup ) { + container.empty().append( $.trim( markup ) ); + } + + /** + * Removes Notification objects which have been added by `addSanitizeNotification` + * + * Note: this mutates the object itself! + * + * @param {wp.customize.Values} notifications An instance of api.Values containing Notification objects + * @return {void} + */ + function removeSanitizeNotifications( notifications ) { + notifications.each( function iterateNotifications( notification ) { + if ( notification.viaWidgetFormSanitizeReturn ) { + notifications.remove( notification.code ); + } + } ); + } + + /** + * Adds a Notification to a Form from the form's `sanitize` method + * + * @param {Form} widgetForm The instance of the Form to modify + * @param {wp.customize.Values} notification An instance of api.Notification to add + * @return {void} + */ + function addSanitizeNotification( widgetForm, notification ) { + notification.viaWidgetFormSanitizeReturn = true; + widgetForm.notifications.add( notification.code, notification ); + } + + /** + * Validate the properties of a Form + * + * Throws an Error if the properties are invalid. + * + * @param {Form} widgetForm The instance of the Form to modify + * @return {void} + */ + function assertValidForm( widgetForm ) { + if ( ! widgetForm.model || ! widgetForm.model.extended || ! widgetForm.model.extended( api.Value ) ) { + throw new Error( 'Widget Form is missing model property which must be a Value or Setting instance.' ); + } + if ( 0 === widgetForm.container.length ) { + throw new Error( 'Widget Form is missing container property as Element or jQuery.' ); + } + if ( ! widgetForm.config || ! widgetForm.config.default_instance ) { + throw new Error( 'Widget Form class is missing config.default_instance' ); + } + } + + /** + * Merges properties for a Form with the defaults + * + * The passed properties override the Form's config property which overrides the default values. + * + * @param {object} config The Form's current config property + * @param {object} properties The passed-in properties to the Form + * @return {object} The merged properties object + */ + function getValidatedFormProperties( config, properties ) { + var defaultConfig = { + form_template_id: '', + notifications_template_id: '', + l10n: {}, + default_instance: {} + }; + + var defaultProperties = { + model: null, + container: null, + config: {} + }; + + var formArguments = properties ? { model: properties.model, container: properties.container } : {}; + var validProperties = _.extend( {}, defaultProperties, formArguments ); + validProperties.config = _.extend( {}, defaultConfig, config ); + return validProperties; + } + +} )( wp.customize, jQuery, _ ); + +if ( 'undefined' !== typeof module ) { + module.exports = wp.widgets.Form; +} diff --git a/package.json b/package.json index 8ab0a6a..b0cf1b2 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "homepage": "https://github.com/xwp/wp-js-widgets", "repository": { "type": "git", - "url": "https://github.com/xwp/wp-js-widgets.git" + "url": "git+https://github.com/xwp/wp-js-widgets.git" }, - "version": "0.3.0", + "version": "0.4.0", "license": "GPL-2.0+", "private": true, "devDependencies": { + "chai": "^3.5.0", "eslint": "^3.13.1", "grunt": "~0.4.5", "grunt-contrib-clean": "~1.0.0", @@ -17,7 +18,25 @@ "grunt-contrib-jshint": "~1.0.0", "grunt-contrib-watch": "^1.0.0", "grunt-shell": "^1.3.1", - "grunt-wp-deploy": "^1.2.1" + "grunt-wp-deploy": "^1.2.1", + "jquery": "^3.1.1", + "jsdom": "^9.9.1", + "jsdom-global": "^2.1.1", + "mocha": "^3.2.0", + "sinon": "^1.17.7", + "sinon-chai": "^2.8.0", + "underscore": "^1.8.3" }, - "author": "XWP" + "author": "XWP", + "description": "The next generation of widgets in core, embracing JS for UI and powering the Widgets REST API.", + "bugs": { + "url": "https://github.com/xwp/wp-js-widgets/issues" + }, + "main": "Gruntfile.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "mocha tests/js" + } } diff --git a/php/class-js-widget-shortcode-controller.php b/php/class-js-widget-shortcode-controller.php new file mode 100644 index 0000000..2dcff28 --- /dev/null +++ b/php/class-js-widget-shortcode-controller.php @@ -0,0 +1,164 @@ +plugin = $plugin; + $this->widget = $widget; + } + + /** + * Get shortcode tag. + * + * @return string Shortcode tag. + */ + public function get_shortcode_tag() { + return sprintf( 'widget_%s', $this->widget->id_base ); + } + + /** + * Register shortcodes for widgets. + * + * @global WP_Widget_Factory $wp_widget_factory + */ + public function register_shortcode() { + add_shortcode( $this->get_shortcode_tag(), array( $this, 'render_widget_shortcode' ) ); + } + + /** + * Get sidebar args needed for rendering a widget. + * + * This will by default use the args from the first registered sidebar. + * + * @see WP_JS_Widget::render() + * + * @return array { + * Sidebar args. + * + * @type string $name Name of the sidebar the widget is assigned to. + * @type string $id ID of the sidebar the widget is assigned to. + * @type string $description The sidebar description. + * @type string $class CSS class applied to the sidebar container. + * @type string $before_widget HTML markup to prepend to each widget in the sidebar. + * @type string $after_widget HTML markup to append to each widget in the sidebar. + * @type string $before_title HTML markup to prepend to the widget title when displayed. + * @type string $after_title HTML markup to append to the widget title when displayed. + * @type string $widget_id ID of the widget. + * @type string $widget_name Name of the widget. + * } + */ + public function get_sidebar_args() { + global $wp_registered_sidebars; + reset( $wp_registered_sidebars ); + + $sidebar = current( $wp_registered_sidebars ); + + $widget_id = sprintf( '%s-%d', $this->widget->id_base, -rand() ); + $args = array_merge( + $sidebar, + array( + 'widget_id' => $widget_id, + 'widget_name' => $this->widget->name, + ) + ); + + // Substitute HTML id and class attributes into before_widget. + $args['before_widget'] = sprintf( $args['before_widget'], $widget_id, $this->widget->widget_options['classname'] ); + + /** This filter is documented in wp-includes/widgets.php */ + $params = apply_filters( 'dynamic_sidebar_params', array( + $args, + array( + 'number' => null, + ), + ) ); + + return $params[0]; + } + + /** + * Render widget shortcode. + * + * @global array $wp_registered_sidebars + * + * @param array $atts Shortcode attributes. + * @return string Rendered shortcode. + */ + public function render_widget_shortcode( $atts ) { + $atts = shortcode_atts( + array( + 'encoded_json_instance' => '', + ), + $atts, + $this->get_shortcode_tag() + ); + + $instance_data = array(); + if ( ! empty( $atts['encoded_json_instance'] ) ) { + $decoded_instance_data = json_decode( urldecode( $atts['encoded_json_instance'] ), true ); + if ( is_array( $decoded_instance_data ) && true === $this->widget->validate( $decoded_instance_data ) ) { + $instance_data = $this->widget->sanitize( $decoded_instance_data, array() ); + if ( is_wp_error( $instance_data ) ) { + $instance_data = array(); + } + } + } + + ob_start(); + $this->widget->enqueue_frontend_scripts(); + $this->widget->render( $this->get_sidebar_args(), $instance_data ); + return ob_get_clean(); + } + + /** + * Register shortcode UI for widget shortcodes. + */ + public function register_shortcode_ui() { + shortcode_ui_register_for_shortcode( + $this->get_shortcode_tag(), + array( + 'label' => $this->widget->name, + 'listItemImage' => $this->widget->icon_name, + 'widgetType' => $this->widget->id_base, + 'attrs' => array( + array( + 'label' => __( 'URL-encoded JSON Widget Instance Data', 'js-widgets' ), + 'attr' => 'encoded_json_instance', + 'type' => 'widget_form', + 'encode' => true, + ), + ), + ) + ); + } +} diff --git a/php/class-js-widgets-plugin.php b/php/class-js-widgets-plugin.php index 068f34f..f83311f 100644 --- a/php/class-js-widgets-plugin.php +++ b/php/class-js-widgets-plugin.php @@ -71,6 +71,13 @@ class JS_Widgets_Plugin { */ public $script_handles = array(); + /** + * Shortcode UI (Shortcake) integration. + * + * @var Shortcode_UI + */ + public $shortcode_ui; + /** * Plugin constructor. */ @@ -100,17 +107,21 @@ public function init() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_scripts' ) ); add_action( 'rest_api_init', array( $this, 'rest_api_init' ), 100 ); add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_customize_controls_scripts' ) ); + add_action( 'customize_controls_print_scripts', array( $this, 'print_available_widget_icon_styles' ), 100 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_widgets_admin_scripts' ) ); add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_widget_form_template_scripts' ) ); add_action( 'admin_footer-widgets.php', array( $this, 'render_widget_form_template_scripts' ) ); add_action( 'customize_controls_init', array( $this, 'upgrade_customize_widget_controls' ) ); - add_action( 'widgets_init', array( $this, 'capture_original_instances' ), 94 ); add_action( 'widgets_init', array( $this, 'upgrade_core_widgets' ) ); + add_action( 'widgets_init', array( $this, 'capture_original_instances' ), 94 ); add_action( 'in_widget_form', array( $this, 'start_capturing_in_widget_form' ), 0, 3 ); add_action( 'in_widget_form', array( $this, 'stop_capturing_in_widget_form' ), 1000, 3 ); - // @todo Add widget REST endpoint for getting the rendered value of widgets. Note originating context URL will need to be supplied when rendering some widgets. + // Shortcake integration. + require_once __DIR__ . '/class-js-widgets-shortcode-ui.php'; + $this->shortcode_ui = new JS_Widgets_Shortcode_UI( $this ); + $this->shortcode_ui->add_hooks(); } /** @@ -151,6 +162,11 @@ public function register_scripts( WP_Scripts $wp_scripts ) { $deps = array( 'admin-widgets', $this->script_handles['form'] ); $wp_scripts->add( $this->script_handles['admin-js-widgets'], $src, $deps, $this->version ); + $this->script_handles['shortcode-ui-view-widget-form-field'] = 'shortcode-ui-view-widget-form-field'; + $src = $plugin_dir_url . 'js/shortcode-ui-view-widget-form-field.js'; + $deps = array( 'shortcode-ui', $this->script_handles['form'] ); + $wp_scripts->add( $this->script_handles['shortcode-ui-view-widget-form-field'], $src, $deps, $this->version ); + $this->script_handles['trac-39389-controls'] = 'js-widgets-trac-39389-controls'; $src = $plugin_dir_url . 'js/trac-39389-controls.js'; $deps = array( 'customize-widgets' ); @@ -235,6 +251,31 @@ function enqueue_customize_controls_scripts() { wp_enqueue_script( $this->script_handles['trac-39389-controls'] ); } + /** + * Print the CSS styles to ensure the defined Dashicon $icon_name in the available JS widgets panel. + * + * This is somewhat hacky to parse the Dashicons CSS to obtain the necessary CSS properties + * to output into the page + * + * @global WP_Widget_Factory $wp_widget_factory + */ + function print_available_widget_icon_styles() { + global $wp_widget_factory; + + $dashicons_content = file_get_contents( ABSPATH . WPINC . '/css/dashicons.css' ); + + echo ''; + } + /** * Enqueue scripts for the widgets admin screen. * @@ -277,16 +318,17 @@ function enqueue_widgets_admin_scripts( $hook_suffix ) { * * @access public * @global WP_Widget_Factory $wp_widget_factory + * @global WP_Customize_Manager $wp_customize */ function enqueue_frontend_scripts() { - global $wp_widget_factory; + global $wp_widget_factory, $wp_customize; foreach ( $wp_widget_factory->widgets as $widget ) { if ( $widget instanceof WP_JS_Widget && ( is_active_widget( false, false, $widget->id_base ) || is_customize_preview() ) ) { $widget->enqueue_frontend_scripts(); } } - if ( is_customize_preview() ) { + if ( is_customize_preview() && ! empty( $wp_customize->widgets ) && current_user_can( 'edit_theme_options' ) ) { wp_enqueue_script( $this->script_handles['trac-39389-preview'] ); } } diff --git a/php/class-js-widgets-rest-controller.php b/php/class-js-widgets-rest-controller.php index 355833b..4e61201 100644 --- a/php/class-js-widgets-rest-controller.php +++ b/php/class-js-widgets-rest-controller.php @@ -181,7 +181,9 @@ public function register_routes() { 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'args' => array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'context' => $this->get_context_param( array( + 'default' => 'view', + ) ), ), ), array( @@ -267,7 +269,10 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE // Only use required / default from arg_options on CREATABLE/EDITABLE endpoints. if ( ! $is_create_or_edit ) { - $params['arg_options'] = array_diff_key( $params['arg_options'], array( 'required' => '', 'default' => '' ) ); + $params['arg_options'] = array_diff_key( $params['arg_options'], array( + 'required' => '', + 'default' => '', + ) ); } $endpoint_args[ $field_id ] = array_merge( $endpoint_args[ $field_id ], $params['arg_options'] ); @@ -397,7 +402,9 @@ public function get_item_permissions_check( $request ) { public function get_items_permissions_check( $request ) { if ( 'edit' === $request['context'] && ! $this->current_user_can_manage_widgets() ) { - return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit widgets.', 'js-widgets' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit widgets.', 'js-widgets' ), array( + 'status' => rest_authorization_required_code(), + ) ); } return true; @@ -445,7 +452,9 @@ public function delete_item_permissions_check( $request ) { public function get_item( $request ) { $instances = $this->widget->get_settings(); if ( ! array_key_exists( $request['widget_number'], $instances ) ) { - return new WP_Error( 'rest_widget_invalid_number', __( 'Unknown widget.', 'js-widgets' ), array( 'status' => 404 ) ); + return new WP_Error( 'rest_widget_invalid_number', __( 'Unknown widget.', 'js-widgets' ), array( + 'status' => 404, + ) ); } $instance = $instances[ $request['widget_number'] ]; @@ -464,16 +473,22 @@ public function get_item( $request ) { public function update_item( $request ) { $instances = $this->widget->get_settings(); if ( ! array_key_exists( $request['widget_number'], $instances ) ) { - return new WP_Error( 'rest_widget_invalid_number', __( 'Unknown widget.', 'js-widgets' ), array( 'status' => 404 ) ); + return new WP_Error( 'rest_widget_invalid_number', __( 'Unknown widget.', 'js-widgets' ), array( + 'status' => 404, + ) ); } $old_instance = $instances[ $request['widget_number'] ]; $expected_id = $this->get_object_id( $request['widget_number'] ); if ( ! empty( $request['id'] ) && $expected_id !== $request['id'] ) { - return new WP_Error( 'rest_widget_unexpected_id', __( 'Widget ID mismatch.', 'js-widgets' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_widget_unexpected_id', __( 'Widget ID mismatch.', 'js-widgets' ), array( + 'status' => 400, + ) ); } if ( ! empty( $request['type'] ) && $this->get_object_type() !== $request['type'] ) { - return new WP_Error( 'rest_widget_unexpected_type', __( 'Widget type mismatch.', 'js-widgets' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_widget_unexpected_type', __( 'Widget type mismatch.', 'js-widgets' ), array( + 'status' => 400, + ) ); } // Note that $new_instance has gone through the validate and sanitize callbacks defined on the instance schema. @@ -485,7 +500,9 @@ public function update_item( $request ) { return $instance; } if ( ! is_array( $instance ) ) { - return new WP_Error( 'rest_widget_sanitize_failed', __( 'Sanitization failed.', 'js-widgets' ), array( 'status' => 400 ) ); + return new WP_Error( 'rest_widget_sanitize_failed', __( 'Sanitization failed.', 'js-widgets' ), array( + 'status' => 400, + ) ); } $instances[ $request['widget_number'] ] = $instance; @@ -530,7 +547,9 @@ public function prepare_item_for_response( $instance, $request, $widget_number = $widget_number = $request['widget_number']; } if ( empty( $widget_number ) ) { - return new WP_Error( 'rest_widget_unavailable_widget_number', __( 'Unknown widget number.', 'js-widgets' ), array( 'status' => 500 ) ); + return new WP_Error( 'rest_widget_unavailable_widget_number', __( 'Unknown widget number.', 'js-widgets' ), array( + 'status' => 500, + ) ); } // Just in case. diff --git a/php/class-js-widgets-shortcode-ui.php b/php/class-js-widgets-shortcode-ui.php new file mode 100644 index 0000000..b4cefc2 --- /dev/null +++ b/php/class-js-widgets-shortcode-ui.php @@ -0,0 +1,193 @@ +plugin = $plugin; + } + + /** + * Add hooks. + */ + public function add_hooks() { + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_the_loop_shortcode_dependencies' ) ); + add_action( 'widgets_init', array( $this, 'register_widget_shortcodes' ), 90 ); + add_filter( 'shortcode_ui_fields', array( $this, 'filter_shortcode_ui_fields' ) ); + add_action( 'print_shortcode_ui_templates', array( $this, 'print_shortcode_ui_templates' ) ); + add_action( 'enqueue_shortcode_ui', array( $this, 'enqueue_shortcode_ui' ) ); + + add_action( 'shortcode_ui_before_do_shortcode', array( $this, 'before_do_shortcode' ) ); + add_action( 'shortcode_ui_after_do_shortcode', array( $this, 'after_do_shortcode' ) ); + } + + /** + * Enqueue scripts and styles for widgets that appear as shortcodes. + * + * @global WP_Widget_Factory $wp_widget_factory + * @global WP_Query $the_wp_query + */ + function enqueue_the_loop_shortcode_dependencies() { + global $wp_the_query, $wp_query, $wp_widget_factory; + + if ( empty( $wp_the_query ) ) { + return; + } + + $widgets_by_id_base = array(); + foreach ( $wp_widget_factory->widgets as $widget ) { + if ( $widget instanceof WP_JS_Widget ) { + $widgets_by_id_base[ $widget->id_base ] = $widget; + } + } + + $pattern = '#' . sprintf( '\[widget_(%s)', join( '|', array_keys( $widgets_by_id_base ) ) ) . '#'; + $all_content = join( ' ', wp_list_pluck( $wp_query->posts, 'post_content' ) ); + if ( ! preg_match_all( $pattern, $all_content, $matches ) ) { + return; + } + foreach ( $matches[1] as $matched_id_base ) { + $widget = $widgets_by_id_base[ $matched_id_base ]; + $widget->enqueue_frontend_scripts(); + } + } + + /** + * Enqueue scripts for shortcode UI. + * + * @global WP_Widget_Factory $wp_widget_factory + */ + function enqueue_shortcode_ui() { + global $wp_widget_factory; + + wp_enqueue_script( $this->plugin->script_handles['shortcode-ui-view-widget-form-field'] ); + + foreach ( $wp_widget_factory->widgets as $widget ) { + if ( $widget instanceof WP_JS_Widget ) { + $widget->enqueue_control_scripts(); + } + } + } + + /** + * Register widget shortcodes. + * + * @global WP_Widget_Factory $wp_widget_factory + */ + public function register_widget_shortcodes() { + global $wp_widget_factory; + require_once __DIR__ . '/class-js-widget-shortcode-controller.php'; + foreach ( $wp_widget_factory->widgets as $widget ) { + if ( $widget instanceof WP_JS_Widget ) { + $widget_shortcode = new JS_Widget_Shortcode_Controller( $this->plugin, $widget ); + $widget_shortcode->register_shortcode(); + add_action( 'register_shortcode_ui', array( $widget_shortcode, 'register_shortcode_ui' ) ); + } + } + } + + /** + * Add widget_form as a new shortcode UI field. + * + * @param array $fields Shortcode fields. + * @return array Fields. + */ + public function filter_shortcode_ui_fields( $fields ) { + $fields['widget_form'] = array( + 'template' => 'shortcode-ui-field-widget_form', + 'view' => 'widgetFormField', + ); + return $fields; + } + + /** + * Print shortcode UI templates. + */ + public function print_shortcode_ui_templates() { + $this->plugin->render_widget_form_template_scripts(); + } + + /** + * Whether footer scripts should be printed. + * + * @var bool + */ + protected $should_print_footer_scripts = false; + + /** + * Backup of suspended WP_Scripts. + * + * @var WP_Scripts + */ + protected $suspended_wp_scripts; + + /** + * Backup of suspended WP_Styles. + * + * @var WP_Styles + */ + protected $suspended_wp_styles; + + /** + * Handle printing before shortcode. + * + * @param string $shortcode Shortcode. + * @global WP_Scripts $wp_scripts + * @global WP_Styles $wp_styles + */ + public function before_do_shortcode( $shortcode ) { + global $wp_scripts, $wp_styles; + + $this->should_print_footer_scripts = (bool) preg_match( '#^\[widget_(?P.+?)\s#', $shortcode ); + if ( ! $this->should_print_footer_scripts ) { + return; + } + + // Reset enqueued assets so that only the widget's specific assets will be enqueued. + $this->suspended_wp_scripts = $wp_scripts; + $this->suspended_wp_styles = $wp_styles; + $wp_scripts = null; + $wp_styles = null; + } + + /** + * Print scripts and styles that the widget depends on. + * + * @global WP_Scripts $wp_scripts + * @global WP_Styles $wp_styles + */ + public function after_do_shortcode() { + global $wp_scripts, $wp_styles; + if ( ! $this->should_print_footer_scripts ) { + return; + } + + // Prints head scripts and styles as well as footer scripts and required templates. + wp_print_footer_scripts(); + + // Restore enqueued scripts and styles. + $wp_scripts = $this->suspended_wp_scripts; + $wp_styles = $this->suspended_wp_styles; + } +} diff --git a/php/class-wp-js-widget.php b/php/class-wp-js-widget.php index e926fcc..81a475d 100644 --- a/php/class-wp-js-widget.php +++ b/php/class-wp-js-widget.php @@ -12,6 +12,13 @@ */ abstract class WP_JS_Widget extends WP_Widget { + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-format-aside'; + /** * REST controller class that should be used for this widget. * @@ -446,6 +453,12 @@ public function validate( $value ) { * @param array $instance The settings for the particular instance of the widget. */ final public function widget( $args, $instance ) { + /* + * Make sure frontend scripts and styles get enqueued if not already done. + * This is particularly important in the case of a widget used in a shortcode. + */ + $this->enqueue_frontend_scripts(); + $this->render( $args, $instance ); } @@ -606,12 +619,13 @@ protected function render_form_field_template( $args = array() ) { } elseif ( 'integer' === $schema_type || 'number' === $schema_type ) { $default_input_attrs['type'] = 'number'; } elseif ( 'string' === $schema_type && isset( $field_schema['format'] ) ) { + + // @todo Support date-time format. if ( 'uri' === $field_schema['format'] ) { $default_input_attrs['type'] = 'url'; } elseif ( 'email' === $field_schema['format'] ) { $default_input_attrs['type'] = 'email'; } - // @todo Support date-time format. } if ( 'integer' === $schema_type ) { @@ -666,9 +680,10 @@ protected function render_form_field_template( $args = array() ) { - + diff --git a/phpcs.ruleset.xml b/phpcs.ruleset.xml index b2a57ab..317c2fe 100644 --- a/phpcs.ruleset.xml +++ b/phpcs.ruleset.xml @@ -18,6 +18,11 @@ 0 + + + 0 + + */dev-lib/* */node_modules/* */vendor/* diff --git a/post-collection-widget.php b/post-collection-widget.php index d8c571e..48de69d 100644 --- a/post-collection-widget.php +++ b/post-collection-widget.php @@ -3,8 +3,8 @@ * Plugin Name: JS Widgets: Post Collection Widget * Description: A widget allowing for featuring a curated list of posts. * Plugin URI: https://github.com/xwp/wp-js-widgets/ - * Version: 0.3.0-alpha - * Author: Weston Ruter, XWP + * Version: 0.4.0 + * Author: XWP * Author URI: https://make.xwp.co/ * License: GPLv2+ * diff --git a/post-collection-widget/class-widget.php b/post-collection-widget/class-widget.php index ed39ebe..4f4abc7 100644 --- a/post-collection-widget/class-widget.php +++ b/post-collection-widget/class-widget.php @@ -26,6 +26,13 @@ class WP_JS_Widget_Post_Collection extends WP_JS_Widget { */ public $id_base = 'post-collection'; + /** + * Icon name. + * + * @var string + */ + public $icon_name = 'dashicons-admin-post'; + /** * Base query vars used in post lookup. * @@ -230,7 +237,7 @@ public function get_rest_response_links( $response, $request, $controller ) { unset( $request, $controller ); $links = array(); - $links['wp:post'] = array(); + $links['item'] = array(); foreach ( $response->data['posts'] as $post_id ) { $post = get_post( $post_id ); if ( empty( $post ) ) { @@ -244,7 +251,7 @@ public function get_rest_response_links( $response, $request, $controller ) { $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; $base = sprintf( '/wp/v2/%s', $rest_base ); - $links['wp:post'][] = array( + $links['item'][] = array( 'href' => rest_url( trailingslashit( $base ) . $post_id ), 'embeddable' => true, 'post_type' => $post->post_type, diff --git a/readme.md b/readme.md index 7343d91..f3dcc09 100644 --- a/readme.md +++ b/readme.md @@ -3,11 +3,11 @@ The next generation of widgets in core, embracing JS for UI and powering the Widgets REST API. -**Contributors:** [xwp](https://profiles.wordpress.org/xwp), [westonruter](https://profiles.wordpress.org/westonruter) +**Contributors:** [xwp](https://profiles.wordpress.org/xwp), [westonruter](https://profiles.wordpress.org/westonruter), [sirbrillig](https://profiles.wordpress.org/sirbrillig) **Tags:** [customizer](https://wordpress.org/plugins/tags/customizer), [widgets](https://wordpress.org/plugins/tags/widgets), [rest-api](https://wordpress.org/plugins/tags/rest-api) **Requires at least:** 4.7.0 -**Tested up to:** 4.7.0 -**Stable tag:** 0.3.0 +**Tested up to:** 4.8-alpha +**Stable tag:** 0.4.0 **License:** [GPLv2 or later](http://www.gnu.org/licenses/gpl-2.0.html) [![Build Status](https://travis-ci.org/xwp/wp-js-widgets.svg?branch=master)](https://travis-ci.org/xwp/wp-js-widgets) [![Coverage Status](https://coveralls.io/repos/xwp/wp-js-widgets/badge.svg?branch=master)](https://coveralls.io/github/xwp/wp-js-widgets) [![Built with Grunt](https://cdn.gruntjs.com/builtwith.svg)](http://gruntjs.com) [![devDependency Status](https://david-dm.org/xwp/wp-js-widgets/dev-status.svg)](https://david-dm.org/xwp/wp-js-widgets#info=devDependencies) @@ -26,7 +26,9 @@ This plugin implements: Features: +* Integrates with [Shortcake (Shortcode UI)](https://wordpress.org/plugins/shortcode-ui/) to allow all JS widgets to be made available as Post Elements in the editor. * Widget instance settings in the Customizer are exported from PHP as regular JSON without any PHP-serialized base64-encoded `encoded_serialized_instance` anywhere to be seen. +* Previewing widget changes in the customizer is faster since the `update-widget` Ajax request can be eliminated since the JS control can directly manipulate the widget instance data. * Widgets control forms use JS content templates instead of PHP to render the markup for each control, reducing the weight of the customizer load, especially when there are a lot of widgets in use. * Widgets that extend `WP_JS_Widget` will editable from both the customizer and the widgets admin page using the same `Form` JS interface. This `Form` is also able to be embedded in other contexts, like on the frontend and as a Shortcake (Shortcode UI) form. See [#11](https://github.com/xwp/wp-js-widgets/issues/11). * Widgets employ the JSON Schema from the REST API to define an instance with validation and sanitization of the instance properties, beyond also providing `validate` and `sanitize` methods that work on the instance array as a whole. @@ -50,6 +52,15 @@ Limitations/Caveats: ## Changelog ## +### 0.4.0 - 2017-02-17 ### +* Integrate with [Shortcake (Shortcode UI)](https://wordpress.org/plugins/shortcode-ui/) to allow any JS widget to be used inside the editor as a Post Element. See [#11](https://github.com/xwp/wp-js-widgets/issues/11), [#32](https://github.com/xwp/wp-js-widgets/pull/32). +* Refactor of `Form` along with introduction of JS unit tests. See [#35](https://github.com/xwp/wp-js-widgets/pull/35). Props [sirbrillig](https://profiles.wordpress.org/sirbrillig)! +* Use `item` relation in resource links instead of ad hoc `wp:post`, `wp:page`, and `wp:comment` relations. See [#36](https://github.com/xwp/wp-js-widgets/issues/36), [#38](https://github.com/xwp/wp-js-widgets/pull/38). + +See issues and PRs in milestone and full release commit log. + +Props Payton Swick (@sirbrillig), Weston Ruter (@westonruter), Piotr Delawski (@delawski). + ### 0.3.0 - 2017-01-11 ### Added: diff --git a/readme.txt b/readme.txt index d343b0b..d398639 100644 --- a/readme.txt +++ b/readme.txt @@ -1,9 +1,9 @@ === JS Widgets === -Contributors: xwp, westonruter +Contributors: xwp, westonruter, sirbrillig Tags: customizer, widgets, rest-api Requires at least: 4.7.0 -Tested up to: 4.7.0 -Stable tag: 0.3.0 +Tested up to: 4.8-alpha +Stable tag: 0.4.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -23,7 +23,9 @@ This plugin implements: Features: +* Integrates with [Shortcake (Shortcode UI)](https://wordpress.org/plugins/shortcode-ui/) to allow all JS widgets to be made available as Post Elements in the editor. * Widget instance settings in the Customizer are exported from PHP as regular JSON without any PHP-serialized base64-encoded `encoded_serialized_instance` anywhere to be seen. +* Previewing widget changes in the customizer is faster since the `update-widget` Ajax request can be eliminated since the JS control can directly manipulate the widget instance data. * Widgets control forms use JS content templates instead of PHP to render the markup for each control, reducing the weight of the customizer load, especially when there are a lot of widgets in use. * Widgets that extend `WP_JS_Widget` will editable from both the customizer and the widgets admin page using the same `Form` JS interface. This `Form` is also able to be embedded in other contexts, like on the frontend and as a Shortcake (Shortcode UI) form. See [#11](https://github.com/xwp/wp-js-widgets/issues/11). * Widgets employ the JSON Schema from the REST API to define an instance with validation and sanitization of the instance properties, beyond also providing `validate` and `sanitize` methods that work on the instance array as a whole. @@ -47,6 +49,16 @@ Limitations/Caveats: == Changelog == += 0.4.0 - 2017-02-17 = + +* Integrate with [Shortcake (Shortcode UI)](https://wordpress.org/plugins/shortcode-ui/) to allow any JS widget to be used inside the editor as a Post Element. See [#11](https://github.com/xwp/wp-js-widgets/issues/11), [#32](https://github.com/xwp/wp-js-widgets/pull/32). +* Refactor of `Form` along with introduction of JS unit tests. See [#35](https://github.com/xwp/wp-js-widgets/pull/35). Props [sirbrillig](https://profiles.wordpress.org/sirbrillig)! +* Use `item` relation in resource links instead of ad hoc `wp:post`, `wp:page`, and `wp:comment` relations. See [#36](https://github.com/xwp/wp-js-widgets/issues/36), [#38](https://github.com/xwp/wp-js-widgets/pull/38). + +See issues and PRs in milestone and full release commit log. + +Props Payton Swick (@sirbrillig), Weston Ruter (@westonruter), Piotr Delawski (@delawski). + = 0.3.0 - 2017-01-11 = Added: diff --git a/tests/js/customize-js-widgets.js b/tests/js/customize-js-widgets.js new file mode 100644 index 0000000..4114976 --- /dev/null +++ b/tests/js/customize-js-widgets.js @@ -0,0 +1,84 @@ +/* globals global, require, describe, it, beforeEach */ +/* eslint-disable no-unused-expressions */ + +const expect = require( 'chai' ).expect; +const _ = require( 'underscore' ); + +const noop = () => null; + +function resetGlobals() { + global.wp = { + widgets: { + formConstructor: { + text: noop, + }, + }, + customize: { + Widgets: { + WidgetControl: { + extend: Object.assign, + prototype: { + initialize: noop, + }, + }, + }, + }, + }; + global.jQuery = {}; + global._ = _; +} + +resetGlobals(); +const JSWidgets = require( '../../js/customize-js-widgets' ); + +describe( 'wp.customize.JSWidgets', function() { + beforeEach( function() { + resetGlobals(); + } ); + + describe( '.isJsWidgetControl()', function() { + it( 'returns false if the passed Control is not a WidgetControl and has no id_base', function() { + const mockWidget = { + extended: () => false, + params: {}, + }; + const result = JSWidgets.isJsWidgetControl( mockWidget ); + expect( result ).to.be.false; + } ); + + it( 'returns false if the passed Control has a known formConstructor for its id_base but is not a WidgetControl', function() { + const mockWidget = { + extended: () => false, + params: { widget_id_base: 'text' }, + }; + const result = JSWidgets.isJsWidgetControl( mockWidget ); + expect( result ).to.be.false; + } ); + + it( 'returns false if the passed Control is a WidgetControl and has an unknown formConstructor for its id_base', function() { + const mockWidget = { + extended: () => true, + params: { widget_id_base: 'foo' }, + }; + const result = JSWidgets.isJsWidgetControl( mockWidget ); + expect( result ).to.be.false; + } ); + + it( 'returns true if the passed Control is a WidgetControl and has a known formConstructor for its id_base', function() { + const mockWidget = { + extended: () => true, + params: { widget_id_base: 'text' }, + }; + const result = JSWidgets.isJsWidgetControl( mockWidget ); + expect( result ).to.be.true; + } ); + } ); + + describe( '.extendWidgetControl()', function() { + it( 'overrides the initialize prototype method of the passed constructor function', function() { + const Obj = function() {}; + JSWidgets.extendWidgetControl( Obj ); + expect( Obj.prototype ).to.have.property( 'initialize' ); + } ); + } ); +} ); diff --git a/tests/js/lib/customize-base.js b/tests/js/lib/customize-base.js new file mode 100644 index 0000000..b754857 --- /dev/null +++ b/tests/js/lib/customize-base.js @@ -0,0 +1,853 @@ +window.wp = window.wp || {}; + +(function( exports, $ ){ + var api = {}, ctor, inherits, + slice = Array.prototype.slice; + + // Shared empty constructor function to aid in prototype-chain creation. + ctor = function() {}; + + /** + * Helper function to correctly set up the prototype chain, for subclasses. + * Similar to `goog.inherits`, but uses a hash of prototype properties and + * class properties to be extended. + * + * @param object parent Parent class constructor to inherit from. + * @param object protoProps Properties to apply to the prototype for use as class instance properties. + * @param object staticProps Properties to apply directly to the class constructor. + * @return child The subclassed constructor. + */ + inherits = function( parent, protoProps, staticProps ) { + var child; + + // The constructor function for the new subclass is either defined by you + // (the "constructor" property in your `extend` definition), or defaulted + // by us to simply call `super()`. + if ( protoProps && protoProps.hasOwnProperty( 'constructor' ) ) { + child = protoProps.constructor; + } else { + child = function() { + // Storing the result `super()` before returning the value + // prevents a bug in Opera where, if the constructor returns + // a function, Opera will reject the return value in favor of + // the original object. This causes all sorts of trouble. + var result = parent.apply( this, arguments ); + return result; + }; + } + + // Inherit class (static) properties from parent. + $.extend( child, parent ); + + // Set the prototype chain to inherit from `parent`, without calling + // `parent`'s constructor function. + ctor.prototype = parent.prototype; + child.prototype = new ctor(); + + // Add prototype properties (instance properties) to the subclass, + // if supplied. + if ( protoProps ) + $.extend( child.prototype, protoProps ); + + // Add static properties to the constructor function, if supplied. + if ( staticProps ) + $.extend( child, staticProps ); + + // Correctly set child's `prototype.constructor`. + child.prototype.constructor = child; + + // Set a convenience property in case the parent's prototype is needed later. + child.__super__ = parent.prototype; + + return child; + }; + + /** + * Base class for object inheritance. + */ + api.Class = function( applicator, argsArray, options ) { + var magic, args = arguments; + + if ( applicator && argsArray && api.Class.applicator === applicator ) { + args = argsArray; + $.extend( this, options || {} ); + } + + magic = this; + + /* + * If the class has a method called "instance", + * the return value from the class' constructor will be a function that + * calls the "instance" method. + * + * It is also an object that has properties and methods inside it. + */ + if ( this.instance ) { + magic = function() { + return magic.instance.apply( magic, arguments ); + }; + + $.extend( magic, this ); + } + + magic.initialize.apply( magic, args ); + return magic; + }; + + /** + * Creates a subclass of the class. + * + * @param object protoProps Properties to apply to the prototype. + * @param object staticProps Properties to apply directly to the class. + * @return child The subclass. + */ + api.Class.extend = function( protoProps, classProps ) { + var child = inherits( this, protoProps, classProps ); + child.extend = this.extend; + return child; + }; + + api.Class.applicator = {}; + + /** + * Initialize a class instance. + * + * Override this function in a subclass as needed. + */ + api.Class.prototype.initialize = function() {}; + + /* + * Checks whether a given instance extended a constructor. + * + * The magic surrounding the instance parameter causes the instanceof + * keyword to return inaccurate results; it defaults to the function's + * prototype instead of the constructor chain. Hence this function. + */ + api.Class.prototype.extended = function( constructor ) { + var proto = this; + + while ( typeof proto.constructor !== 'undefined' ) { + if ( proto.constructor === constructor ) + return true; + if ( typeof proto.constructor.__super__ === 'undefined' ) + return false; + proto = proto.constructor.__super__; + } + return false; + }; + + /** + * An events manager object, offering the ability to bind to and trigger events. + * + * Used as a mixin. + */ + api.Events = { + trigger: function( id ) { + if ( this.topics && this.topics[ id ] ) + this.topics[ id ].fireWith( this, slice.call( arguments, 1 ) ); + return this; + }, + + bind: function( id ) { + this.topics = this.topics || {}; + this.topics[ id ] = this.topics[ id ] || $.Callbacks(); + this.topics[ id ].add.apply( this.topics[ id ], slice.call( arguments, 1 ) ); + return this; + }, + + unbind: function( id ) { + if ( this.topics && this.topics[ id ] ) + this.topics[ id ].remove.apply( this.topics[ id ], slice.call( arguments, 1 ) ); + return this; + } + }; + + /** + * Observable values that support two-way binding. + * + * @constructor + */ + api.Value = api.Class.extend({ + /** + * @param {mixed} initial The initial value. + * @param {object} options + */ + initialize: function( initial, options ) { + this._value = initial; // @todo: potentially change this to a this.set() call. + this.callbacks = $.Callbacks(); + this._dirty = false; + + $.extend( this, options || {} ); + + this.set = $.proxy( this.set, this ); + }, + + /* + * Magic. Returns a function that will become the instance. + * Set to null to prevent the instance from extending a function. + */ + instance: function() { + return arguments.length ? this.set.apply( this, arguments ) : this.get(); + }, + + /** + * Get the value. + * + * @return {mixed} + */ + get: function() { + return this._value; + }, + + /** + * Set the value and trigger all bound callbacks. + * + * @param {object} to New value. + */ + set: function( to ) { + var from = this._value; + + to = this._setter.apply( this, arguments ); + to = this.validate( to ); + + // Bail if the sanitized value is null or unchanged. + if ( null === to || _.isEqual( from, to ) ) { + return this; + } + + this._value = to; + this._dirty = true; + + this.callbacks.fireWith( this, [ to, from ] ); + + return this; + }, + + _setter: function( to ) { + return to; + }, + + setter: function( callback ) { + var from = this.get(); + this._setter = callback; + // Temporarily clear value so setter can decide if it's valid. + this._value = null; + this.set( from ); + return this; + }, + + resetSetter: function() { + this._setter = this.constructor.prototype._setter; + this.set( this.get() ); + return this; + }, + + validate: function( value ) { + return value; + }, + + /** + * Bind a function to be invoked whenever the value changes. + * + * @param {...Function} A function, or multiple functions, to add to the callback stack. + */ + bind: function() { + this.callbacks.add.apply( this.callbacks, arguments ); + return this; + }, + + /** + * Unbind a previously bound function. + * + * @param {...Function} A function, or multiple functions, to remove from the callback stack. + */ + unbind: function() { + this.callbacks.remove.apply( this.callbacks, arguments ); + return this; + }, + + link: function() { // values* + var set = this.set; + $.each( arguments, function() { + this.bind( set ); + }); + return this; + }, + + unlink: function() { // values* + var set = this.set; + $.each( arguments, function() { + this.unbind( set ); + }); + return this; + }, + + sync: function() { // values* + var that = this; + $.each( arguments, function() { + that.link( this ); + this.link( that ); + }); + return this; + }, + + unsync: function() { // values* + var that = this; + $.each( arguments, function() { + that.unlink( this ); + this.unlink( that ); + }); + return this; + } + }); + + /** + * A collection of observable values. + * + * @constructor + * @augments wp.customize.Class + * @mixes wp.customize.Events + */ + api.Values = api.Class.extend({ + + /** + * The default constructor for items of the collection. + * + * @type {object} + */ + defaultConstructor: api.Value, + + initialize: function( options ) { + $.extend( this, options || {} ); + + this._value = {}; + this._deferreds = {}; + }, + + /** + * Get the instance of an item from the collection if only ID is specified. + * + * If more than one argument is supplied, all are expected to be IDs and + * the last to be a function callback that will be invoked when the requested + * items are available. + * + * @see {api.Values.when} + * + * @param {string} id ID of the item. + * @param {...} Zero or more IDs of items to wait for and a callback + * function to invoke when they're available. Optional. + * @return {mixed} The item instance if only one ID was supplied. + * A Deferred Promise object if a callback function is supplied. + */ + instance: function( id ) { + if ( arguments.length === 1 ) + return this.value( id ); + + return this.when.apply( this, arguments ); + }, + + /** + * Get the instance of an item. + * + * @param {string} id The ID of the item. + * @return {[type]} [description] + */ + value: function( id ) { + return this._value[ id ]; + }, + + /** + * Whether the collection has an item with the given ID. + * + * @param {string} id The ID of the item to look for. + * @return {Boolean} + */ + has: function( id ) { + return typeof this._value[ id ] !== 'undefined'; + }, + + /** + * Add an item to the collection. + * + * @param {string} id The ID of the item. + * @param {mixed} value The item instance. + * @return {mixed} The new item's instance. + */ + add: function( id, value ) { + if ( this.has( id ) ) + return this.value( id ); + + this._value[ id ] = value; + value.parent = this; + + // Propagate a 'change' event on an item up to the collection. + if ( value.extended( api.Value ) ) + value.bind( this._change ); + + this.trigger( 'add', value ); + + // If a deferred object exists for this item, + // resolve it. + if ( this._deferreds[ id ] ) + this._deferreds[ id ].resolve(); + + return this._value[ id ]; + }, + + /** + * Create a new item of the collection using the collection's default constructor + * and store it in the collection. + * + * @param {string} id The ID of the item. + * @param {mixed} value Any extra arguments are passed into the item's initialize method. + * @return {mixed} The new item's instance. + */ + create: function( id ) { + return this.add( id, new this.defaultConstructor( api.Class.applicator, slice.call( arguments, 1 ) ) ); + }, + + /** + * Iterate over all items in the collection invoking the provided callback. + * + * @param {Function} callback Function to invoke. + * @param {object} context Object context to invoke the function with. Optional. + */ + each: function( callback, context ) { + context = typeof context === 'undefined' ? this : context; + + $.each( this._value, function( key, obj ) { + callback.call( context, obj, key ); + }); + }, + + /** + * Remove an item from the collection. + * + * @param {string} id The ID of the item to remove. + */ + remove: function( id ) { + var value; + + if ( this.has( id ) ) { + value = this.value( id ); + this.trigger( 'remove', value ); + if ( value.extended( api.Value ) ) + value.unbind( this._change ); + delete value.parent; + } + + delete this._value[ id ]; + delete this._deferreds[ id ]; + }, + + /** + * Runs a callback once all requested values exist. + * + * when( ids*, [callback] ); + * + * For example: + * when( id1, id2, id3, function( value1, value2, value3 ) {} ); + * + * @returns $.Deferred.promise(); + */ + when: function() { + var self = this, + ids = slice.call( arguments ), + dfd = $.Deferred(); + + // If the last argument is a callback, bind it to .done() + if ( $.isFunction( ids[ ids.length - 1 ] ) ) + dfd.done( ids.pop() ); + + /* + * Create a stack of deferred objects for each item that is not + * yet available, and invoke the supplied callback when they are. + */ + $.when.apply( $, $.map( ids, function( id ) { + if ( self.has( id ) ) + return; + + /* + * The requested item is not available yet, create a deferred + * object to resolve when it becomes available. + */ + return self._deferreds[ id ] = self._deferreds[ id ] || $.Deferred(); + })).done( function() { + var values = $.map( ids, function( id ) { + return self( id ); + }); + + // If a value is missing, we've used at least one expired deferred. + // Call Values.when again to generate a new deferred. + if ( values.length !== ids.length ) { + // ids.push( callback ); + self.when.apply( self, ids ).done( function() { + dfd.resolveWith( self, values ); + }); + return; + } + + dfd.resolveWith( self, values ); + }); + + return dfd.promise(); + }, + + /** + * A helper function to propagate a 'change' event from an item + * to the collection itself. + */ + _change: function() { + this.parent.trigger( 'change', this ); + } + }); + + // Create a global events bus on the Customizer. + $.extend( api.Values.prototype, api.Events ); + + + /** + * Cast a string to a jQuery collection if it isn't already. + * + * @param {string|jQuery collection} element + */ + api.ensure = function( element ) { + return typeof element == 'string' ? $( element ) : element; + }; + + /** + * An observable value that syncs with an element. + * + * Handles inputs, selects, and textareas by default. + * + * @constructor + * @augments wp.customize.Value + * @augments wp.customize.Class + */ + api.Element = api.Value.extend({ + initialize: function( element, options ) { + var self = this, + synchronizer = api.Element.synchronizer.html, + type, update, refresh; + + this.element = api.ensure( element ); + this.events = ''; + + if ( this.element.is('input, select, textarea') ) { + this.events += 'change'; + synchronizer = api.Element.synchronizer.val; + + if ( this.element.is('input') ) { + type = this.element.prop('type'); + if ( api.Element.synchronizer[ type ] ) { + synchronizer = api.Element.synchronizer[ type ]; + } + if ( 'text' === type || 'password' === type ) { + this.events += ' keyup'; + } else if ( 'range' === type ) { + this.events += ' input propertychange'; + } + } else if ( this.element.is('textarea') ) { + this.events += ' keyup'; + } + } + + api.Value.prototype.initialize.call( this, null, $.extend( options || {}, synchronizer ) ); + this._value = this.get(); + + update = this.update; + refresh = this.refresh; + + this.update = function( to ) { + if ( to !== refresh.call( self ) ) + update.apply( this, arguments ); + }; + this.refresh = function() { + self.set( refresh.call( self ) ); + }; + + this.bind( this.update ); + this.element.bind( this.events, this.refresh ); + }, + + find: function( selector ) { + return $( selector, this.element ); + }, + + refresh: function() {}, + + update: function() {} + }); + + api.Element.synchronizer = {}; + + $.each( [ 'html', 'val' ], function( index, method ) { + api.Element.synchronizer[ method ] = { + update: function( to ) { + this.element[ method ]( to ); + }, + refresh: function() { + return this.element[ method ](); + } + }; + }); + + api.Element.synchronizer.checkbox = { + update: function( to ) { + this.element.prop( 'checked', to ); + }, + refresh: function() { + return this.element.prop( 'checked' ); + } + }; + + api.Element.synchronizer.radio = { + update: function( to ) { + this.element.filter( function() { + return this.value === to; + }).prop( 'checked', true ); + }, + refresh: function() { + return this.element.filter( ':checked' ).val(); + } + }; + + $.support.postMessage = !! window.postMessage; + + /** + * A communicator for sending data from one window to another over postMessage. + * + * @constructor + * @augments wp.customize.Class + * @mixes wp.customize.Events + */ + api.Messenger = api.Class.extend({ + /** + * Create a new Value. + * + * @param {string} key Unique identifier. + * @param {mixed} initial Initial value. + * @param {mixed} options Options hash. Optional. + * @return {Value} Class instance of the Value. + */ + add: function( key, initial, options ) { + return this[ key ] = new api.Value( initial, options ); + }, + + /** + * Initialize Messenger. + * + * @param {object} params - Parameters to configure the messenger. + * {string} params.url - The URL to communicate with. + * {window} params.targetWindow - The window instance to communicate with. Default window.parent. + * {string} params.channel - If provided, will send the channel with each message and only accept messages a matching channel. + * @param {object} options - Extend any instance parameter or method with this object. + */ + initialize: function( params, options ) { + // Target the parent frame by default, but only if a parent frame exists. + var defaultTarget = window.parent === window ? null : window.parent; + + $.extend( this, options || {} ); + + this.add( 'channel', params.channel ); + this.add( 'url', params.url || '' ); + this.add( 'origin', this.url() ).link( this.url ).setter( function( to ) { + var urlParser = document.createElement( 'a' ); + urlParser.href = to; + // Port stripping needed by IE since it adds to host but not to event.origin. + return urlParser.protocol + '//' + urlParser.host.replace( /:80$/, '' ); + }); + + // first add with no value + this.add( 'targetWindow', null ); + // This avoids SecurityErrors when setting a window object in x-origin iframe'd scenarios. + this.targetWindow.set = function( to ) { + var from = this._value; + + to = this._setter.apply( this, arguments ); + to = this.validate( to ); + + if ( null === to || from === to ) { + return this; + } + + this._value = to; + this._dirty = true; + + this.callbacks.fireWith( this, [ to, from ] ); + + return this; + }; + // now set it + this.targetWindow( params.targetWindow || defaultTarget ); + + + // Since we want jQuery to treat the receive function as unique + // to this instance, we give the function a new guid. + // + // This will prevent every Messenger's receive function from being + // unbound when calling $.off( 'message', this.receive ); + this.receive = $.proxy( this.receive, this ); + this.receive.guid = $.guid++; + + $( window ).on( 'message', this.receive ); + }, + + destroy: function() { + $( window ).off( 'message', this.receive ); + }, + + /** + * Receive data from the other window. + * + * @param {jQuery.Event} event Event with embedded data. + */ + receive: function( event ) { + var message; + + event = event.originalEvent; + + if ( ! this.targetWindow || ! this.targetWindow() ) { + return; + } + + // Check to make sure the origin is valid. + if ( this.origin() && event.origin !== this.origin() ) + return; + + // Ensure we have a string that's JSON.parse-able + if ( typeof event.data !== 'string' || event.data[0] !== '{' ) { + return; + } + + message = JSON.parse( event.data ); + + // Check required message properties. + if ( ! message || ! message.id || typeof message.data === 'undefined' ) + return; + + // Check if channel names match. + if ( ( message.channel || this.channel() ) && this.channel() !== message.channel ) + return; + + this.trigger( message.id, message.data ); + }, + + /** + * Send data to the other window. + * + * @param {string} id The event name. + * @param {object} data Data. + */ + send: function( id, data ) { + var message; + + data = typeof data === 'undefined' ? null : data; + + if ( ! this.url() || ! this.targetWindow() ) + return; + + message = { id: id, data: data }; + if ( this.channel() ) + message.channel = this.channel(); + + this.targetWindow().postMessage( JSON.stringify( message ), this.origin() ); + } + }); + + // Add the Events mixin to api.Messenger. + $.extend( api.Messenger.prototype, api.Events ); + + /** + * Notification. + * + * @class + * @augments wp.customize.Class + * @since 4.6.0 + * + * @param {string} code - The error code. + * @param {object} params - Params. + * @param {string} params.message=null - The error message. + * @param {string} [params.type=error] - The notification type. + * @param {boolean} [params.fromServer=false] - Whether the notification was server-sent. + * @param {string} [params.setting=null] - The setting ID that the notification is related to. + * @param {*} [params.data=null] - Any additional data. + */ + api.Notification = api.Class.extend({ + initialize: function( code, params ) { + var _params; + this.code = code; + _params = _.extend( + { + message: null, + type: 'error', + fromServer: false, + data: null, + setting: null + }, + params + ); + delete _params.code; + _.extend( this, _params ); + } + }); + + // The main API object is also a collection of all customizer settings. + api = $.extend( new api.Values(), api ); + + /** + * Get all customize settings. + * + * @return {object} + */ + api.get = function() { + var result = {}; + + this.each( function( obj, key ) { + result[ key ] = obj.get(); + }); + + return result; + }; + + /** + * Utility function namespace + */ + api.utils = {}; + + /** + * Parse query string. + * + * @since 4.7.0 + * @access public + * + * @param {string} queryString Query string. + * @returns {object} Parsed query string. + */ + api.utils.parseQueryString = function parseQueryString( queryString ) { + var queryParams = {}; + _.each( queryString.split( '&' ), function( pair ) { + var parts, key, value; + parts = pair.split( '=', 2 ); + if ( ! parts[0] ) { + return; + } + key = decodeURIComponent( parts[0].replace( /\+/g, ' ' ) ); + key = key.replace( / /g, '_' ); // What PHP does. + if ( _.isUndefined( parts[1] ) ) { + value = null; + } else { + value = decodeURIComponent( parts[1].replace( /\+/g, ' ' ) ); + } + queryParams[ key ] = value; + } ); + return queryParams; + }; + + // Expose the API publicly on window.wp.customize + exports.customize = api; +})( wp, jQuery ); + +if ( 'undefined' !== typeof module ) { + module.exports = wp.customize; +} diff --git a/tests/js/widget-form.js b/tests/js/widget-form.js new file mode 100644 index 0000000..753a26d --- /dev/null +++ b/tests/js/widget-form.js @@ -0,0 +1,685 @@ +/* globals global, require, describe, it, beforeEach */ +/* eslint-disable no-unused-expressions, no-new, no-magic-numbers, max-nested-callbacks */ + +const sinon = require( 'sinon' ); +const sinonChai = require( 'sinon-chai' ); +const chai = require( 'chai' ); +const _ = require( 'underscore' ); +const markup = 'findme'; +require( 'jsdom-global' )( '
' ); +const jQuery = require( 'jquery' ); + +chai.use( sinonChai ); +const expect = chai.expect; + +function resetGlobals() { + global.wp.customize = {}; + global.wp.a11y = { speak: () => null }; + global.wp.template = id => _.template( jQuery( '#tmpl-' + id ).html(), { evaluate: /<#([\s\S]+?)#>/g, interpolate: /\{\{\{([\s\S]+?)\}\}\}/g, escape: /\{\{([^\}]+?)\}\}(?!\})/g, variable: 'data' } ); + global.jQuery = jQuery; + global.$ = jQuery; + global._ = _; + global.window = { + jQuery, + }; +} + +resetGlobals(); +const { Value, Notification } = require( './lib/customize-base' ); +const Form = require( '../../js/widget-form' ); + +describe( 'wp.widgets.Form', function() { + let CustomForm; + + beforeEach( function() { + resetGlobals(); + jQuery( '.root' ).html( markup ); + CustomForm = Form.extend( { + config: { default_instance: { red: '#f00' } }, + } ); + } ); + + describe( '.initialize() (constructor)', function() { + let model; + let container; + + beforeEach( function() { + model = new Value( 'test' ); + container = '.findme'; + } ); + + it( 'ignores arbitrary properties on the argument object', function() { + const form = new CustomForm( { foo: 'bar', model, container } ); + expect( form.foo ).to.not.exist; + } ); + + it( 'throws an error if the model property is missing', function( done ) { + try { + new CustomForm( { container } ); + } catch ( err ) { + done(); + } + throw new Error( 'Expected initialize to throw an Error but it did not' ); + } ); + + it( 'throws an error if the model property is not an instance of Value', function( done ) { + try { + new CustomForm( { model: { hello: 'world' }, container } ); + } catch ( err ) { + done(); + } + throw new Error( 'Expected initialize to throw an Error but it did not' ); + } ); + + it( 'throws an error if the config.default_instance property is not set on the Form prototype', function( done ) { + CustomForm.prototype.config.default_instance = null; + try { + new CustomForm( { model, container } ); + } catch ( err ) { + done(); + } + throw new Error( 'Expected initialize to throw an Error but it did not' ); + } ); + + it( 'assigns a default config property if it has not already been set on the form object', function() { + CustomForm.prototype.config = null; + const form = new CustomForm( { model, container } ); + const expected = { + form_template_id: '', + notifications_template_id: '', + l10n: {}, + default_instance: {}, + }; + expect( form.config ).to.eql( expected ); + } ); + + it( 'keeps the current config property if it is already set on the form object', function() { + const MyForm = CustomForm.extend( { + config: { subclass: 'yes' }, + } ); + const form = new MyForm( { model, container } ); + const expected = { + form_template_id: '', + notifications_template_id: '', + l10n: {}, + default_instance: {}, + subclass: 'yes', + }; + expect( form.config ).to.eql( expected ); + } ); + + it( 'assigns form.setting as an alias to form.model', function() { + const form = new CustomForm( { model, container } ); + expect( form.setting ).to.equal( model ); + } ); + + it( 'assigns form.notifications as an alias for form.model.notifications if it exists', function() { + model.notifications = { hello: 'world' }; + const form = new CustomForm( { model, container } ); + expect( form.notifications ).to.equal( model.notifications ); + } ); + + it( 'assigns form.notifications to a new Values with the Notification defaultConstructor if form.model.notifications does not exist', function() { + const form = new CustomForm( { model, container } ); + expect( form.notifications.defaultConstructor ).to.equal( Notification ); + } ); + + it( 'assigns form.container to a jQuery DOM object for the selector previously in form.container', function() { + const form = new CustomForm( { model, container: '.findme' } ); + expect( form.container[ 0 ].className ).to.eql( 'findme' ); + } ); + + it( 'throws an error if the form.container selector does not match a DOM node', function( done ) { + try { + new CustomForm( { model, container: 'notfound' } ); + } catch( err ) { + done(); + } + throw new Error( 'Expected initialize to throw an Error but it did not' ); + } ); + } ); + + describe( '.validate()', function() { + let model, form; + + beforeEach( function() { + model = new Value( { hello: 'world' } ); + form = new CustomForm( { model, container: '.findme' } ); + } ); + + it( 'returns an object with the properties of the object passed in', function() { + const actual = form.validate( { foo: 'bar' } ); + expect( actual.foo ).to.eql( 'bar' ); + } ); + + it( 'does not remove any Notifications from form.notifications which do not have the `viaWidgetFormSanitizeReturn` property', function() { + form.notifications.add( 'other-notification', new Notification( 'other-notification', { message: 'test', type: 'warning' } ) ); + form.validate( { foo: 'bar' } ); + expect( form.notifications.has( 'other-notification' ) ).to.be.true; + } ); + + it( 'removes any Notifications from form.notifications which have the `viaWidgetFormSanitizeReturn` property', function() { + const notification = new Notification( 'other-notification', { message: 'test', type: 'warning' } ); + notification.viaWidgetFormSanitizeReturn = true; + form.notifications.add( 'other-notification', notification ); + form.validate( { foo: 'bar' } ); + expect( form.notifications.has( 'other-notification' ) ).to.be.false; + } ); + + describe( 'if the sanitize function returns a Notification', function() { + let MyForm; + + beforeEach( function() { + const sanitize = input => MyForm.returnNotification ? new Notification( 'testcode', { message: 'test', type: 'warning' } ) : input; + MyForm = CustomForm.extend( { sanitize } ); + form = new MyForm( { model, container: '.findme' } ); + MyForm.returnNotification = true; + } ); + + it( 'returns null', function() { + const actual = form.validate( { foo: 'bar' } ); + expect( actual ).to.be.null; + } ); + + it( 'adds any Notification returned from the sanitize method to form.notifications', function() { + form.validate( { foo: 'bar' } ); + expect( form.notifications.has( 'testcode' ) ).to.be.true; + } ); + + it( 'adds `viaWidgetFormSanitizeReturn` property to any Notification returned from the sanitize method', function() { + form.validate( { foo: 'bar' } ); + expect( form.notifications.value( 'testcode' ).viaWidgetFormSanitizeReturn ).to.be.true; + } ); + + it( 'adds `viaWidgetFormSanitizeReturn` property to any Notification returned from the sanitize method', function() { + form.validate( { foo: 'bar' } ); + expect( form.notifications.value( 'testcode' ).viaWidgetFormSanitizeReturn ).to.be.true; + } ); + + it( 'causes subsequent calls to remove any Notifications from form.notifications which have the `viaWidgetFormSanitizeReturn` property', function() { + form.validate( { foo: 'bar' } ); + MyForm.returnNotification = false; + form.validate( { foo: 'bar' } ); + expect( form.notifications.has( 'testcode' ) ).to.be.false; + } ); + + it( 'removes any Notifications from form.notifications which have the `viaWidgetFormSanitizeReturn` property and whose code matches the new Notification', function() { + const notification = new Notification( 'testcode', { message: 'test', type: 'warning' } ); + notification.firstOne = true; + notification.viaWidgetFormSanitizeReturn = true; + form.notifications.add( 'testcode', notification ); + form.validate( { foo: 'bar' } ); + expect( form.notifications.value( 'testcode' ) ).to.not.have.property( 'firstOne' ); + } ); + } ); + + describe( 'if the sanitize function returns an Error', function() { + let MyForm; + + beforeEach( function() { + MyForm = CustomForm.extend( { + sanitize: input => MyForm.returnError ? new Error( 'test error' ) : input, + } ); + form = new MyForm( { model, container: '.findme' } ); + MyForm.returnError = true; + } ); + + it( 'returns null', function() { + const actual = form.validate( { foo: 'bar' } ); + expect( actual ).to.be.null; + } ); + + it( 'adds a Notification with the code `invalidValue`', function() { + form.validate( { foo: 'bar' } ); + expect( form.notifications.has( 'invalidValue' ) ).to.be.true; + } ); + + it( 'adds a Notification with the type `error`', function() { + form.validate( { foo: 'bar' } ); + expect( form.notifications.value( 'invalidValue' ).type ).to.eql( 'error' ); + } ); + + it( 'adds a Notification with the Error message in the message', function() { + form.validate( { foo: 'bar' } ); + expect( form.notifications.value( 'invalidValue' ).message ).to.eql( 'test error' ); + } ); + } ); + } ); + + describe( '.getNotificationsContainerElement()', function() { + let form; + + beforeEach( function() { + const model = new Value( 'test' ); + form = new CustomForm( { model, container: '.findme' } ); + } ); + + it( 'returns a jQuery object for the first instance of .js-widget-form-notifications-container in the container', function() { + jQuery( '.findme' ).append( 'notifications' ); + const actual = form.getNotificationsContainerElement(); + expect( actual.length ).to.eql( 1 ); + } ); + + it( 'returns an empty jQuery object if there are no instances of .js-widget-form-notifications-container in the container', function() { + const actual = form.getNotificationsContainerElement(); + expect( actual.length ).to.eql( 0 ); + } ); + } ); + + describe( '.sanitize()', function() { + let form; + + beforeEach( function() { + const model = new Value( 'test' ); + form = new CustomForm( { model, container: '.findme' } ); + } ); + + it( 'throws an Error if oldInstance is not set', function( done ) { + try { + form.sanitize( {} ); + } catch( err ) { + done(); + } + throw new Error( 'Expected sanitize to throw an Error but it did not' ); + } ); + + it( 'does not throw an error if oldInstance is null', function() { + form.sanitize( {}, null ); + } ); + + it( 'returns an object with the same properties as the original object', function() { + const actual = form.sanitize( { foo: 'bar' }, {} ); + expect( actual.foo ).to.eql( 'bar' ); + } ); + + it( 'returns an object with the `title` property trimmed', function() { + const actual = form.sanitize( { title: ' hello world ' }, {} ); + expect( actual.title ).to.eql( 'hello world' ); + } ); + + it( 'adds a markupTitleInvalid notification to form.notifications if there is markup in the `title` property', function() { + form.sanitize( { title: 'this is cool' }, {} ); + expect( form.notifications.has( 'markupTitleInvalid' ) ).to.be.true; + } ); + + it( 'does not add a markupTitleInvalid notification from form.notifications if there is no markup in the `title` property', function() { + form.sanitize( { title: 'this is cool' }, {} ); + expect( form.notifications.has( 'markupTitleInvalid' ) ).to.be.false; + } ); + + it( 'removes any existing markupTitleInvalid notification from form.notifications if there is no markup in the `title` property', function() { + form.sanitize( { title: 'this is cool' }, {} ); + form.sanitize( { title: 'this is cool' }, {} ); + expect( form.notifications.has( 'markupTitleInvalid' ) ).to.be.false; + } ); + } ); + + describe( '.getValue()', function() { + let form; + + beforeEach( function() { + const model = new Value( { hello: 'world' } ); + const MyForm = CustomForm.extend( { + config: { default_instance: { foo: 'bar' } }, + } ); + form = new MyForm( { model, container: '.findme' } ); + } ); + + it( 'returns an object with the properties of the form\'s config.default_instance', function() { + const actual = form.getValue(); + expect( actual.foo ).to.eql( 'bar' ); + } ); + + it( 'returns an object with the properties of the form\'s model.get()', function() { + const actual = form.getValue(); + expect( actual.hello ).to.eql( 'world' ); + } ); + } ); + + describe( '.setState()', function() { + let form, model; + + beforeEach( function() { + model = new Value( { hello: 'world' } ); + form = new CustomForm( { model, container: '.findme' } ); + } ); + + it( 'updates the model by merging its value with the passed object properties to add values', function() { + form.setState( { beep: 'boop' } ); + expect( model.get().beep ).to.eql( 'boop' ); + } ); + + it( 'updates the model by merging its value with the passed object properties to replace values', function() { + form.setState( { hello: 'boop' } ); + expect( model.get().hello ).to.eql( 'boop' ); + } ); + + it( 'retains the current values on the model if not specifically overwritten', function() { + form.setState( { beep: 'boop' } ); + expect( model.get().hello ).to.eql( 'world' ); + } ); + + it( 'does not change the model if no properties are passed', function() { + form.setState( {} ); + expect( model.get() ).to.eql( { hello: 'world' } ); + } ); + + it( 'calls form.validate() on its value before updating the model', function() { + form.validate = original => { + return Object.assign( {}, _.omit( original, [ 'invalid' ] ), { valid: 'good' } ); + }; + form.setState( { invalid: 'bad' } ); + expect( model.get() ).to.eql( { hello: 'world', valid: 'good' } ); + } ); + + it( 'calls form.validate() with the new value merged with the model\'s current value', function() { + form.validate = sinon.spy(); + form.setState( { foo: 'bar' } ); + expect( form.validate ).to.be.calledWith( { hello: 'world', foo: 'bar' } ); + } ); + + it( 'if form.validate returns an Error, the model is left unchanged', function() { + form.validate = () => new Error( 'test error' ); + form.setState( { invalid: 'bad' } ); + expect( model.get() ).to.eql( { hello: 'world' } ); + } ); + + it( 'if form.validate returns null, the model is left unchanged', function() { + form.validate = () => null; + form.setState( { invalid: 'bad' } ); + expect( model.get() ).to.eql( { hello: 'world' } ); + } ); + + it( 'if form.validate returns a Notification, the model is left unchanged', function() { + form.validate = () => new Notification( 'other-notification', { message: 'test', type: 'warning' } ); + form.setState( { invalid: 'bad' } ); + expect( model.get() ).to.eql( { hello: 'world' } ); + } ); + } ); + + describe( '.getTemplate()', function() { + let form, model, templateSpy; + + beforeEach( function() { + templateSpy = sinon.stub().returns( () => 'pastries' ); + global.wp.template = templateSpy; + model = new Value( { hello: 'world' } ); + jQuery( '.root' ).append( '' ) + const MyForm = CustomForm.extend( { + config: { form_template_id: 'my-template' }, + } ); + form = new MyForm( { model, container: '.findme' } ); + } ); + + it( 'calls wp.template() on the config.form_template_id if no template is cached', function() { + form.getTemplate(); + expect( templateSpy ).to.have.been.calledWith( 'my-template' ); + } ); + + it( 'returns the result of wp.template() if no template is cached', function() { + const actual = form.getTemplate(); + expect( actual() ).to.eql( 'pastries' ); + } ); + + it( 'throws an error if the template does not exist in the DOM', function( done ) { + const MyForm = CustomForm.extend( { + config: { form_template_id: 'notfound' }, + } ); + form = new MyForm( { model, container: '.findme' } ); + try { + form.getTemplate(); + } catch( err ) { + done(); + } + throw new Error( 'Expected getTemplate to throw an Error but it did not' ); + } ); + + it( 'returns the cached template if one is saved', function() { + form.getTemplate(); + const actual = form.getTemplate(); + expect( actual() ).to.eql( 'pastries' ); + } ); + + it( 'does not search the DOM for a template if one is cached', function() { + form.getTemplate(); + form.getTemplate(); + expect( templateSpy ).to.have.been.calledOnce; + } ); + } ); + + describe( '.renderNotificationsToContainer()', function() { + let form, model; + + beforeEach( function() { + model = new Value( { hello: 'world' } ); + jQuery( '.root' ).append( '' ) + jQuery( '.root' ).append( '' ) + jQuery( '.findme' ).append( '' ); + const MyForm = CustomForm.extend( { + config: { form_template_id: 'my-template', notifications_template_id: 'my-notifications' }, + } ); + form = new MyForm( { model, container: '.findme' } ); + } ); + + it( 'throws an error if notifications_template_id is undefined', function( done ) { + const MyForm = CustomForm.extend( { + config: { form_template_id: 'my-template' }, + } ); + form = new MyForm( { model, container: '.findme' } ); + try { + form.renderNotificationsToContainer(); + } catch( err ) { + done(); + } + throw new Error( 'Expected renderNotificationsToContainer to throw an Error but it did not' ); + } ); + + it( 'does not throw any errors if getNotificationsContainerElement returns null', function() { + form.getNotificationsContainerElement = () => null; + expect( form.renderNotificationsToContainer() ).to.be.empty; + } ); + + describe( 'when form.notifications is empty', function() { + it( 'hides the notifications area', function() { + const notificationsContainer = jQuery( '.js-widget-form-notifications-container' ); + notificationsContainer.slideUp = sinon.spy(); + form.getNotificationsContainerElement = () => notificationsContainer; + form.renderNotificationsToContainer(); + expect( notificationsContainer.slideUp ).to.have.been.called; + } ); + + it( 'does not change the height css of the notifications area', function( done ) { + const notificationsContainer = jQuery( '.js-widget-form-notifications-container' ); + notificationsContainer.css = sinon.spy(); + form.getNotificationsContainerElement = () => notificationsContainer; + form.renderNotificationsToContainer(); + // The height change happens async so we defer this assertion + setTimeout( function() { + expect( notificationsContainer.css ).to.not.have.been.called; + done(); + }, 0 ); + } ); + + it( 'removes the `has-notifications` class on form.container', function() { + jQuery( '.findme' ).addClass( 'has-notifications' ); + form.renderNotificationsToContainer(); + expect( jQuery( '.findme' ).hasClass( 'has-notifications' ) ).to.be.false; + } ); + + it( 'removes the `has-error` class on form.container', function() { + jQuery( '.findme' ).addClass( 'has-error' ); + form.renderNotificationsToContainer(); + expect( jQuery( '.findme' ).hasClass( 'has-error' ) ).to.be.false; + } ); + + it( 'replaces the content of the notifications area with the contents of the notifications template', function() { + jQuery( '.js-widget-form-notifications-container' ).text( 'nothing here' ); + form.renderNotificationsToContainer(); + expect( jQuery( '.js-widget-form-notifications-container' ).text() ).to.eql( 'notifications template' ); + } ); + + it( 'replaces the content of the notifications area with the trimmed contents of the notifications template', function() { + jQuery( '#tmpl-my-notifications' ).html( ' notifications template ' ) + jQuery( '.js-widget-form-notifications-container' ).text( 'nothing here' ); + form.renderNotificationsToContainer(); + expect( jQuery( '.js-widget-form-notifications-container' ).text() ).to.eql( 'notifications template' ); + } ); + + it( 'does not call wp.a11y.speak', function() { + wp.a11y.speak = sinon.spy(); + form.renderNotificationsToContainer(); + expect( wp.a11y.speak ).to.not.have.been.called; + } ); + } ); + + describe( 'when there are notifications', function() { + let myNotification; + + beforeEach( function() { + myNotification = new Notification( 'other-notification', { message: 'test', type: 'warning' } ); + form.notifications.add( 'other-notification', myNotification ); + } ); + + it( 'shows the notifications area', function() { + const notificationsContainer = jQuery( '.js-widget-form-notifications-container' ); + notificationsContainer.slideDown = sinon.spy(); + form.getNotificationsContainerElement = () => notificationsContainer; + form.renderNotificationsToContainer(); + expect( notificationsContainer.slideDown ).to.have.been.called; + } ); + + it( 'changes the css height property on the notification container to `auto`', function( done ) { + const notificationsContainer = jQuery( '.js-widget-form-notifications-container' ); + notificationsContainer.css = sinon.spy(); + form.getNotificationsContainerElement = () => notificationsContainer; + form.renderNotificationsToContainer(); + // The height change happens async so we defer this assertion + setTimeout( function() { + expect( notificationsContainer.css ).to.have.been.calledWith( 'height', 'auto' ); + done(); + }, 0 ); + } ); + + it( 'adds the `has-notifications` class on form.container', function() { + jQuery( '.findme' ).removeClass( 'has-notifications' ); + form.renderNotificationsToContainer(); + expect( jQuery( '.findme' ).hasClass( 'has-notifications' ) ).to.be.true; + } ); + + it( 'adds the `has-error` class on form.container if a notification is an error', function() { + jQuery( '.findme' ).removeClass( 'has-error' ); + form.notifications.add( 'error-notification', new Notification( 'error-notification', { message: 'test', type: 'error' } ) ); + form.renderNotificationsToContainer(); + expect( jQuery( '.findme' ).hasClass( 'has-error' ) ).to.be.true; + } ); + + it( 'removes the `has-error` class on form.container if no notifications are errors', function() { + jQuery( '.findme' ).addClass( 'has-error' ); + form.renderNotificationsToContainer(); + expect( jQuery( '.findme' ).hasClass( 'has-error' ) ).to.be.false; + } ); + + it( 'calls wp.a11y.speak for each notification', function() { + wp.a11y.speak = sinon.spy(); + form.renderNotificationsToContainer(); + expect( wp.a11y.speak ).to.have.been.calledWith( 'test', 'assertive' ); + } ); + + it( 'calls the notifications template function with the notifications as interpolated values', function() { + const interpolateSpy = sinon.spy(); + const templateSpy = sinon.stub().returns( interpolateSpy ); + global.wp.template = templateSpy; + form.renderNotificationsToContainer(); + expect( interpolateSpy ).to.have.been.calledWith( { notifications: [ myNotification ], altNotice: false } ); + } ); + } ); + } ); + + describe( '.render()', function() { + let form, model, templateSpy; + + beforeEach( function() { + model = new Value( { hello: 'world' } ); + jQuery( '.root' ).append( '' ) + const MyForm = CustomForm.extend( { + config: { form_template_id: 'my-template' }, + } ); + form = new MyForm( { model, container: '.findme' } ); + form.container.html = sinon.spy(); + } ); + + it( 'replaces the html of form.container with the interpolated value of form.getTemplate()', function() { + form.render(); + expect( form.container.html ).to.have.been.calledWith( 'pastries' ); + } ); + + it( 'calls the template function passing the Form object itself', function() { + const interpolateSpy = sinon.spy(); + templateSpy = sinon.stub().returns( interpolateSpy ); + global.wp.template = templateSpy; + form.render(); + expect( interpolateSpy ).to.have.been.calledWith( form ); + } ); + } ); + + describe( 'when rendered with a form element whose `data-field` attribute matches a model property', function() { + let model, form; + + beforeEach( function() { + model = new Value( { hello: 'world' } ); + jQuery( '.root' ).append( '' ); + const MyForm = CustomForm.extend( { + config: { form_template_id: 'my-template' }, + } ); + form = new MyForm( { model, container: '.findme' } ); + form.render(); + } ); + + it( 'updates the associated model property when the form element changes', function() { + jQuery( '.hello-field' ).val( 'computer' ); + jQuery( '.hello-field' ).trigger( 'change' ); + expect( model.get().hello ).to.eql( 'computer' ); + } ); + + it( 'updates the associated model property with a validated version when the form element changes', function() { + form.validate = () => ( { hello: 'valid' } ); + jQuery( '.hello-field' ).val( 'invalid' ); + jQuery( '.hello-field' ).trigger( 'change' ); + expect( model.get().hello ).to.eql( 'valid' ); + } ); + + it( 'does not update the associated model property when the form element changes and validation fails', function() { + form.validate = () => null; + jQuery( '.hello-field' ).val( 'invalid' ); + jQuery( '.hello-field' ).trigger( 'change' ); + expect( model.get().hello ).to.eql( 'world' ); + } ); + + it( 'updates the form field value when the model property changes', function() { + model.set( { hello: 'computer' } ); + expect( jQuery( '.hello-field' ).val() ).to.eql( 'computer' ); + } ); + } ); + + describe( 'when rendered and then destroyed', function() { + let model, form; + + beforeEach( function() { + model = new Value( { hello: 'world' } ); + jQuery( '.root' ).append( '' ); + const MyForm = CustomForm.extend( { + config: { form_template_id: 'my-template' }, + } ); + form = new MyForm( { model, container: '.findme' } ); + form.render(); + } ); + + it( 'removes event bindings between the Form and the DOM', function() { + // This is hard to test; keep the DOM around to see if it retains references + form.container.empty = () => null; + form.destruct(); + model.set( { hello: 'computer' } ); + expect( jQuery( '.hello-field' ).val() ).to.not.eql( 'computer' ); + } ); + } ); +} );