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' );
+ } );
+ } );
+} );