├── .david-dev ├── .jshintrc ├── phpunit.xml.dist ├── .gitmodules ├── .jshintignore ├── .coveralls.yml ├── .eslintignore ├── .dev-lib ├── .gitignore ├── post-collection-widget ├── view.css ├── form.css ├── class-plugin.php ├── form.js └── class-widget.php ├── .jscsrc ├── core-adapter-widgets ├── rss │ ├── form.js │ └── class.php ├── meta │ ├── form.js │ └── class.php ├── search │ ├── form.js │ └── class.php ├── archives │ ├── form.js │ └── class.php ├── calendar │ ├── form.js │ └── class.php ├── tag_cloud │ ├── form.js │ └── class.php ├── categories │ ├── form.js │ └── class.php ├── recent-posts │ ├── form.js │ └── class.php ├── recent-comments │ ├── form.js │ └── class.php ├── pages │ ├── form.js │ └── class.php └── nav_menu │ ├── class.php │ └── form.js ├── composer.json ├── phpcs.ruleset.xml ├── .travis.yml ├── js ├── trac-39389-controls.js ├── shortcode-ui-view-widget-form-field.js ├── trac-39389-preview.js ├── admin-js-widgets.js ├── customize-js-widgets.js └── widget-form.js ├── php ├── class-wp-customize-js-widget-control.php ├── class-wp-adapter-js-widget.php ├── class-js-widget-shortcode-controller.php ├── class-js-widgets-shortcode-ui.php └── class-js-widgets-rest-controller.php ├── package.json ├── js-widgets.php ├── post-collection-widget.php ├── tests └── js │ └── customize-js-widgets.js ├── Gruntfile.js ├── contributing.md ├── css └── widget-form.css ├── .eslintrc ├── readme.txt └── readme.md /.david-dev: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | dev-lib/.jshintrc -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | dev-lib/phpunit-plugin.xml -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "dev-lib"] 2 | path = dev-lib 3 | url = https://github.com/xwp/wp-dev-lib.git 4 | branch = master 5 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | **/*.min.js 2 | **/node_modules/** 3 | **/vendor/** 4 | **/*.jsx 5 | /bower_components/** 6 | /tests/js/** 7 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | coverage_clover: build/logs/clover.xml 3 | json_path: build/logs/coveralls-upload.json 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.min.js 2 | **/node_modules/** 3 | **/vendor/** 4 | /tests/js/lib/** 5 | **/*.browserified.js 6 | /bower_components/** 7 | -------------------------------------------------------------------------------- /.dev-lib: -------------------------------------------------------------------------------- 1 | WPCS_GIT_TREE=develop 2 | ASSETS_DIR=wp-assets 3 | 4 | if [[ ${TRAVIS_PHP_VERSION:0:3} == "5.2" ]] || [[ ${TRAVIS_PHP_VERSION:0:3} == "5.3" ]]; then 5 | DEV_LIB_SKIP="$DEV_LIB_SKIP,phpcs" 6 | fi 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Grunt 4 | /build/ 5 | /node_modules/ 6 | npm-debug.log 7 | 8 | # Composer 9 | composer.lock 10 | /vendor/ 11 | 12 | # Compiled files 13 | *.min.js 14 | *.min.css 15 | -------------------------------------------------------------------------------- /post-collection-widget/view.css: -------------------------------------------------------------------------------- 1 | 2 | .widget-post-collection-list .title, 3 | .widget-post-collection-list .author { 4 | margin: 0; 5 | } 6 | .widget-post-collection-list .date { 7 | display: block; 8 | } 9 | .widget-post-collection-list li { 10 | margin-bottom: 0.5em; 11 | } 12 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "wordpress", 3 | "requireCamelCaseOrUpperCaseIdentifiers": { 4 | "ignoreProperties": true, 5 | "allExcept": [ "Shortcode_UI" ] 6 | }, 7 | "excludeFiles": [ 8 | "**/*.min.js", 9 | "**/*.jsx", 10 | "**/node_modules/**", 11 | "**/vendor/**", 12 | "**/tests/**", 13 | "bower_components/**" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /post-collection-widget/form.css: -------------------------------------------------------------------------------- 1 | 2 | .widget-post-collection .customize-object-selector-container, 3 | .widget-post-collection .customize-object-selector-container .select2-container { 4 | display: block; 5 | } 6 | 7 | .widget-post-collection .customize-object-selector-container .select2-selection--multiple .select2-selection__rendered .select2-selection__choice { 8 | display: block; 9 | float: none; 10 | cursor: move; 11 | } 12 | -------------------------------------------------------------------------------- /core-adapter-widgets/rss/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor.rss = (function() { 7 | 'use strict'; 8 | 9 | var RSSWidgetForm; 10 | 11 | /** 12 | * RSS Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | RSSWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = RSSWidgetForm; 20 | } 21 | return RSSWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/meta/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor.meta = (function() { 7 | 'use strict'; 8 | 9 | var MetaWidgetForm; 10 | 11 | /** 12 | * Meta Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | MetaWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = MetaWidgetForm; 20 | } 21 | return MetaWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/search/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor.search = (function() { 7 | 'use strict'; 8 | 9 | var SearchWidgetForm; 10 | 11 | /** 12 | * Search Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | SearchWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = SearchWidgetForm; 20 | } 21 | return SearchWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/archives/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor.archives = (function() { 7 | 'use strict'; 8 | 9 | var ArchivesWidgetForm; 10 | 11 | /** 12 | * Archives Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | ArchivesWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = ArchivesWidgetForm; 20 | } 21 | return ArchivesWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/calendar/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor.calendar = (function() { 7 | 'use strict'; 8 | 9 | var CalendarWidgetForm; 10 | 11 | /** 12 | * Calendar Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | CalendarWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = CalendarWidgetForm; 20 | } 21 | return CalendarWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/tag_cloud/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor.tag_cloud = (function() { 7 | 'use strict'; 8 | 9 | var TagCloudWidgetForm; 10 | 11 | /** 12 | * Tag Cloud Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | TagCloudWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = TagCloudWidgetForm; 20 | } 21 | return TagCloudWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/categories/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor.categories = (function() { 7 | 'use strict'; 8 | 9 | var CategoriesWidgetForm; 10 | 11 | /** 12 | * Categories Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | CategoriesWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = CategoriesWidgetForm; 20 | } 21 | return CategoriesWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/recent-posts/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor['recent-posts'] = (function() { 7 | 'use strict'; 8 | 9 | var RecentPostsWidgetForm; 10 | 11 | /** 12 | * Recent Posts Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | RecentPostsWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = RecentPostsWidgetForm; 20 | } 21 | return RecentPostsWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /core-adapter-widgets/recent-comments/form.js: -------------------------------------------------------------------------------- 1 | /* global wp, module */ 2 | /* eslint consistent-this: [ "error", "form" ] */ 3 | /* eslint-disable strict */ 4 | /* eslint-disable complexity */ 5 | 6 | wp.widgets.formConstructor['recent-comments'] = (function() { 7 | 'use strict'; 8 | 9 | var RecentCommentsWidgetForm; 10 | 11 | /** 12 | * Recent Comments Widget Form. 13 | * 14 | * @constructor 15 | */ 16 | RecentCommentsWidgetForm = wp.widgets.Form.extend( {} ); 17 | 18 | if ( 'undefined' !== typeof module ) { 19 | module.exports = RecentCommentsWidgetForm; 20 | } 21 | return RecentCommentsWidgetForm; 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xwp/wp-js-widgets", 3 | "description": "The next generation of widgets in Core (Widgets 3.0), embracing JS for UI and powering the Widgets REST API.", 4 | "version": "0.4.3", 5 | "type": "wordpress-plugin", 6 | "keywords": [ "customizer", "widgets", "rest-api" ], 7 | "homepage": "https://github.com/xwp/wp-js-widgets/", 8 | "license": [ "GPL-2.0+" ], 9 | "repositories": [ 10 | { 11 | "type": "git", 12 | "url": "https://github.com/xwp/wp-js-widgets.git" 13 | } 14 | ], 15 | "dist": { 16 | "url": "https://downloads.wordpress.org/plugin/js-widgets.zip", 17 | "type": "zip" 18 | }, 19 | "require": { 20 | "php": ">=5.2.0" 21 | }, 22 | "authors": [ 23 | { 24 | "name": "Weston Ruter", 25 | "homepage": "https://weston.ruter.net/" 26 | } 27 | ], 28 | "require-dev": { 29 | "satooshi/php-coveralls": "dev-master" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /phpcs.ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 0 19 | 20 | 21 | 22 | 23 | 0 24 | 25 | 26 | */dev-lib/* 27 | */node_modules/* 28 | */vendor/* 29 | 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: precise 2 | sudo: false 3 | 4 | notifications: 5 | email: 6 | on_success: never 7 | on_failure: change 8 | 9 | cache: 10 | directories: 11 | - node_modules 12 | - vendor 13 | - $HOME/phpunit-bin 14 | 15 | language: 16 | - php 17 | - node_js 18 | 19 | php: 20 | - 5.3 21 | - 7.0 22 | 23 | env: 24 | - WP_VERSION=4.7 WP_MULTISITE=0 25 | - WP_VERSION=trunk WP_MULTISITE=0 26 | - WP_VERSION=trunk WP_MULTISITE=1 27 | 28 | install: 29 | - nvm install 6 && nvm use 6 30 | - export DEV_LIB_PATH=dev-lib 31 | - if [ ! -e "$DEV_LIB_PATH" ] && [ -L .travis.yml ]; then export DEV_LIB_PATH=$( dirname $( readlink .travis.yml ) ); fi 32 | - if [ ! -e "$DEV_LIB_PATH" ]; then git clone https://github.com/xwp/wp-dev-lib.git $DEV_LIB_PATH; fi 33 | - source $DEV_LIB_PATH/travis.install.sh 34 | 35 | script: 36 | - npm test 37 | - set -x 38 | - source $DEV_LIB_PATH/travis.script.sh 39 | 40 | after_script: 41 | - source $DEV_LIB_PATH/travis.after_script.sh 42 | -------------------------------------------------------------------------------- /js/trac-39389-controls.js: -------------------------------------------------------------------------------- 1 | /* global wp */ 2 | /* eslint-disable strict */ 3 | /* eslint consistent-this: [ "error", "partial" ] */ 4 | 5 | (function( api ) { 6 | 'use strict'; 7 | 8 | var component = { 9 | 10 | /** 11 | * Init component. 12 | * 13 | * @returns {void} 14 | */ 15 | init: function init() { 16 | api.control.each( component.handleControlAddition ); 17 | api.control.bind( 'add', component.handleControlAddition ); 18 | }, 19 | 20 | /** 21 | * Handle control addition. 22 | * 23 | * @param {wp.customize.Control} control Control. 24 | * @returns {void} 25 | */ 26 | handleControlAddition: function handleControlAddition( control ) { 27 | if ( ! control.extended( api.Widgets.WidgetControl ) ) { 28 | return; 29 | } 30 | control.expanded.bind( function handleControlExpandedChange( isExpanded ) { 31 | if ( isExpanded ) { 32 | api.previewer.send( 'scroll-setting-related-partial-into-view', control.setting.id ); 33 | } 34 | } ); 35 | } 36 | }; 37 | 38 | api.bind( 'ready', component.init ); 39 | } )( wp.customize ); 40 | -------------------------------------------------------------------------------- /core-adapter-widgets/search/class.php: -------------------------------------------------------------------------------- 1 | invoke( $this ); 24 | 25 | $exported_properties = array( 'widget_id', 'widget_id_base', 'sidebar_id', 'width', 'height', 'is_wide' ); 26 | foreach ( $exported_properties as $key ) { 27 | $this->json[ $key ] = $this->$key; 28 | } 29 | 30 | $this->json['content'] = ''; 31 | $this->json['widget_control'] = null; 32 | $this->json['widget_content'] = null; 33 | } 34 | 35 | /** 36 | * Disable rendering the control wrapper since handled dynamically in JS. 37 | */ 38 | protected function render() {} 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-widgets", 3 | "title": "JS Widgets", 4 | "homepage": "https://github.com/xwp/wp-js-widgets", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/xwp/wp-js-widgets.git" 8 | }, 9 | "version": "0.4.3", 10 | "license": "GPL-2.0+", 11 | "private": true, 12 | "devDependencies": { 13 | "chai": "^3.5.0", 14 | "eslint": "^3.13.1", 15 | "grunt": "~0.4.5", 16 | "grunt-contrib-clean": "~1.0.0", 17 | "grunt-contrib-copy": "~1.0.0", 18 | "grunt-contrib-jshint": "~1.0.0", 19 | "grunt-contrib-watch": "^1.0.0", 20 | "grunt-shell": "^1.3.1", 21 | "grunt-wp-deploy": "^1.2.1", 22 | "jquery": "^3.1.1", 23 | "jsdom": "^9.9.1", 24 | "jsdom-global": "^2.1.1", 25 | "mocha": "^3.2.0", 26 | "sinon": "^1.17.7", 27 | "sinon-chai": "^2.8.0", 28 | "underscore": "^1.8.3" 29 | }, 30 | "author": "XWP", 31 | "description": "The next generation of widgets in core, embracing JS for UI and powering the Widgets REST API.", 32 | "bugs": { 33 | "url": "https://github.com/xwp/wp-js-widgets/issues" 34 | }, 35 | "main": "Gruntfile.js", 36 | "directories": { 37 | "test": "tests" 38 | }, 39 | "scripts": { 40 | "test": "mocha tests/js" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /js-widgets.php: -------------------------------------------------------------------------------- 1 | version = $matches[1]; 36 | } 37 | } 38 | 39 | /** 40 | * Add hooks. 41 | * 42 | * @access public 43 | */ 44 | public function init() { 45 | if ( ! class_exists( 'JS_Widgets_Plugin' ) ) { 46 | add_action( 'admin_notices', array( $this, 'print_admin_notice_missing_plugin_dependency' ) ); 47 | return; 48 | } 49 | 50 | add_action( 'widgets_init', array( $this, 'register_widget' ) ); 51 | } 52 | 53 | /** 54 | * Show admin notice when the JS Widgets plugin is not active. 55 | */ 56 | public function print_admin_notice_missing_plugin_dependency() { 57 | ?> 58 |
59 |

60 |
61 | widget = new WP_JS_Widget_Post_Collection( $this ); 72 | register_widget( $this->widget ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /core-adapter-widgets/calendar/class.php: -------------------------------------------------------------------------------- 1 | __( 'The rendered HTML for the post calendar.', 'js-widgets' ), 44 | 'type' => 'string', 45 | 'context' => array( 'view', 'edit', 'embed' ), 46 | 'readonly' => true, 47 | 'default' => '', 48 | ); 49 | return $item_schema; 50 | } 51 | 52 | /** 53 | * Render a widget instance for a REST API response. 54 | * 55 | * @inheritdoc 56 | * 57 | * @param array $instance Raw database instance. 58 | * @param WP_REST_Request $request REST request. 59 | * @return array Widget item. 60 | */ 61 | public function prepare_item_for_response( $instance, $request ) { 62 | $item = parent::prepare_item_for_response( $instance, $request ); 63 | $item['rendered'] = get_calendar( true, false ); 64 | return $item; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /core-adapter-widgets/archives/class.php: -------------------------------------------------------------------------------- 1 | array( 42 | 'description' => __( 'Display as dropdown', 'default' ), 43 | 'type' => 'boolean', 44 | 'default' => false, 45 | 'context' => array( 'view', 'edit', 'embed' ), 46 | ), 47 | 'count' => array( 48 | 'description' => __( 'Show post counts', 'default' ), 49 | 'type' => 'boolean', 50 | 'default' => false, 51 | 'context' => array( 'view', 'edit', 'embed' ), 52 | ), 53 | // @todo There needs to be raw data returned such that a client can construct an archive view as get_archives() does on the server. 54 | ) 55 | ); 56 | return $item_schema; 57 | } 58 | 59 | /** 60 | * Render JS template contents minus the ` 395 | ` wrapper. 400 | * 401 | * This is called in `WP_JS_Widget::render_form_template_scripts()`. 402 | * 403 | * @see WP_JS_Widget::render_form_template_scripts() 404 | */ 405 | public function render_form_template() { 406 | if ( ! wp_script_is( 'customize-object-selector-component' ) ) : ?> 407 |

408 | %2$s', 414 | 'https://github.com/xwp/wp-customize-object-selector', 415 | __( 'Customize Object Selector', 'js-widgets' ) 416 | ) 417 | ) ); 418 | ?> 419 |

420 | 421 | render_title_form_field_template(); 423 | ?> 424 |

425 | 426 | 427 |

428 | render_form_field_template( array( 430 | 'field' => 'show_date', 431 | 'label' => __( 'Show date', 'js-widgets' ), 432 | ) ); 433 | $this->render_form_field_template( array( 434 | 'field' => 'show_author', 435 | 'label' => __( 'Show author', 'js-widgets' ), 436 | ) ); 437 | $this->render_form_field_template( array( 438 | 'field' => 'show_featured_image', 439 | 'label' => __( 'Show featured image', 'js-widgets' ), 440 | ) ); 441 | ?> 442 | 0 ) 78 | .then( function() { 79 | if ( notifications.length > 0 ) { 80 | container.css( 'height', 'auto' ); 81 | } 82 | } ); 83 | form.container.toggleClass( 'has-error', notifications.filter( isNotificationError ).length > 0 ); 84 | form.container.toggleClass( 'has-notifications', notifications.length > 0 ); 85 | 86 | notifications.map( speakNotification ); 87 | 88 | templateFunction = getNotificationsTemplate( form ); 89 | renderMarkupToContainer( container, templateFunction( { notifications: notifications, altNotice: Boolean( form.altNotice ) } ) ); 90 | }, 91 | 92 | /** 93 | * Get the element inside of a form's container that contains the notifications. 94 | * 95 | * Control subclasses may override this to return the proper container to render notifications into. 96 | * 97 | * @returns {jQuery} Notifications container element. 98 | */ 99 | getNotificationsContainerElement: function getNotificationsContainerElement() { 100 | var form = this; 101 | return form.container.find( '.js-widget-form-notifications-container:first' ); 102 | }, 103 | 104 | /** 105 | * Validate the instance data. 106 | * 107 | * @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: 108 | * 109 | * @param {object} value Instance value. 110 | * @returns {object|Error|wp.customize.Notification} Sanitized instance value or error/notification. 111 | */ 112 | validate: function validate( value ) { 113 | var form = this, newValue, oldValue; 114 | oldValue = form.model.get(); 115 | newValue = form.sanitize( value, oldValue ); 116 | 117 | // Remove all existing notifications added via sanitization since only one can be returned. 118 | removeSanitizeNotifications( form.notifications ); 119 | 120 | // If sanitize method returned an error/notification, block setting update and add a notification 121 | if ( newValue instanceof Error ) { 122 | newValue = new api.Notification( 'invalidValue', { message: newValue.message, type: 'error' } ); 123 | } 124 | if ( newValue instanceof api.Notification ) { 125 | addSanitizeNotification( form, newValue ); 126 | return null; 127 | } 128 | 129 | return newValue; 130 | }, 131 | 132 | /** 133 | * Sanitize the instance data. 134 | * 135 | * @param {object} newInstance New instance. 136 | * @param {object} oldInstance Existing instance. 137 | * @returns {object|Error|wp.customize.Notification} Sanitized instance or validation error/notification. 138 | */ 139 | sanitize: function sanitize( newInstance, oldInstance ) { 140 | var form = this, instance, code, notification; 141 | if ( _.isUndefined( oldInstance ) ) { 142 | throw new Error( 'Expected oldInstance' ); 143 | } 144 | instance = _.extend( {}, newInstance ); 145 | 146 | // Warn about markup in title. 147 | code = 'markupTitleInvalid'; 148 | if ( /<\/?\w+[^>]*>/.test( instance.title ) ) { 149 | notification = new api.Notification( code, { 150 | message: form.config.l10n.title_tags_invalid, 151 | type: 'warning' 152 | } ); 153 | form.notifications.add( code, notification ); 154 | } else { 155 | form.notifications.remove( code ); 156 | } 157 | 158 | /* 159 | * Trim per sanitize_text_field(). 160 | * Protip: This prevents the widget partial from refreshing after adding a space or adding a new paragraph. 161 | */ 162 | if ( instance.title ) { 163 | instance.title = $.trim( instance.title ); 164 | } 165 | 166 | return instance; 167 | }, 168 | 169 | /** 170 | * Get cloned value. 171 | * 172 | * @todo This will only do shallow copy. 173 | * 174 | * @return {object} Instance. 175 | */ 176 | getValue: function getValue() { 177 | var form = this; 178 | return _.extend( 179 | {}, 180 | form.config.default_instance, 181 | form.model.get() || {} 182 | ); 183 | }, 184 | 185 | /** 186 | * Merge the props into the current value. 187 | * 188 | * @todo Rename this update? Rename this set? Or setExtend? Or setValue()? 189 | * 190 | * @param {object} props Instance props. 191 | * @returns {void} 192 | */ 193 | setState: function setState( props ) { 194 | var form = this, validated; 195 | validated = form.validate( _.extend( {}, form.model.get(), props ) ); 196 | if ( ! validated || validated instanceof Error || validated instanceof api.Notification ) { 197 | return; 198 | } 199 | form.model.set( validated ); 200 | }, 201 | 202 | /** 203 | * Create synced property value. 204 | * 205 | * Given that the current setting contains an object value, create a new 206 | * model (Value) to represent the value of one of its properties, and 207 | * sync the value between the root object and the property value when 208 | * either are changed. The returned Value can be used to sync with an 209 | * Element. 210 | * 211 | * @param {wp.customize.Value} root Root value instance. 212 | * @param {string} property Property name. 213 | * @returns {object} Property value instance. 214 | */ 215 | createSyncedPropertyValue: function createSyncedPropertyValue( root, property ) { 216 | var form = this, propertyValue, rootChangeListener, propertyChangeListener; 217 | 218 | propertyValue = new api.Value( form.getValue()[ property ] ); 219 | 220 | // Sync changes to the property back to the root value. 221 | propertyChangeListener = function( newPropertyValue ) { 222 | var newState = {}; 223 | newState[ property ] = newPropertyValue; 224 | form.setState( newState ); 225 | }; 226 | propertyValue.bind( propertyChangeListener ); 227 | 228 | // Sync changes in the root value to the model. 229 | rootChangeListener = function updateRootValue( newRootValue, oldRootValue ) { 230 | if ( ! _.isEqual( newRootValue[ property ], oldRootValue[ property ] ) ) { 231 | propertyValue.set( newRootValue[ property ] ); 232 | } 233 | }; 234 | root.bind( rootChangeListener ); 235 | 236 | return { 237 | value: propertyValue, 238 | propertyChangeListener: propertyChangeListener, 239 | rootChangeListener: rootChangeListener 240 | }; 241 | }, 242 | 243 | /** 244 | * Create elements to link setting value properties with corresponding inputs in the form. 245 | * 246 | * @returns {void} 247 | */ 248 | linkPropertyElements: function linkPropertyElements() { 249 | var form = this, initialInstanceData; 250 | initialInstanceData = form.getValue(); 251 | form.syncedProperties = {}; 252 | form.container.find( ':input[data-field]' ).each( function() { 253 | var input = $( this ), field = input.data( 'field' ), syncedProperty; 254 | if ( _.isUndefined( initialInstanceData[ field ] ) ) { 255 | return; 256 | } 257 | 258 | syncedProperty = form.createSyncedPropertyValue( form.model, field ); 259 | syncedProperty.element = new api.Element( input ); 260 | syncedProperty.element.set( initialInstanceData[ field ] ); 261 | syncedProperty.element.sync( syncedProperty.value ); 262 | form.syncedProperties[ field ] = syncedProperty; 263 | } ); 264 | }, 265 | 266 | /** 267 | * Unlink setting value properties with corresponding inputs in the form. 268 | * 269 | * @returns {void} 270 | */ 271 | unlinkPropertyElements: function unlinkPropertyElements() { 272 | var form = this; 273 | _.each( form.syncedProperties, function( syncedProperty ) { 274 | syncedProperty.element.unsync( syncedProperty.value ); 275 | form.model.unbind( syncedProperty.rootChangeListener ); 276 | syncedProperty.value.callbacks.remove(); 277 | } ); 278 | form.syncedProperties = {}; 279 | }, 280 | 281 | /** 282 | * Get template function. 283 | * 284 | * @returns {Function} Template function. 285 | */ 286 | getTemplate: function getTemplate() { 287 | var form = this; 288 | if ( ! form._template ) { 289 | if ( ! $( '#tmpl-' + form.config.form_template_id ).is( 'script[type="text/template"]' ) ) { 290 | throw new Error( 'Missing script[type="text/template"]#' + form.config.form_template_id + ' script for widget form.' ); 291 | } 292 | form._template = wp.template( form.config.form_template_id ); 293 | } 294 | return form._template; 295 | }, 296 | 297 | /** 298 | * Embed. 299 | * 300 | * @deprecated 301 | * @returns {void} 302 | */ 303 | embed: function embed() { 304 | if ( 'undefined' !== typeof console ) { 305 | console.warn( 'wp.widgets.Form#embed is deprecated.' ); 306 | } 307 | this.render(); 308 | }, 309 | 310 | /** 311 | * Render (mount) the form into the container. 312 | * 313 | * @returns {void} 314 | */ 315 | render: function render() { 316 | var form = this, template = form.getTemplate(); 317 | form.container.html( template( form ) ); 318 | form.linkPropertyElements(); 319 | form.notifications.bind( 'add', form.renderNotifications ); 320 | form.notifications.bind( 'remove', form.renderNotifications ); 321 | }, 322 | 323 | /** 324 | * Destruct (unrender/unmount) the form. 325 | * 326 | * Subclasses can do cleanup of event listeners on other components, 327 | * 328 | * @returns {void} 329 | */ 330 | destruct: function destruct() { 331 | var form = this; 332 | form.container.empty(); 333 | form.unlinkPropertyElements(); 334 | form.notifications.unbind( 'add', form.renderNotifications ); 335 | form.notifications.unbind( 'remove', form.renderNotifications ); 336 | } 337 | }); 338 | 339 | /** 340 | * Return an Array of an api.Values object's values 341 | * 342 | * @param {wp.customize.Values} values An instance of api.Values 343 | * @return {Array} An array of api.Value objects 344 | */ 345 | function getArrayFromValues( values ) { 346 | var ary = []; 347 | values.each( function( value ) { 348 | ary.push( value ); 349 | } ); 350 | return ary; 351 | } 352 | 353 | /** 354 | * Return true if the Notification is an error 355 | * 356 | * @param {wp.customize.Notification} notification An instance of api.Notification 357 | * @return {Boolean} True if the `type` of the Notification is 'error' 358 | */ 359 | function isNotificationError( notification ) { 360 | return 'error' === notification.type; 361 | } 362 | 363 | /** 364 | * Hide or show a DOM node using jQuery animation 365 | * 366 | * @param {jQuery} container The jQuery object to hide or show 367 | * @param {Boolean} showContainer True to show the node, or false to hide it 368 | * @return {Deferred} A promise that is resolved when the animation is complete 369 | */ 370 | function toggleContainer( container, showContainer ) { 371 | var deferred = $.Deferred(); 372 | if ( showContainer ) { 373 | container.stop().slideDown( 'fast', null, function() { 374 | deferred.resolve(); 375 | } ); 376 | } else { 377 | container.stop().slideUp( 'fast', null, deferred.resolve ); 378 | } 379 | return deferred; 380 | } 381 | 382 | /** 383 | * Speak a Notification using wp.a11y 384 | * 385 | * Will only speak a Notification once, so if passed a Notification that has already been spoken, this is a noop. 386 | * 387 | * @param {Notification} notification The Notification to speak 388 | * @return {void} 389 | */ 390 | function speakNotification( notification ) { 391 | if ( ! notification.hasA11ySpoken ) { 392 | 393 | // @todo In the context of the Customizer, this presently will end up getting spoken twice due to wp.customize.Control also rendering it. 394 | wp.a11y.speak( notification.message, 'assertive' ); 395 | notification.hasA11ySpoken = true; 396 | } 397 | } 398 | 399 | /** 400 | * Return the template function for rendering Notifications 401 | * 402 | * @param {Form} widgetForm The instance of the Form whose template to fetch 403 | * @return {Function} The template function 404 | */ 405 | function getNotificationsTemplate( widgetForm ) { 406 | if ( ! widgetForm._notificationsTemplate ) { 407 | widgetForm._notificationsTemplate = wp.template( widgetForm.config.notifications_template_id ); 408 | } 409 | return widgetForm._notificationsTemplate; 410 | } 411 | 412 | /** 413 | * Replace the markup of a DOM node container 414 | * 415 | * @param {jQuery} container The DOM node which will be replaced by the markup 416 | * @param {string} markup The markup to apply to the container 417 | * @return {void} 418 | */ 419 | function renderMarkupToContainer( container, markup ) { 420 | container.empty().append( $.trim( markup ) ); 421 | } 422 | 423 | /** 424 | * Removes Notification objects which have been added by `addSanitizeNotification` 425 | * 426 | * Note: this mutates the object itself! 427 | * 428 | * @param {wp.customize.Values} notifications An instance of api.Values containing Notification objects 429 | * @return {void} 430 | */ 431 | function removeSanitizeNotifications( notifications ) { 432 | notifications.each( function iterateNotifications( notification ) { 433 | if ( notification.viaWidgetFormSanitizeReturn ) { 434 | notifications.remove( notification.code ); 435 | } 436 | } ); 437 | } 438 | 439 | /** 440 | * Adds a Notification to a Form from the form's `sanitize` method 441 | * 442 | * @param {Form} widgetForm The instance of the Form to modify 443 | * @param {wp.customize.Values} notification An instance of api.Notification to add 444 | * @return {void} 445 | */ 446 | function addSanitizeNotification( widgetForm, notification ) { 447 | notification.viaWidgetFormSanitizeReturn = true; 448 | widgetForm.notifications.add( notification.code, notification ); 449 | } 450 | 451 | /** 452 | * Validate the properties of a Form 453 | * 454 | * Throws an Error if the properties are invalid. 455 | * 456 | * @param {Form} widgetForm The instance of the Form to modify 457 | * @return {void} 458 | */ 459 | function assertValidForm( widgetForm ) { 460 | if ( ! widgetForm.model || ! widgetForm.model.extended || ! widgetForm.model.extended( api.Value ) ) { 461 | throw new Error( 'Widget Form is missing model property which must be a Value or Setting instance.' ); 462 | } 463 | if ( 0 === widgetForm.container.length ) { 464 | throw new Error( 'Widget Form is missing container property as Element or jQuery.' ); 465 | } 466 | if ( ! widgetForm.config || ! widgetForm.config.default_instance ) { 467 | throw new Error( 'Widget Form class is missing config.default_instance' ); 468 | } 469 | } 470 | 471 | /** 472 | * Merges properties for a Form with the defaults 473 | * 474 | * The passed properties override the Form's config property which overrides the default values. 475 | * 476 | * @param {object} config The Form's current config property 477 | * @param {object} properties The passed-in properties to the Form 478 | * @return {object} The merged properties object 479 | */ 480 | function getValidatedFormProperties( config, properties ) { 481 | var defaultConfig = { 482 | form_template_id: '', 483 | notifications_template_id: '', 484 | l10n: {}, 485 | default_instance: {} 486 | }; 487 | 488 | var defaultProperties = { 489 | model: null, 490 | container: null, 491 | config: {} 492 | }; 493 | 494 | var formArguments = properties ? { model: properties.model, container: properties.container } : {}; 495 | var validProperties = _.extend( {}, defaultProperties, formArguments ); 496 | validProperties.config = _.extend( {}, defaultConfig, config ); 497 | return validProperties; 498 | } 499 | 500 | } )( wp.customize, jQuery, _ ); 501 | 502 | if ( 'undefined' !== typeof module ) { 503 | module.exports = wp.widgets.Form; 504 | } 505 | -------------------------------------------------------------------------------- /php/class-js-widgets-rest-controller.php: -------------------------------------------------------------------------------- 1 | plugin = $plugin; 37 | $this->namespace = $plugin->rest_api_namespace; 38 | $this->widget = $widget; 39 | $this->rest_base = $widget->id_base; 40 | } 41 | 42 | /** 43 | * Get namespace. 44 | * 45 | * @return string 46 | */ 47 | public function get_namespace() { 48 | return $this->namespace; 49 | } 50 | 51 | /** 52 | * Get REST Base. 53 | * 54 | * @return string 55 | */ 56 | public function get_rest_base() { 57 | return $this->rest_base; 58 | } 59 | 60 | /** 61 | * Get the object type for the REST resource. 62 | * 63 | * @return string 64 | */ 65 | protected function get_object_type() { 66 | return $this->widget->id_base . '-widget'; 67 | } 68 | 69 | /** 70 | * Get a widget object (resource) ID. 71 | * 72 | * This simple re-uses a widget number as a widget ID, which will only be unique 73 | * among the widgets of a given type. Eventually this ID should map to the post ID 74 | * for a given widget_instance post type so that it is truly unique across all 75 | * widget types in a site. 76 | * 77 | * @param int $widget_number Widget number. 78 | * @return int Widget object ID. 79 | */ 80 | protected function get_object_id( $widget_number ) { 81 | $widget_id = intval( $widget_number ); 82 | return $widget_id; 83 | } 84 | 85 | /** 86 | * Get the item's schema, conforming to JSON Schema. 87 | * 88 | * @return array 89 | */ 90 | public function get_item_schema() { 91 | $item_schema = array( 92 | '$schema' => 'http://json-schema.org/draft-04/schema#', 93 | 'title' => $this->get_object_type(), 94 | 'type' => 'object', 95 | 'properties' => array( 96 | 'id' => array( 97 | 'description' => __( 'Widget ID. This will only be unique among widgets of a given type until widgets are stored as posts. See WP Trac #35669.', 'js-widgets' ), 98 | 'type' => 'integer', 99 | 'context' => array( 'view', 'edit', 'embed' ), 100 | 'readonly' => true, 101 | ), 102 | 'type' => array( 103 | 'description' => __( 'Object type.', 'js-widgets' ), 104 | 'type' => 'string', 105 | 'context' => array( 'view', 'edit', 'embed' ), 106 | 'readonly' => true, 107 | ), 108 | ), 109 | ); 110 | 111 | $reserved_field_ids = array( 'id', 'type', '_links', '_embedded' ); 112 | 113 | foreach ( $this->widget->get_item_schema() as $field_id => $field_schema ) { 114 | 115 | // Prevent clobbering reserved fields. 116 | if ( in_array( $field_id, $reserved_field_ids, true ) ) { 117 | /* translators: %s is field ID */ 118 | _doing_it_wrong( get_class( $this->widget ) . '::get_item_schema', sprintf( __( 'The field "%s" is reserved.', 'js-widgets' ), esc_html( $field_id ) ), '' ); // WPCS: xss ok. 119 | continue; 120 | } 121 | 122 | // By default, widget properties are private and only available in an edit context. 123 | if ( ! isset( $field_schema['context'] ) ) { 124 | $field_schema['context'] = array( 'edit' ); 125 | } 126 | 127 | $item_schema['properties'][ $field_id ] = $field_schema; 128 | } 129 | 130 | $item_schema = $this->add_additional_fields_schema( $item_schema ); 131 | 132 | // Expose root-level required properties according to JSON Schema. 133 | if ( ! isset( $item_schema['required'] ) ) { 134 | $item_schema['required'] = array(); 135 | } 136 | foreach ( $item_schema['properties'] as $field_id => $field_schema ) { 137 | if ( ! empty( $field_schema['required'] ) ) { 138 | $item_schema['required'][] = $field_id; 139 | } 140 | } 141 | 142 | return $item_schema; 143 | } 144 | 145 | /** 146 | * Get base URL for API. 147 | * 148 | * @return string 149 | */ 150 | public function get_base_url() { 151 | $base = sprintf( '/%s/widgets/%s', $this->namespace, $this->rest_base ); 152 | return $base; 153 | } 154 | 155 | /** 156 | * Register routes. 157 | */ 158 | public function register_routes() { 159 | $route = '/widgets/' . $this->rest_base; 160 | 161 | register_rest_route( $this->namespace, $route, array( 162 | array( 163 | 'methods' => WP_REST_Server::READABLE, 164 | 'callback' => array( $this, 'get_items' ), 165 | 'permission_callback' => array( $this, 'get_items_permissions_check' ), 166 | 'args' => $this->get_collection_params(), 167 | ), 168 | array( 169 | 'methods' => WP_REST_Server::CREATABLE, 170 | 'callback' => array( $this, 'create_item' ), 171 | 'permission_callback' => array( $this, 'create_item_permissions_check' ), 172 | 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), 173 | ), 174 | 'schema' => array( $this, 'get_public_item_schema' ), 175 | ) ); 176 | 177 | $route = '/widgets/' . $this->rest_base . '/(?P\d+)'; 178 | register_rest_route( $this->namespace, $route, array( 179 | array( 180 | 'methods' => WP_REST_Server::READABLE, 181 | 'callback' => array( $this, 'get_item' ), 182 | 'permission_callback' => array( $this, 'get_item_permissions_check' ), 183 | 'args' => array( 184 | 'context' => $this->get_context_param( array( 185 | 'default' => 'view', 186 | ) ), 187 | ), 188 | ), 189 | array( 190 | 'methods' => WP_REST_Server::EDITABLE, 191 | 'callback' => array( $this, 'update_item' ), 192 | 'permission_callback' => array( $this, 'update_item_permissions_check' ), 193 | 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), 194 | ), 195 | array( 196 | 'methods' => WP_REST_Server::DELETABLE, 197 | 'callback' => array( $this, 'delete_item' ), 198 | 'permission_callback' => array( $this, 'delete_item_permissions_check' ), 199 | 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::DELETABLE ), 200 | ), 201 | 'schema' => array( $this, 'get_public_item_schema' ), 202 | ) ); 203 | } 204 | 205 | /** 206 | * Get the query params for collections. 207 | * 208 | * @return array 209 | */ 210 | public function get_collection_params() { 211 | $params = parent::get_collection_params(); 212 | 213 | // @todo Support these params. 214 | unset( $params['page'] ); 215 | unset( $params['per_page'] ); 216 | unset( $params['search'] ); 217 | return $params; 218 | } 219 | 220 | /** 221 | * Get an array of endpoint arguments from the item schema for the controller. 222 | * 223 | * @param string $method HTTP method of the request. The arguments 224 | * for `CREATABLE` requests are checked for required 225 | * values and may fall-back to a given default, this 226 | * is not done on `EDITABLE` requests. Default is 227 | * WP_REST_Server::CREATABLE. 228 | * @return array $endpoint_args 229 | */ 230 | public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { 231 | 232 | $schema = $this->get_item_schema(); 233 | $schema_properties = ! empty( $schema['properties'] ) ? $schema['properties'] : array(); 234 | $endpoint_args = array(); 235 | $is_create_or_edit = ( WP_REST_Server::EDITABLE === $method || WP_REST_Server::CREATABLE === $method ); 236 | 237 | foreach ( $schema_properties as $field_id => $params ) { 238 | 239 | // Arguments specified as `readonly` are not allowed to be set. 240 | if ( ! empty( $params['readonly'] ) ) { 241 | continue; 242 | } 243 | 244 | $endpoint_args[ $field_id ] = array( 245 | 'validate_callback' => array( $this, 'rest_validate_request_arg' ), 246 | 'sanitize_callback' => 'rest_sanitize_request_arg', 247 | ); 248 | 249 | if ( isset( $params['description'] ) ) { 250 | $endpoint_args[ $field_id ]['description'] = $params['description']; 251 | } 252 | 253 | if ( $is_create_or_edit && isset( $params['default'] ) ) { 254 | $endpoint_args[ $field_id ]['default'] = $params['default']; 255 | } 256 | 257 | if ( $is_create_or_edit && ! empty( $params['required'] ) ) { 258 | $endpoint_args[ $field_id ]['required'] = true; 259 | } 260 | 261 | foreach ( array( 'type', 'format', 'enum', 'properties' ) as $schema_prop ) { // @todo Should this not be including everything? 262 | if ( isset( $params[ $schema_prop ] ) ) { 263 | $endpoint_args[ $field_id ][ $schema_prop ] = $params[ $schema_prop ]; 264 | } 265 | } 266 | 267 | // Merge in any options provided by the schema property. 268 | if ( isset( $params['arg_options'] ) ) { 269 | 270 | // Only use required / default from arg_options on CREATABLE/EDITABLE endpoints. 271 | if ( ! $is_create_or_edit ) { 272 | $params['arg_options'] = array_diff_key( $params['arg_options'], array( 273 | 'required' => '', 274 | 'default' => '', 275 | ) ); 276 | } 277 | 278 | $endpoint_args[ $field_id ] = array_merge( $endpoint_args[ $field_id ], $params['arg_options'] ); 279 | } 280 | } // End foreach(). 281 | 282 | return $endpoint_args; 283 | } 284 | 285 | 286 | /** 287 | * Validate a request argument based on details registered to the route. 288 | * 289 | * This is a replacement for `rest_validate_request_arg()` to take advantage of `WP_JS_Widget::rest_validate_value_from_schema()` 290 | * 291 | * @param mixed $value Value. 292 | * @param WP_REST_Request $request Request. 293 | * @param string $param Param name. 294 | * @return WP_Error|true Error on fail; true on success. 295 | */ 296 | public function rest_validate_request_arg( $value, $request, $param ) { 297 | $attributes = $request->get_attributes(); 298 | if ( ! isset( $attributes['args'][ $param ] ) || ! is_array( $attributes['args'][ $param ] ) ) { 299 | return true; 300 | } 301 | $args = $attributes['args'][ $param ]; 302 | 303 | return $this->rest_validate_value_from_schema( $value, $args, $param ); 304 | } 305 | 306 | /** 307 | * Validate a value based on a schema, with augmented support for type arrays and object types. 308 | * 309 | * @link https://core.trac.wordpress.org/ticket/38583 310 | * 311 | * @param mixed $value The value to validate. 312 | * @param array $args Schema array to use for validation. 313 | * @param string $param The parameter name, used in error messages. 314 | * @return true|WP_Error 315 | */ 316 | protected function rest_validate_value_from_schema( $value, $args, $param ) { 317 | 318 | if ( ! isset( $args['type'] ) ) { 319 | return true; 320 | } 321 | $validity = rest_validate_value_from_schema( $value, $args, $param ); 322 | if ( is_wp_error( $validity ) ) { 323 | return $validity; 324 | } 325 | 326 | // Implement validation for multi-type arrays. 327 | if ( is_array( $args['type'] ) ) { 328 | $has_valid_type = false; 329 | $errors = array(); 330 | foreach ( $args['type'] as $type ) { 331 | $validity = $this->rest_validate_value_from_schema( $value, array_merge( $args, compact( 'type' ) ), $param ); 332 | if ( ! is_wp_error( $validity ) ) { 333 | $has_valid_type = true; 334 | break; 335 | } else { 336 | $errors[] = $validity; 337 | } 338 | } 339 | if ( ! $has_valid_type ) { 340 | /* translators: 1 is param name, 2 is param types */ 341 | $error_messages = array( sprintf( __( 'Expected %1$s param to be of one types: %2$s', 'js-widgets' ), $param, join( ', ', $args['type'] ) ) ); 342 | foreach ( $errors as $sub_error ) { 343 | foreach ( $sub_error->get_error_messages( 'rest_invalid_param' ) as $error_message ) { 344 | $error_messages[] = $error_message; 345 | } 346 | } 347 | return new WP_Error( 'rest_invalid_param', join( '; ', $error_messages ) ); 348 | } 349 | return true; 350 | } 351 | 352 | // Validate object types. 353 | if ( 'object' === $args['type'] ) { 354 | if ( ! is_array( $value ) ) { 355 | /* translators: %s is the type of the value */ 356 | return new WP_Error( 'rest_invalid_param', sprintf( __( 'Expected object but got %s.', 'js-widgets' ), gettype( $value ) ) ); 357 | } 358 | if ( ! empty( $value ) && wp_is_numeric_array( $value ) ) { 359 | return new WP_Error( 'rest_invalid_param', __( 'Expected object but got positional array.', 'js-widgets' ) ); 360 | } 361 | 362 | foreach ( $value as $sub_key => $sub_value ) { 363 | if ( ! isset( $args['properties'][ $sub_key ] ) ) { 364 | continue; 365 | } 366 | $validity = $this->rest_validate_value_from_schema( $sub_value, $args['properties'][ $sub_key ], "$param.$sub_key" ); 367 | if ( is_wp_error( $validity ) ) { 368 | return $validity; 369 | } 370 | } 371 | } 372 | return true; 373 | } 374 | 375 | /** 376 | * Return whether the current user can manage widgets. 377 | * 378 | * @return bool 379 | */ 380 | public function current_user_can_manage_widgets() { 381 | return current_user_can( 'edit_theme_options' ) || current_user_can( 'manage_widgets' ); 382 | } 383 | 384 | /** 385 | * Check if a given request has access to get a specific item. 386 | * 387 | * @param WP_REST_Request $request Full details about the request. 388 | * @return bool 389 | */ 390 | public function get_item_permissions_check( $request ) { 391 | 392 | // @todo Check if the widget is registered to a sidebar. If not, and if the context is not edit and user can't manage widgets, return forbidden. 393 | return $this->get_items_permissions_check( $request ); 394 | } 395 | 396 | /** 397 | * Check if a given request has access to get items. 398 | * 399 | * @param WP_REST_Request $request Full details about the request. 400 | * @return WP_Error|boolean 401 | */ 402 | public function get_items_permissions_check( $request ) { 403 | 404 | if ( 'edit' === $request['context'] && ! $this->current_user_can_manage_widgets() ) { 405 | return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit widgets.', 'js-widgets' ), array( 406 | 'status' => rest_authorization_required_code(), 407 | ) ); 408 | } 409 | 410 | return true; 411 | } 412 | 413 | /** 414 | * Check if a given request has access to create items. 415 | * 416 | * @param WP_REST_Request $request Full details about the request. 417 | * @return WP_Error|boolean 418 | */ 419 | public function create_item_permissions_check( $request ) { 420 | unset( $request ); 421 | return $this->current_user_can_manage_widgets(); 422 | } 423 | 424 | /** 425 | * Check if a given request has access to update a specific item. 426 | * 427 | * @param WP_REST_Request $request Full details about the request. 428 | * @return WP_Error|boolean 429 | */ 430 | public function update_item_permissions_check( $request ) { 431 | unset( $request ); 432 | return $this->current_user_can_manage_widgets(); 433 | } 434 | 435 | /** 436 | * Check if a given request has access to delete a specific item. 437 | * 438 | * @param WP_REST_Request $request Full details about the request. 439 | * @return WP_Error|boolean 440 | */ 441 | public function delete_item_permissions_check( $request ) { 442 | unset( $request ); 443 | return $this->current_user_can_manage_widgets(); 444 | } 445 | 446 | /** 447 | * Get widget instance for REST request. 448 | * 449 | * @param WP_REST_Request $request Request. 450 | * @return WP_Error|WP_REST_Response Response. 451 | */ 452 | public function get_item( $request ) { 453 | $instances = $this->widget->get_settings(); 454 | if ( ! array_key_exists( $request['widget_number'], $instances ) ) { 455 | return new WP_Error( 'rest_widget_invalid_number', __( 'Unknown widget.', 'js-widgets' ), array( 456 | 'status' => 404, 457 | ) ); 458 | } 459 | 460 | $instance = $instances[ $request['widget_number'] ]; 461 | $data = $this->prepare_item_for_response( $instance, $request, $request['widget_number'] ); 462 | $response = rest_ensure_response( $data ); 463 | return $response; 464 | } 465 | 466 | /** 467 | * Update one item from the collection. 468 | * 469 | * @param WP_REST_Request $request Full data about the request. 470 | * 471 | * @return WP_Error|WP_REST_Response Response. 472 | */ 473 | public function update_item( $request ) { 474 | $instances = $this->widget->get_settings(); 475 | if ( ! array_key_exists( $request['widget_number'], $instances ) ) { 476 | return new WP_Error( 'rest_widget_invalid_number', __( 'Unknown widget.', 'js-widgets' ), array( 477 | 'status' => 404, 478 | ) ); 479 | } 480 | 481 | $old_instance = $instances[ $request['widget_number'] ]; 482 | $expected_id = $this->get_object_id( $request['widget_number'] ); 483 | if ( ! empty( $request['id'] ) && $expected_id !== $request['id'] ) { 484 | return new WP_Error( 'rest_widget_unexpected_id', __( 'Widget ID mismatch.', 'js-widgets' ), array( 485 | 'status' => 400, 486 | ) ); 487 | } 488 | if ( ! empty( $request['type'] ) && $this->get_object_type() !== $request['type'] ) { 489 | return new WP_Error( 'rest_widget_unexpected_type', __( 'Widget type mismatch.', 'js-widgets' ), array( 490 | 'status' => 400, 491 | ) ); 492 | } 493 | 494 | // Note that $new_instance has gone through the validate and sanitize callbacks defined on the instance schema. 495 | $new_instance = $this->widget->prepare_item_for_database( $request ); 496 | $new_instance = array_merge( $old_instance, $new_instance ); // Allow instances to be patched. 497 | $instance = $this->widget->sanitize( $new_instance, $old_instance ); 498 | 499 | if ( is_wp_error( $instance ) ) { 500 | return $instance; 501 | } 502 | if ( ! is_array( $instance ) ) { 503 | return new WP_Error( 'rest_widget_sanitize_failed', __( 'Sanitization failed.', 'js-widgets' ), array( 504 | 'status' => 400, 505 | ) ); 506 | } 507 | 508 | $instances[ $request['widget_number'] ] = $instance; 509 | $this->widget->save_settings( $instances ); 510 | 511 | $request->set_param( 'context', 'edit' ); 512 | $data = $this->prepare_item_for_response( $instance, $request, $request['widget_number'] ); 513 | $response = rest_ensure_response( $data ); 514 | return $response; 515 | } 516 | 517 | /** 518 | * Get a collection of items. 519 | * 520 | * @param WP_REST_Request $request Full data about the request. 521 | * 522 | * @todo Add get_collection_params() to be able to paginate and search. 523 | * 524 | * @return WP_Error|WP_REST_Response Response. 525 | */ 526 | public function get_items( $request ) { 527 | $instances = array(); 528 | foreach ( $this->widget->get_settings() as $widget_number => $instance ) { 529 | 530 | // @todo Skip if the instance is not assigned to any sidebars and the context is not edit and the user lacks permission. 531 | $data = $this->prepare_item_for_response( $instance, $request, $widget_number ); 532 | $instances[] = $this->prepare_response_for_collection( $data ); 533 | } 534 | return new WP_REST_Response( $instances ); 535 | } 536 | 537 | /** 538 | * Prepare a single widget instance for response. 539 | * 540 | * @param array $instance Instance data. 541 | * @param WP_REST_Request $request Request object. 542 | * @param int $widget_number Request object. 543 | * @return WP_REST_Response|WP_Error Data or error. 544 | */ 545 | public function prepare_item_for_response( $instance, $request, $widget_number = null ) { 546 | if ( empty( $widget_number ) ) { 547 | $widget_number = $request['widget_number']; 548 | } 549 | if ( empty( $widget_number ) ) { 550 | return new WP_Error( 'rest_widget_unavailable_widget_number', __( 'Unknown widget number.', 'js-widgets' ), array( 551 | 'status' => 500, 552 | ) ); 553 | } 554 | 555 | // Just in case. 556 | unset( $instance['id'] ); 557 | unset( $instance['type'] ); 558 | 559 | $widget_id = $this->get_object_id( $widget_number ); 560 | $data = array_merge( 561 | array( 562 | 'id' => $widget_id, 563 | 'type' => $this->get_object_type(), 564 | ), 565 | $this->widget->prepare_item_for_response( $instance, $request ) 566 | ); 567 | 568 | $data = $this->add_additional_fields_to_object( $data, $request ); 569 | $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; 570 | $data = $this->filter_response_by_context( $data, $context ); 571 | 572 | // Wrap the data in a response object. 573 | $response = rest_ensure_response( $data ); 574 | if ( $response instanceof WP_REST_Response ) { 575 | $response->add_links( $this->prepare_links( $response, $request ) ); 576 | } 577 | 578 | return $response; 579 | } 580 | 581 | /** 582 | * Prepare links for the request. 583 | * 584 | * @param WP_REST_Response $response Response. 585 | * @param WP_REST_Request $request Request. 586 | * @return array Links for the given post. 587 | */ 588 | protected function prepare_links( $response, $request ) { 589 | $base = $this->get_base_url(); 590 | $links = array_merge( 591 | $this->widget->get_rest_response_links( $response, $request, $this ), 592 | array( 593 | 'self' => array( 594 | 'href' => rest_url( trailingslashit( $base ) . $response->data['id'] ), 595 | ), 596 | 'collection' => array( 597 | 'href' => rest_url( $base ), 598 | ), 599 | ) 600 | ); 601 | return $links; 602 | } 603 | } 604 | --------------------------------------------------------------------------------