diff --git a/.gitignore b/.gitignore index 216de23..27b9982 100644 --- a/.gitignore +++ b/.gitignore @@ -12,14 +12,3 @@ composer.lock # Compiled files *.min.js *.min.css -*.compiled.js -*-browserified.js - -/bower_components/react/* -!/bower_components/react/react-dom.js -!/bower_components/react/react-dom.min.js -/bower_components/react/* -!/bower_components/react/react.js -!/bower_components/react/react.min.js -/bower_components/redux/* -!/bower_components/redux/index.js diff --git a/Gruntfile.js b/Gruntfile.js index d596c2e..10e0b1c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -7,51 +7,6 @@ module.exports = function( grunt ) { pkg: grunt.file.readJSON( 'package.json' ), - browserify: { - - // http://stackoverflow.com/questions/34372877/how-to-bundle-multiple-javascript-libraries-with-browserify - options: { - browserifyOptions: { - debug: true - }, - transform: [ - [ 'babelify' ], - [ 'browserify-shim' ] - ], - external: [ - 'react', - 'react-dom' - ], - banner: '/* THIS FILE IS GENERATED FROM BROWSERIFY. DO NOT EDIT DIRECTLY. */' - }, - recent_posts_form: { - options: { - browserifyOptions: { - standalone: 'RecentPostsWidgetFormReactComponent' - } - }, - files: { - './js/widgets/recent-posts-widget-form-react-component-browserified.js': './js/widgets/recent-posts-widget-form-react-component.jsx' - } - }, - recent_posts_widget: { - options: { - browserifyOptions: { - standalone: 'RecentPostsWidgetFrontendReactComponent' - } - }, - files: { - './js/widgets/recent-posts-widget-frontend-react-component-browserified.js': './js/widgets/recent-posts-widget-frontend-react-component.jsx' - } - } - }, - watch: { - browserify: { - files: [ './js/**/*.jsx' ], - tasks: [ 'browserify' ] - } - }, - // JavaScript linting with JSHint. jshint: { options: { @@ -64,56 +19,17 @@ module.exports = function( grunt ) { ] }, - // Minify .js files. - uglify: { - options: { - preserveComments: false - }, - core: { - files: [ { - expand: true, - cwd: 'js/', - src: [ - '*.js', - 'widgets/*.js', - '!*.min.js' - ], - dest: 'js/', - ext: '.min.js' - } ] - } - }, - - // Minify .css files. - cssmin: { - core: { - files: [ { - expand: true, - cwd: 'css/', - src: [ - '*.css', - '!*.min.css' - ], - dest: 'css/', - ext: '.min.css' - } ] - } - }, - // Build a deploy-able plugin copy: { build: { src: [ '*.php', 'css/*', - 'js/**', - 'php/**', - 'readme.txt', - 'bower_components/react/react-dom.js', - 'bower_components/react/react-dom.min.js', - 'bower_components/react/react.js', - 'bower_components/react/react.min.js', - 'bower_components/redux/index.js' + 'js/*', + 'php/*', + 'core-adapter-widgets/**', + 'post-collection-widget/**', + 'readme.txt' ], dest: 'build', expand: true, @@ -167,12 +83,9 @@ module.exports = function( grunt ) { // Load tasks grunt.loadNpmTasks( 'grunt-contrib-clean' ); grunt.loadNpmTasks( 'grunt-contrib-copy' ); - grunt.loadNpmTasks( 'grunt-contrib-cssmin' ); grunt.loadNpmTasks( 'grunt-contrib-jshint' ); - grunt.loadNpmTasks( 'grunt-contrib-uglify' ); grunt.loadNpmTasks( 'grunt-shell' ); grunt.loadNpmTasks( 'grunt-wp-deploy' ); - grunt.loadNpmTasks( 'grunt-browserify' ); grunt.loadNpmTasks( 'grunt-contrib-watch' ); // Register tasks @@ -199,9 +112,6 @@ module.exports = function( grunt ) { grunt.registerTask( 'build', [ 'jshint', - 'browserify', - 'uglify', - 'cssmin', 'readme', 'copy' ] ); diff --git a/bower.json b/bower.json deleted file mode 100644 index 8df88e4..0000000 --- a/bower.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "js-widgets", - "private": true, - "ignore": [ - "**/.*", - "node_modules", - "bower_components", - "test", - "tests" - ], - "dependencies": { - "react": "~15.0.2", - "redux": "https://npmcdn.com/redux/dist/redux.js" - } -} diff --git a/bower_components/react/react.js b/bower_components/react/react.js deleted file mode 100644 index 499694d..0000000 --- a/bower_components/react/react.js +++ /dev/null @@ -1,19423 +0,0 @@ - /** - * React v15.0.2 - */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.React = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 8 && documentMode <= 11); - -/** - * Opera <= 12 includes TextEvent in window, but does not fire - * text input events. Rely on keypress instead. - */ -function isPresto() { - var opera = window.opera; - return typeof opera === 'object' && typeof opera.version === 'function' && parseInt(opera.version(), 10) <= 12; -} - -var SPACEBAR_CODE = 32; -var SPACEBAR_CHAR = String.fromCharCode(SPACEBAR_CODE); - -var topLevelTypes = EventConstants.topLevelTypes; - -// Events and their corresponding property names. -var eventTypes = { - beforeInput: { - phasedRegistrationNames: { - bubbled: keyOf({ onBeforeInput: null }), - captured: keyOf({ onBeforeInputCapture: null }) - }, - dependencies: [topLevelTypes.topCompositionEnd, topLevelTypes.topKeyPress, topLevelTypes.topTextInput, topLevelTypes.topPaste] - }, - compositionEnd: { - phasedRegistrationNames: { - bubbled: keyOf({ onCompositionEnd: null }), - captured: keyOf({ onCompositionEndCapture: null }) - }, - dependencies: [topLevelTypes.topBlur, topLevelTypes.topCompositionEnd, topLevelTypes.topKeyDown, topLevelTypes.topKeyPress, topLevelTypes.topKeyUp, topLevelTypes.topMouseDown] - }, - compositionStart: { - phasedRegistrationNames: { - bubbled: keyOf({ onCompositionStart: null }), - captured: keyOf({ onCompositionStartCapture: null }) - }, - dependencies: [topLevelTypes.topBlur, topLevelTypes.topCompositionStart, topLevelTypes.topKeyDown, topLevelTypes.topKeyPress, topLevelTypes.topKeyUp, topLevelTypes.topMouseDown] - }, - compositionUpdate: { - phasedRegistrationNames: { - bubbled: keyOf({ onCompositionUpdate: null }), - captured: keyOf({ onCompositionUpdateCapture: null }) - }, - dependencies: [topLevelTypes.topBlur, topLevelTypes.topCompositionUpdate, topLevelTypes.topKeyDown, topLevelTypes.topKeyPress, topLevelTypes.topKeyUp, topLevelTypes.topMouseDown] - } -}; - -// Track whether we've ever handled a keypress on the space key. -var hasSpaceKeypress = false; - -/** - * Return whether a native keypress event is assumed to be a command. - * This is required because Firefox fires `keypress` events for key commands - * (cut, copy, select-all, etc.) even though no character is inserted. - */ -function isKeypressCommand(nativeEvent) { - return (nativeEvent.ctrlKey || nativeEvent.altKey || nativeEvent.metaKey) && - // ctrlKey && altKey is equivalent to AltGr, and is not a command. - !(nativeEvent.ctrlKey && nativeEvent.altKey); -} - -/** - * Translate native top level events into event types. - * - * @param {string} topLevelType - * @return {object} - */ -function getCompositionEventType(topLevelType) { - switch (topLevelType) { - case topLevelTypes.topCompositionStart: - return eventTypes.compositionStart; - case topLevelTypes.topCompositionEnd: - return eventTypes.compositionEnd; - case topLevelTypes.topCompositionUpdate: - return eventTypes.compositionUpdate; - } -} - -/** - * Does our fallback best-guess model think this event signifies that - * composition has begun? - * - * @param {string} topLevelType - * @param {object} nativeEvent - * @return {boolean} - */ -function isFallbackCompositionStart(topLevelType, nativeEvent) { - return topLevelType === topLevelTypes.topKeyDown && nativeEvent.keyCode === START_KEYCODE; -} - -/** - * Does our fallback mode think that this event is the end of composition? - * - * @param {string} topLevelType - * @param {object} nativeEvent - * @return {boolean} - */ -function isFallbackCompositionEnd(topLevelType, nativeEvent) { - switch (topLevelType) { - case topLevelTypes.topKeyUp: - // Command keys insert or clear IME input. - return END_KEYCODES.indexOf(nativeEvent.keyCode) !== -1; - case topLevelTypes.topKeyDown: - // Expect IME keyCode on each keydown. If we get any other - // code we must have exited earlier. - return nativeEvent.keyCode !== START_KEYCODE; - case topLevelTypes.topKeyPress: - case topLevelTypes.topMouseDown: - case topLevelTypes.topBlur: - // Events are not possible without cancelling IME. - return true; - default: - return false; - } -} - -/** - * Google Input Tools provides composition data via a CustomEvent, - * with the `data` property populated in the `detail` object. If this - * is available on the event object, use it. If not, this is a plain - * composition event and we have nothing special to extract. - * - * @param {object} nativeEvent - * @return {?string} - */ -function getDataFromCustomEvent(nativeEvent) { - var detail = nativeEvent.detail; - if (typeof detail === 'object' && 'data' in detail) { - return detail.data; - } - return null; -} - -// Track the current IME composition fallback object, if any. -var currentComposition = null; - -/** - * @return {?object} A SyntheticCompositionEvent. - */ -function extractCompositionEvent(topLevelType, targetInst, nativeEvent, nativeEventTarget) { - var eventType; - var fallbackData; - - if (canUseCompositionEvent) { - eventType = getCompositionEventType(topLevelType); - } else if (!currentComposition) { - if (isFallbackCompositionStart(topLevelType, nativeEvent)) { - eventType = eventTypes.compositionStart; - } - } else if (isFallbackCompositionEnd(topLevelType, nativeEvent)) { - eventType = eventTypes.compositionEnd; - } - - if (!eventType) { - return null; - } - - if (useFallbackCompositionData) { - // The current composition is stored statically and must not be - // overwritten while composition continues. - if (!currentComposition && eventType === eventTypes.compositionStart) { - currentComposition = FallbackCompositionState.getPooled(nativeEventTarget); - } else if (eventType === eventTypes.compositionEnd) { - if (currentComposition) { - fallbackData = currentComposition.getData(); - } - } - } - - var event = SyntheticCompositionEvent.getPooled(eventType, targetInst, nativeEvent, nativeEventTarget); - - if (fallbackData) { - // Inject data generated from fallback path into the synthetic event. - // This matches the property of native CompositionEventInterface. - event.data = fallbackData; - } else { - var customData = getDataFromCustomEvent(nativeEvent); - if (customData !== null) { - event.data = customData; - } - } - - EventPropagators.accumulateTwoPhaseDispatches(event); - return event; -} - -/** - * @param {string} topLevelType Record from `EventConstants`. - * @param {object} nativeEvent Native browser event. - * @return {?string} The string corresponding to this `beforeInput` event. - */ -function getNativeBeforeInputChars(topLevelType, nativeEvent) { - switch (topLevelType) { - case topLevelTypes.topCompositionEnd: - return getDataFromCustomEvent(nativeEvent); - case topLevelTypes.topKeyPress: - /** - * If native `textInput` events are available, our goal is to make - * use of them. However, there is a special case: the spacebar key. - * In Webkit, preventing default on a spacebar `textInput` event - * cancels character insertion, but it *also* causes the browser - * to fall back to its default spacebar behavior of scrolling the - * page. - * - * Tracking at: - * https://code.google.com/p/chromium/issues/detail?id=355103 - * - * To avoid this issue, use the keypress event as if no `textInput` - * event is available. - */ - var which = nativeEvent.which; - if (which !== SPACEBAR_CODE) { - return null; - } - - hasSpaceKeypress = true; - return SPACEBAR_CHAR; - - case topLevelTypes.topTextInput: - // Record the characters to be added to the DOM. - var chars = nativeEvent.data; - - // If it's a spacebar character, assume that we have already handled - // it at the keypress level and bail immediately. Android Chrome - // doesn't give us keycodes, so we need to blacklist it. - if (chars === SPACEBAR_CHAR && hasSpaceKeypress) { - return null; - } - - return chars; - - default: - // For other native event types, do nothing. - return null; - } -} - -/** - * For browsers that do not provide the `textInput` event, extract the - * appropriate string to use for SyntheticInputEvent. - * - * @param {string} topLevelType Record from `EventConstants`. - * @param {object} nativeEvent Native browser event. - * @return {?string} The fallback string for this `beforeInput` event. - */ -function getFallbackBeforeInputChars(topLevelType, nativeEvent) { - // If we are currently composing (IME) and using a fallback to do so, - // try to extract the composed characters from the fallback object. - if (currentComposition) { - if (topLevelType === topLevelTypes.topCompositionEnd || isFallbackCompositionEnd(topLevelType, nativeEvent)) { - var chars = currentComposition.getData(); - FallbackCompositionState.release(currentComposition); - currentComposition = null; - return chars; - } - return null; - } - - switch (topLevelType) { - case topLevelTypes.topPaste: - // If a paste event occurs after a keypress, throw out the input - // chars. Paste events should not lead to BeforeInput events. - return null; - case topLevelTypes.topKeyPress: - /** - * As of v27, Firefox may fire keypress events even when no character - * will be inserted. A few possibilities: - * - * - `which` is `0`. Arrow keys, Esc key, etc. - * - * - `which` is the pressed key code, but no char is available. - * Ex: 'AltGr + d` in Polish. There is no modified character for - * this key combination and no character is inserted into the - * document, but FF fires the keypress for char code `100` anyway. - * No `input` event will occur. - * - * - `which` is the pressed key code, but a command combination is - * being used. Ex: `Cmd+C`. No character is inserted, and no - * `input` event will occur. - */ - if (nativeEvent.which && !isKeypressCommand(nativeEvent)) { - return String.fromCharCode(nativeEvent.which); - } - return null; - case topLevelTypes.topCompositionEnd: - return useFallbackCompositionData ? null : nativeEvent.data; - default: - return null; - } -} - -/** - * Extract a SyntheticInputEvent for `beforeInput`, based on either native - * `textInput` or fallback behavior. - * - * @return {?object} A SyntheticInputEvent. - */ -function extractBeforeInputEvent(topLevelType, targetInst, nativeEvent, nativeEventTarget) { - var chars; - - if (canUseTextInputEvent) { - chars = getNativeBeforeInputChars(topLevelType, nativeEvent); - } else { - chars = getFallbackBeforeInputChars(topLevelType, nativeEvent); - } - - // If no characters are being inserted, no BeforeInput event should - // be fired. - if (!chars) { - return null; - } - - var event = SyntheticInputEvent.getPooled(eventTypes.beforeInput, targetInst, nativeEvent, nativeEventTarget); - - event.data = chars; - EventPropagators.accumulateTwoPhaseDispatches(event); - return event; -} - -/** - * Create an `onBeforeInput` event to match - * http://www.w3.org/TR/2013/WD-DOM-Level-3-Events-20131105/#events-inputevents. - * - * This event plugin is based on the native `textInput` event - * available in Chrome, Safari, Opera, and IE. This event fires after - * `onKeyPress` and `onCompositionEnd`, but before `onInput`. - * - * `beforeInput` is spec'd but not implemented in any browsers, and - * the `input` event does not provide any useful information about what has - * actually been added, contrary to the spec. Thus, `textInput` is the best - * available event to identify the characters that have actually been inserted - * into the target node. - * - * This plugin is also responsible for emitting `composition` events, thus - * allowing us to share composition fallback code for both `beforeInput` and - * `composition` event types. - */ -var BeforeInputEventPlugin = { - - eventTypes: eventTypes, - - extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget) { - return [extractCompositionEvent(topLevelType, targetInst, nativeEvent, nativeEventTarget), extractBeforeInputEvent(topLevelType, targetInst, nativeEvent, nativeEventTarget)]; - } -}; - -module.exports = BeforeInputEventPlugin; -},{"101":101,"105":105,"144":144,"16":16,"162":162,"20":20,"21":21}],3:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule CSSProperty - */ - -'use strict'; - -/** - * CSS properties which accept numbers but are not in units of "px". - */ - -var isUnitlessNumber = { - animationIterationCount: true, - borderImageOutset: true, - borderImageSlice: true, - borderImageWidth: true, - boxFlex: true, - boxFlexGroup: true, - boxOrdinalGroup: true, - columnCount: true, - flex: true, - flexGrow: true, - flexPositive: true, - flexShrink: true, - flexNegative: true, - flexOrder: true, - gridRow: true, - gridColumn: true, - fontWeight: true, - lineClamp: true, - lineHeight: true, - opacity: true, - order: true, - orphans: true, - tabSize: true, - widows: true, - zIndex: true, - zoom: true, - - // SVG-related properties - fillOpacity: true, - floodOpacity: true, - stopOpacity: true, - strokeDasharray: true, - strokeDashoffset: true, - strokeMiterlimit: true, - strokeOpacity: true, - strokeWidth: true -}; - -/** - * @param {string} prefix vendor-specific prefix, eg: Webkit - * @param {string} key style name, eg: transitionDuration - * @return {string} style name prefixed with `prefix`, properly camelCased, eg: - * WebkitTransitionDuration - */ -function prefixKey(prefix, key) { - return prefix + key.charAt(0).toUpperCase() + key.substring(1); -} - -/** - * Support style names that may come passed in prefixed by adding permutations - * of vendor prefixes. - */ -var prefixes = ['Webkit', 'ms', 'Moz', 'O']; - -// Using Object.keys here, or else the vanilla for-in loop makes IE8 go into an -// infinite loop, because it iterates over the newly added props too. -Object.keys(isUnitlessNumber).forEach(function (prop) { - prefixes.forEach(function (prefix) { - isUnitlessNumber[prefixKey(prefix, prop)] = isUnitlessNumber[prop]; - }); -}); - -/** - * Most style properties can be unset by doing .style[prop] = '' but IE8 - * doesn't like doing that with shorthand properties so for the properties that - * IE8 breaks on, which are listed here, we instead unset each of the - * individual properties. See http://bugs.jquery.com/ticket/12385. - * The 4-value 'clock' properties like margin, padding, border-width seem to - * behave without any problems. Curiously, list-style works too without any - * special prodding. - */ -var shorthandPropertyExpansions = { - background: { - backgroundAttachment: true, - backgroundColor: true, - backgroundImage: true, - backgroundPositionX: true, - backgroundPositionY: true, - backgroundRepeat: true - }, - backgroundPosition: { - backgroundPositionX: true, - backgroundPositionY: true - }, - border: { - borderWidth: true, - borderStyle: true, - borderColor: true - }, - borderBottom: { - borderBottomWidth: true, - borderBottomStyle: true, - borderBottomColor: true - }, - borderLeft: { - borderLeftWidth: true, - borderLeftStyle: true, - borderLeftColor: true - }, - borderRight: { - borderRightWidth: true, - borderRightStyle: true, - borderRightColor: true - }, - borderTop: { - borderTopWidth: true, - borderTopStyle: true, - borderTopColor: true - }, - font: { - fontStyle: true, - fontVariant: true, - fontWeight: true, - fontSize: true, - lineHeight: true, - fontFamily: true - }, - outline: { - outlineWidth: true, - outlineStyle: true, - outlineColor: true - } -}; - -var CSSProperty = { - isUnitlessNumber: isUnitlessNumber, - shorthandPropertyExpansions: shorthandPropertyExpansions -}; - -module.exports = CSSProperty; -},{}],4:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule CSSPropertyOperations - */ - -'use strict'; - -var CSSProperty = _dereq_(3); -var ExecutionEnvironment = _dereq_(144); -var ReactPerf = _dereq_(82); - -var camelizeStyleName = _dereq_(146); -var dangerousStyleValue = _dereq_(118); -var hyphenateStyleName = _dereq_(157); -var memoizeStringOnly = _dereq_(164); -var warning = _dereq_(168); - -var processStyleName = memoizeStringOnly(function (styleName) { - return hyphenateStyleName(styleName); -}); - -var hasShorthandPropertyBug = false; -var styleFloatAccessor = 'cssFloat'; -if (ExecutionEnvironment.canUseDOM) { - var tempStyle = document.createElement('div').style; - try { - // IE8 throws "Invalid argument." if resetting shorthand style properties. - tempStyle.font = ''; - } catch (e) { - hasShorthandPropertyBug = true; - } - // IE8 only supports accessing cssFloat (standard) as styleFloat - if (document.documentElement.style.cssFloat === undefined) { - styleFloatAccessor = 'styleFloat'; - } -} - -if ("development" !== 'production') { - // 'msTransform' is correct, but the other prefixes should be capitalized - var badVendoredStyleNamePattern = /^(?:webkit|moz|o)[A-Z]/; - - // style values shouldn't contain a semicolon - var badStyleValueWithSemicolonPattern = /;\s*$/; - - var warnedStyleNames = {}; - var warnedStyleValues = {}; - var warnedForNaNValue = false; - - var warnHyphenatedStyleName = function (name, owner) { - if (warnedStyleNames.hasOwnProperty(name) && warnedStyleNames[name]) { - return; - } - - warnedStyleNames[name] = true; - "development" !== 'production' ? warning(false, 'Unsupported style property %s. Did you mean %s?%s', name, camelizeStyleName(name), checkRenderMessage(owner)) : void 0; - }; - - var warnBadVendoredStyleName = function (name, owner) { - if (warnedStyleNames.hasOwnProperty(name) && warnedStyleNames[name]) { - return; - } - - warnedStyleNames[name] = true; - "development" !== 'production' ? warning(false, 'Unsupported vendor-prefixed style property %s. Did you mean %s?%s', name, name.charAt(0).toUpperCase() + name.slice(1), checkRenderMessage(owner)) : void 0; - }; - - var warnStyleValueWithSemicolon = function (name, value, owner) { - if (warnedStyleValues.hasOwnProperty(value) && warnedStyleValues[value]) { - return; - } - - warnedStyleValues[value] = true; - "development" !== 'production' ? warning(false, 'Style property values shouldn\'t contain a semicolon.%s ' + 'Try "%s: %s" instead.', checkRenderMessage(owner), name, value.replace(badStyleValueWithSemicolonPattern, '')) : void 0; - }; - - var warnStyleValueIsNaN = function (name, value, owner) { - if (warnedForNaNValue) { - return; - } - - warnedForNaNValue = true; - "development" !== 'production' ? warning(false, '`NaN` is an invalid value for the `%s` css style property.%s', name, checkRenderMessage(owner)) : void 0; - }; - - var checkRenderMessage = function (owner) { - if (owner) { - var name = owner.getName(); - if (name) { - return ' Check the render method of `' + name + '`.'; - } - } - return ''; - }; - - /** - * @param {string} name - * @param {*} value - * @param {ReactDOMComponent} component - */ - var warnValidStyle = function (name, value, component) { - var owner; - if (component) { - owner = component._currentElement._owner; - } - if (name.indexOf('-') > -1) { - warnHyphenatedStyleName(name, owner); - } else if (badVendoredStyleNamePattern.test(name)) { - warnBadVendoredStyleName(name, owner); - } else if (badStyleValueWithSemicolonPattern.test(value)) { - warnStyleValueWithSemicolon(name, value, owner); - } - - if (typeof value === 'number' && isNaN(value)) { - warnStyleValueIsNaN(name, value, owner); - } - }; -} - -/** - * Operations for dealing with CSS properties. - */ -var CSSPropertyOperations = { - - /** - * Serializes a mapping of style properties for use as inline styles: - * - * > createMarkupForStyles({width: '200px', height: 0}) - * "width:200px;height:0;" - * - * Undefined values are ignored so that declarative programming is easier. - * The result should be HTML-escaped before insertion into the DOM. - * - * @param {object} styles - * @param {ReactDOMComponent} component - * @return {?string} - */ - createMarkupForStyles: function (styles, component) { - var serialized = ''; - for (var styleName in styles) { - if (!styles.hasOwnProperty(styleName)) { - continue; - } - var styleValue = styles[styleName]; - if ("development" !== 'production') { - warnValidStyle(styleName, styleValue, component); - } - if (styleValue != null) { - serialized += processStyleName(styleName) + ':'; - serialized += dangerousStyleValue(styleName, styleValue, component) + ';'; - } - } - return serialized || null; - }, - - /** - * Sets the value for multiple styles on a node. If a value is specified as - * '' (empty string), the corresponding style property will be unset. - * - * @param {DOMElement} node - * @param {object} styles - * @param {ReactDOMComponent} component - */ - setValueForStyles: function (node, styles, component) { - var style = node.style; - for (var styleName in styles) { - if (!styles.hasOwnProperty(styleName)) { - continue; - } - if ("development" !== 'production') { - warnValidStyle(styleName, styles[styleName], component); - } - var styleValue = dangerousStyleValue(styleName, styles[styleName], component); - if (styleName === 'float' || styleName === 'cssFloat') { - styleName = styleFloatAccessor; - } - if (styleValue) { - style[styleName] = styleValue; - } else { - var expansion = hasShorthandPropertyBug && CSSProperty.shorthandPropertyExpansions[styleName]; - if (expansion) { - // Shorthand property that IE8 won't like unsetting, so unset each - // component to placate it - for (var individualStyleName in expansion) { - style[individualStyleName] = ''; - } - } else { - style[styleName] = ''; - } - } - } - } - -}; - -ReactPerf.measureMethods(CSSPropertyOperations, 'CSSPropertyOperations', { - setValueForStyles: 'setValueForStyles' -}); - -module.exports = CSSPropertyOperations; -},{"118":118,"144":144,"146":146,"157":157,"164":164,"168":168,"3":3,"82":82}],5:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule CallbackQueue - */ - -'use strict'; - -var _assign = _dereq_(169); - -var PooledClass = _dereq_(25); - -var invariant = _dereq_(158); - -/** - * A specialized pseudo-event module to help keep track of components waiting to - * be notified when their DOM representations are available for use. - * - * This implements `PooledClass`, so you should never need to instantiate this. - * Instead, use `CallbackQueue.getPooled()`. - * - * @class ReactMountReady - * @implements PooledClass - * @internal - */ -function CallbackQueue() { - this._callbacks = null; - this._contexts = null; -} - -_assign(CallbackQueue.prototype, { - - /** - * Enqueues a callback to be invoked when `notifyAll` is invoked. - * - * @param {function} callback Invoked when `notifyAll` is invoked. - * @param {?object} context Context to call `callback` with. - * @internal - */ - enqueue: function (callback, context) { - this._callbacks = this._callbacks || []; - this._contexts = this._contexts || []; - this._callbacks.push(callback); - this._contexts.push(context); - }, - - /** - * Invokes all enqueued callbacks and clears the queue. This is invoked after - * the DOM representation of a component has been created or updated. - * - * @internal - */ - notifyAll: function () { - var callbacks = this._callbacks; - var contexts = this._contexts; - if (callbacks) { - !(callbacks.length === contexts.length) ? "development" !== 'production' ? invariant(false, 'Mismatched list of contexts in callback queue') : invariant(false) : void 0; - this._callbacks = null; - this._contexts = null; - for (var i = 0; i < callbacks.length; i++) { - callbacks[i].call(contexts[i]); - } - callbacks.length = 0; - contexts.length = 0; - } - }, - - checkpoint: function () { - return this._callbacks ? this._callbacks.length : 0; - }, - - rollback: function (len) { - if (this._callbacks) { - this._callbacks.length = len; - this._contexts.length = len; - } - }, - - /** - * Resets the internal queue. - * - * @internal - */ - reset: function () { - this._callbacks = null; - this._contexts = null; - }, - - /** - * `PooledClass` looks for this. - */ - destructor: function () { - this.reset(); - } - -}); - -PooledClass.addPoolingTo(CallbackQueue); - -module.exports = CallbackQueue; -},{"158":158,"169":169,"25":25}],6:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule ChangeEventPlugin - */ - -'use strict'; - -var EventConstants = _dereq_(16); -var EventPluginHub = _dereq_(17); -var EventPropagators = _dereq_(20); -var ExecutionEnvironment = _dereq_(144); -var ReactDOMComponentTree = _dereq_(40); -var ReactUpdates = _dereq_(94); -var SyntheticEvent = _dereq_(103); - -var getEventTarget = _dereq_(126); -var isEventSupported = _dereq_(133); -var isTextInputElement = _dereq_(134); -var keyOf = _dereq_(162); - -var topLevelTypes = EventConstants.topLevelTypes; - -var eventTypes = { - change: { - phasedRegistrationNames: { - bubbled: keyOf({ onChange: null }), - captured: keyOf({ onChangeCapture: null }) - }, - dependencies: [topLevelTypes.topBlur, topLevelTypes.topChange, topLevelTypes.topClick, topLevelTypes.topFocus, topLevelTypes.topInput, topLevelTypes.topKeyDown, topLevelTypes.topKeyUp, topLevelTypes.topSelectionChange] - } -}; - -/** - * For IE shims - */ -var activeElement = null; -var activeElementInst = null; -var activeElementValue = null; -var activeElementValueProp = null; - -/** - * SECTION: handle `change` event - */ -function shouldUseChangeEvent(elem) { - var nodeName = elem.nodeName && elem.nodeName.toLowerCase(); - return nodeName === 'select' || nodeName === 'input' && elem.type === 'file'; -} - -var doesChangeEventBubble = false; -if (ExecutionEnvironment.canUseDOM) { - // See `handleChange` comment below - doesChangeEventBubble = isEventSupported('change') && (!('documentMode' in document) || document.documentMode > 8); -} - -function manualDispatchChangeEvent(nativeEvent) { - var event = SyntheticEvent.getPooled(eventTypes.change, activeElementInst, nativeEvent, getEventTarget(nativeEvent)); - EventPropagators.accumulateTwoPhaseDispatches(event); - - // If change and propertychange bubbled, we'd just bind to it like all the - // other events and have it go through ReactBrowserEventEmitter. Since it - // doesn't, we manually listen for the events and so we have to enqueue and - // process the abstract event manually. - // - // Batching is necessary here in order to ensure that all event handlers run - // before the next rerender (including event handlers attached to ancestor - // elements instead of directly on the input). Without this, controlled - // components don't work properly in conjunction with event bubbling because - // the component is rerendered and the value reverted before all the event - // handlers can run. See https://github.com/facebook/react/issues/708. - ReactUpdates.batchedUpdates(runEventInBatch, event); -} - -function runEventInBatch(event) { - EventPluginHub.enqueueEvents(event); - EventPluginHub.processEventQueue(false); -} - -function startWatchingForChangeEventIE8(target, targetInst) { - activeElement = target; - activeElementInst = targetInst; - activeElement.attachEvent('onchange', manualDispatchChangeEvent); -} - -function stopWatchingForChangeEventIE8() { - if (!activeElement) { - return; - } - activeElement.detachEvent('onchange', manualDispatchChangeEvent); - activeElement = null; - activeElementInst = null; -} - -function getTargetInstForChangeEvent(topLevelType, targetInst) { - if (topLevelType === topLevelTypes.topChange) { - return targetInst; - } -} -function handleEventsForChangeEventIE8(topLevelType, target, targetInst) { - if (topLevelType === topLevelTypes.topFocus) { - // stopWatching() should be a noop here but we call it just in case we - // missed a blur event somehow. - stopWatchingForChangeEventIE8(); - startWatchingForChangeEventIE8(target, targetInst); - } else if (topLevelType === topLevelTypes.topBlur) { - stopWatchingForChangeEventIE8(); - } -} - -/** - * SECTION: handle `input` event - */ -var isInputEventSupported = false; -if (ExecutionEnvironment.canUseDOM) { - // IE9 claims to support the input event but fails to trigger it when - // deleting text, so we ignore its input events. - // IE10+ fire input events to often, such when a placeholder - // changes or when an input with a placeholder is focused. - isInputEventSupported = isEventSupported('input') && (!('documentMode' in document) || document.documentMode > 11); -} - -/** - * (For IE <=11) Replacement getter/setter for the `value` property that gets - * set on the active element. - */ -var newValueProp = { - get: function () { - return activeElementValueProp.get.call(this); - }, - set: function (val) { - // Cast to a string so we can do equality checks. - activeElementValue = '' + val; - activeElementValueProp.set.call(this, val); - } -}; - -/** - * (For IE <=11) Starts tracking propertychange events on the passed-in element - * and override the value property so that we can distinguish user events from - * value changes in JS. - */ -function startWatchingForValueChange(target, targetInst) { - activeElement = target; - activeElementInst = targetInst; - activeElementValue = target.value; - activeElementValueProp = Object.getOwnPropertyDescriptor(target.constructor.prototype, 'value'); - - // Not guarded in a canDefineProperty check: IE8 supports defineProperty only - // on DOM elements - Object.defineProperty(activeElement, 'value', newValueProp); - if (activeElement.attachEvent) { - activeElement.attachEvent('onpropertychange', handlePropertyChange); - } else { - activeElement.addEventListener('propertychange', handlePropertyChange, false); - } -} - -/** - * (For IE <=11) Removes the event listeners from the currently-tracked element, - * if any exists. - */ -function stopWatchingForValueChange() { - if (!activeElement) { - return; - } - - // delete restores the original property definition - delete activeElement.value; - - if (activeElement.detachEvent) { - activeElement.detachEvent('onpropertychange', handlePropertyChange); - } else { - activeElement.removeEventListener('propertychange', handlePropertyChange, false); - } - - activeElement = null; - activeElementInst = null; - activeElementValue = null; - activeElementValueProp = null; -} - -/** - * (For IE <=11) Handles a propertychange event, sending a `change` event if - * the value of the active element has changed. - */ -function handlePropertyChange(nativeEvent) { - if (nativeEvent.propertyName !== 'value') { - return; - } - var value = nativeEvent.srcElement.value; - if (value === activeElementValue) { - return; - } - activeElementValue = value; - - manualDispatchChangeEvent(nativeEvent); -} - -/** - * If a `change` event should be fired, returns the target's ID. - */ -function getTargetInstForInputEvent(topLevelType, targetInst) { - if (topLevelType === topLevelTypes.topInput) { - // In modern browsers (i.e., not IE8 or IE9), the input event is exactly - // what we want so fall through here and trigger an abstract event - return targetInst; - } -} - -function handleEventsForInputEventIE(topLevelType, target, targetInst) { - if (topLevelType === topLevelTypes.topFocus) { - // In IE8, we can capture almost all .value changes by adding a - // propertychange handler and looking for events with propertyName - // equal to 'value' - // In IE9-11, propertychange fires for most input events but is buggy and - // doesn't fire when text is deleted, but conveniently, selectionchange - // appears to fire in all of the remaining cases so we catch those and - // forward the event if the value has changed - // In either case, we don't want to call the event handler if the value - // is changed from JS so we redefine a setter for `.value` that updates - // our activeElementValue variable, allowing us to ignore those changes - // - // stopWatching() should be a noop here but we call it just in case we - // missed a blur event somehow. - stopWatchingForValueChange(); - startWatchingForValueChange(target, targetInst); - } else if (topLevelType === topLevelTypes.topBlur) { - stopWatchingForValueChange(); - } -} - -// For IE8 and IE9. -function getTargetInstForInputEventIE(topLevelType, targetInst) { - if (topLevelType === topLevelTypes.topSelectionChange || topLevelType === topLevelTypes.topKeyUp || topLevelType === topLevelTypes.topKeyDown) { - // On the selectionchange event, the target is just document which isn't - // helpful for us so just check activeElement instead. - // - // 99% of the time, keydown and keyup aren't necessary. IE8 fails to fire - // propertychange on the first input event after setting `value` from a - // script and fires only keydown, keypress, keyup. Catching keyup usually - // gets it and catching keydown lets us fire an event for the first - // keystroke if user does a key repeat (it'll be a little delayed: right - // before the second keystroke). Other input methods (e.g., paste) seem to - // fire selectionchange normally. - if (activeElement && activeElement.value !== activeElementValue) { - activeElementValue = activeElement.value; - return activeElementInst; - } - } -} - -/** - * SECTION: handle `click` event - */ -function shouldUseClickEvent(elem) { - // Use the `click` event to detect changes to checkbox and radio inputs. - // This approach works across all browsers, whereas `change` does not fire - // until `blur` in IE8. - return elem.nodeName && elem.nodeName.toLowerCase() === 'input' && (elem.type === 'checkbox' || elem.type === 'radio'); -} - -function getTargetInstForClickEvent(topLevelType, targetInst) { - if (topLevelType === topLevelTypes.topClick) { - return targetInst; - } -} - -/** - * This plugin creates an `onChange` event that normalizes change events - * across form elements. This event fires at a time when it's possible to - * change the element's value without seeing a flicker. - * - * Supported elements are: - * - input (see `isTextInputElement`) - * - textarea - * - select - */ -var ChangeEventPlugin = { - - eventTypes: eventTypes, - - extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget) { - var targetNode = targetInst ? ReactDOMComponentTree.getNodeFromInstance(targetInst) : window; - - var getTargetInstFunc, handleEventFunc; - if (shouldUseChangeEvent(targetNode)) { - if (doesChangeEventBubble) { - getTargetInstFunc = getTargetInstForChangeEvent; - } else { - handleEventFunc = handleEventsForChangeEventIE8; - } - } else if (isTextInputElement(targetNode)) { - if (isInputEventSupported) { - getTargetInstFunc = getTargetInstForInputEvent; - } else { - getTargetInstFunc = getTargetInstForInputEventIE; - handleEventFunc = handleEventsForInputEventIE; - } - } else if (shouldUseClickEvent(targetNode)) { - getTargetInstFunc = getTargetInstForClickEvent; - } - - if (getTargetInstFunc) { - var inst = getTargetInstFunc(topLevelType, targetInst); - if (inst) { - var event = SyntheticEvent.getPooled(eventTypes.change, inst, nativeEvent, nativeEventTarget); - event.type = 'change'; - EventPropagators.accumulateTwoPhaseDispatches(event); - return event; - } - } - - if (handleEventFunc) { - handleEventFunc(topLevelType, targetNode, targetInst); - } - } - -}; - -module.exports = ChangeEventPlugin; -},{"103":103,"126":126,"133":133,"134":134,"144":144,"16":16,"162":162,"17":17,"20":20,"40":40,"94":94}],7:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule DOMChildrenOperations - */ - -'use strict'; - -var DOMLazyTree = _dereq_(8); -var Danger = _dereq_(12); -var ReactMultiChildUpdateTypes = _dereq_(77); -var ReactPerf = _dereq_(82); - -var createMicrosoftUnsafeLocalFunction = _dereq_(117); -var setInnerHTML = _dereq_(138); -var setTextContent = _dereq_(139); - -function getNodeAfter(parentNode, node) { - // Special case for text components, which return [open, close] comments - // from getNativeNode. - if (Array.isArray(node)) { - node = node[1]; - } - return node ? node.nextSibling : parentNode.firstChild; -} - -/** - * Inserts `childNode` as a child of `parentNode` at the `index`. - * - * @param {DOMElement} parentNode Parent node in which to insert. - * @param {DOMElement} childNode Child node to insert. - * @param {number} index Index at which to insert the child. - * @internal - */ -var insertChildAt = createMicrosoftUnsafeLocalFunction(function (parentNode, childNode, referenceNode) { - // We rely exclusively on `insertBefore(node, null)` instead of also using - // `appendChild(node)`. (Using `undefined` is not allowed by all browsers so - // we are careful to use `null`.) - parentNode.insertBefore(childNode, referenceNode); -}); - -function insertLazyTreeChildAt(parentNode, childTree, referenceNode) { - DOMLazyTree.insertTreeBefore(parentNode, childTree, referenceNode); -} - -function moveChild(parentNode, childNode, referenceNode) { - if (Array.isArray(childNode)) { - moveDelimitedText(parentNode, childNode[0], childNode[1], referenceNode); - } else { - insertChildAt(parentNode, childNode, referenceNode); - } -} - -function removeChild(parentNode, childNode) { - if (Array.isArray(childNode)) { - var closingComment = childNode[1]; - childNode = childNode[0]; - removeDelimitedText(parentNode, childNode, closingComment); - parentNode.removeChild(closingComment); - } - parentNode.removeChild(childNode); -} - -function moveDelimitedText(parentNode, openingComment, closingComment, referenceNode) { - var node = openingComment; - while (true) { - var nextNode = node.nextSibling; - insertChildAt(parentNode, node, referenceNode); - if (node === closingComment) { - break; - } - node = nextNode; - } -} - -function removeDelimitedText(parentNode, startNode, closingComment) { - while (true) { - var node = startNode.nextSibling; - if (node === closingComment) { - // The closing comment is removed by ReactMultiChild. - break; - } else { - parentNode.removeChild(node); - } - } -} - -function replaceDelimitedText(openingComment, closingComment, stringText) { - var parentNode = openingComment.parentNode; - var nodeAfterComment = openingComment.nextSibling; - if (nodeAfterComment === closingComment) { - // There are no text nodes between the opening and closing comments; insert - // a new one if stringText isn't empty. - if (stringText) { - insertChildAt(parentNode, document.createTextNode(stringText), nodeAfterComment); - } - } else { - if (stringText) { - // Set the text content of the first node after the opening comment, and - // remove all following nodes up until the closing comment. - setTextContent(nodeAfterComment, stringText); - removeDelimitedText(parentNode, nodeAfterComment, closingComment); - } else { - removeDelimitedText(parentNode, openingComment, closingComment); - } - } -} - -/** - * Operations for updating with DOM children. - */ -var DOMChildrenOperations = { - - dangerouslyReplaceNodeWithMarkup: Danger.dangerouslyReplaceNodeWithMarkup, - - replaceDelimitedText: replaceDelimitedText, - - /** - * Updates a component's children by processing a series of updates. The - * update configurations are each expected to have a `parentNode` property. - * - * @param {array} updates List of update configurations. - * @internal - */ - processUpdates: function (parentNode, updates) { - for (var k = 0; k < updates.length; k++) { - var update = updates[k]; - switch (update.type) { - case ReactMultiChildUpdateTypes.INSERT_MARKUP: - insertLazyTreeChildAt(parentNode, update.content, getNodeAfter(parentNode, update.afterNode)); - break; - case ReactMultiChildUpdateTypes.MOVE_EXISTING: - moveChild(parentNode, update.fromNode, getNodeAfter(parentNode, update.afterNode)); - break; - case ReactMultiChildUpdateTypes.SET_MARKUP: - setInnerHTML(parentNode, update.content); - break; - case ReactMultiChildUpdateTypes.TEXT_CONTENT: - setTextContent(parentNode, update.content); - break; - case ReactMultiChildUpdateTypes.REMOVE_NODE: - removeChild(parentNode, update.fromNode); - break; - } - } - } - -}; - -ReactPerf.measureMethods(DOMChildrenOperations, 'DOMChildrenOperations', { - replaceDelimitedText: 'replaceDelimitedText' -}); - -module.exports = DOMChildrenOperations; -},{"117":117,"12":12,"138":138,"139":139,"77":77,"8":8,"82":82}],8:[function(_dereq_,module,exports){ -/** - * Copyright 2015-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule DOMLazyTree - */ - -'use strict'; - -var createMicrosoftUnsafeLocalFunction = _dereq_(117); -var setTextContent = _dereq_(139); - -/** - * In IE (8-11) and Edge, appending nodes with no children is dramatically - * faster than appending a full subtree, so we essentially queue up the - * .appendChild calls here and apply them so each node is added to its parent - * before any children are added. - * - * In other browsers, doing so is slower or neutral compared to the other order - * (in Firefox, twice as slow) so we only do this inversion in IE. - * - * See https://github.com/spicyj/innerhtml-vs-createelement-vs-clonenode. - */ -var enableLazy = typeof document !== 'undefined' && typeof document.documentMode === 'number' || typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string' && /\bEdge\/\d/.test(navigator.userAgent); - -function insertTreeChildren(tree) { - if (!enableLazy) { - return; - } - var node = tree.node; - var children = tree.children; - if (children.length) { - for (var i = 0; i < children.length; i++) { - insertTreeBefore(node, children[i], null); - } - } else if (tree.html != null) { - node.innerHTML = tree.html; - } else if (tree.text != null) { - setTextContent(node, tree.text); - } -} - -var insertTreeBefore = createMicrosoftUnsafeLocalFunction(function (parentNode, tree, referenceNode) { - // DocumentFragments aren't actually part of the DOM after insertion so - // appending children won't update the DOM. We need to ensure the fragment - // is properly populated first, breaking out of our lazy approach for just - // this level. - if (tree.node.nodeType === 11) { - insertTreeChildren(tree); - parentNode.insertBefore(tree.node, referenceNode); - } else { - parentNode.insertBefore(tree.node, referenceNode); - insertTreeChildren(tree); - } -}); - -function replaceChildWithTree(oldNode, newTree) { - oldNode.parentNode.replaceChild(newTree.node, oldNode); - insertTreeChildren(newTree); -} - -function queueChild(parentTree, childTree) { - if (enableLazy) { - parentTree.children.push(childTree); - } else { - parentTree.node.appendChild(childTree.node); - } -} - -function queueHTML(tree, html) { - if (enableLazy) { - tree.html = html; - } else { - tree.node.innerHTML = html; - } -} - -function queueText(tree, text) { - if (enableLazy) { - tree.text = text; - } else { - setTextContent(tree.node, text); - } -} - -function DOMLazyTree(node) { - return { - node: node, - children: [], - html: null, - text: null - }; -} - -DOMLazyTree.insertTreeBefore = insertTreeBefore; -DOMLazyTree.replaceChildWithTree = replaceChildWithTree; -DOMLazyTree.queueChild = queueChild; -DOMLazyTree.queueHTML = queueHTML; -DOMLazyTree.queueText = queueText; - -module.exports = DOMLazyTree; -},{"117":117,"139":139}],9:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule DOMNamespaces - */ - -'use strict'; - -var DOMNamespaces = { - html: 'http://www.w3.org/1999/xhtml', - mathml: 'http://www.w3.org/1998/Math/MathML', - svg: 'http://www.w3.org/2000/svg' -}; - -module.exports = DOMNamespaces; -},{}],10:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule DOMProperty - */ - -'use strict'; - -var invariant = _dereq_(158); - -function checkMask(value, bitmask) { - return (value & bitmask) === bitmask; -} - -var DOMPropertyInjection = { - /** - * Mapping from normalized, camelcased property names to a configuration that - * specifies how the associated DOM property should be accessed or rendered. - */ - MUST_USE_PROPERTY: 0x1, - HAS_SIDE_EFFECTS: 0x2, - HAS_BOOLEAN_VALUE: 0x4, - HAS_NUMERIC_VALUE: 0x8, - HAS_POSITIVE_NUMERIC_VALUE: 0x10 | 0x8, - HAS_OVERLOADED_BOOLEAN_VALUE: 0x20, - - /** - * Inject some specialized knowledge about the DOM. This takes a config object - * with the following properties: - * - * isCustomAttribute: function that given an attribute name will return true - * if it can be inserted into the DOM verbatim. Useful for data-* or aria-* - * attributes where it's impossible to enumerate all of the possible - * attribute names, - * - * Properties: object mapping DOM property name to one of the - * DOMPropertyInjection constants or null. If your attribute isn't in here, - * it won't get written to the DOM. - * - * DOMAttributeNames: object mapping React attribute name to the DOM - * attribute name. Attribute names not specified use the **lowercase** - * normalized name. - * - * DOMAttributeNamespaces: object mapping React attribute name to the DOM - * attribute namespace URL. (Attribute names not specified use no namespace.) - * - * DOMPropertyNames: similar to DOMAttributeNames but for DOM properties. - * Property names not specified use the normalized name. - * - * DOMMutationMethods: Properties that require special mutation methods. If - * `value` is undefined, the mutation method should unset the property. - * - * @param {object} domPropertyConfig the config as described above. - */ - injectDOMPropertyConfig: function (domPropertyConfig) { - var Injection = DOMPropertyInjection; - var Properties = domPropertyConfig.Properties || {}; - var DOMAttributeNamespaces = domPropertyConfig.DOMAttributeNamespaces || {}; - var DOMAttributeNames = domPropertyConfig.DOMAttributeNames || {}; - var DOMPropertyNames = domPropertyConfig.DOMPropertyNames || {}; - var DOMMutationMethods = domPropertyConfig.DOMMutationMethods || {}; - - if (domPropertyConfig.isCustomAttribute) { - DOMProperty._isCustomAttributeFunctions.push(domPropertyConfig.isCustomAttribute); - } - - for (var propName in Properties) { - !!DOMProperty.properties.hasOwnProperty(propName) ? "development" !== 'production' ? invariant(false, 'injectDOMPropertyConfig(...): You\'re trying to inject DOM property ' + '\'%s\' which has already been injected. You may be accidentally ' + 'injecting the same DOM property config twice, or you may be ' + 'injecting two configs that have conflicting property names.', propName) : invariant(false) : void 0; - - var lowerCased = propName.toLowerCase(); - var propConfig = Properties[propName]; - - var propertyInfo = { - attributeName: lowerCased, - attributeNamespace: null, - propertyName: propName, - mutationMethod: null, - - mustUseProperty: checkMask(propConfig, Injection.MUST_USE_PROPERTY), - hasSideEffects: checkMask(propConfig, Injection.HAS_SIDE_EFFECTS), - hasBooleanValue: checkMask(propConfig, Injection.HAS_BOOLEAN_VALUE), - hasNumericValue: checkMask(propConfig, Injection.HAS_NUMERIC_VALUE), - hasPositiveNumericValue: checkMask(propConfig, Injection.HAS_POSITIVE_NUMERIC_VALUE), - hasOverloadedBooleanValue: checkMask(propConfig, Injection.HAS_OVERLOADED_BOOLEAN_VALUE) - }; - - !(propertyInfo.mustUseProperty || !propertyInfo.hasSideEffects) ? "development" !== 'production' ? invariant(false, 'DOMProperty: Properties that have side effects must use property: %s', propName) : invariant(false) : void 0; - !(propertyInfo.hasBooleanValue + propertyInfo.hasNumericValue + propertyInfo.hasOverloadedBooleanValue <= 1) ? "development" !== 'production' ? invariant(false, 'DOMProperty: Value can be one of boolean, overloaded boolean, or ' + 'numeric value, but not a combination: %s', propName) : invariant(false) : void 0; - - if ("development" !== 'production') { - DOMProperty.getPossibleStandardName[lowerCased] = propName; - } - - if (DOMAttributeNames.hasOwnProperty(propName)) { - var attributeName = DOMAttributeNames[propName]; - propertyInfo.attributeName = attributeName; - if ("development" !== 'production') { - DOMProperty.getPossibleStandardName[attributeName] = propName; - } - } - - if (DOMAttributeNamespaces.hasOwnProperty(propName)) { - propertyInfo.attributeNamespace = DOMAttributeNamespaces[propName]; - } - - if (DOMPropertyNames.hasOwnProperty(propName)) { - propertyInfo.propertyName = DOMPropertyNames[propName]; - } - - if (DOMMutationMethods.hasOwnProperty(propName)) { - propertyInfo.mutationMethod = DOMMutationMethods[propName]; - } - - DOMProperty.properties[propName] = propertyInfo; - } - } -}; - -/* eslint-disable max-len */ -var ATTRIBUTE_NAME_START_CHAR = ':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD'; -/* eslint-enable max-len */ - -/** - * DOMProperty exports lookup objects that can be used like functions: - * - * > DOMProperty.isValid['id'] - * true - * > DOMProperty.isValid['foobar'] - * undefined - * - * Although this may be confusing, it performs better in general. - * - * @see http://jsperf.com/key-exists - * @see http://jsperf.com/key-missing - */ -var DOMProperty = { - - ID_ATTRIBUTE_NAME: 'data-reactid', - ROOT_ATTRIBUTE_NAME: 'data-reactroot', - - ATTRIBUTE_NAME_START_CHAR: ATTRIBUTE_NAME_START_CHAR, - ATTRIBUTE_NAME_CHAR: ATTRIBUTE_NAME_START_CHAR + '\\-.0-9\\uB7\\u0300-\\u036F\\u203F-\\u2040', - - /** - * Map from property "standard name" to an object with info about how to set - * the property in the DOM. Each object contains: - * - * attributeName: - * Used when rendering markup or with `*Attribute()`. - * attributeNamespace - * propertyName: - * Used on DOM node instances. (This includes properties that mutate due to - * external factors.) - * mutationMethod: - * If non-null, used instead of the property or `setAttribute()` after - * initial render. - * mustUseProperty: - * Whether the property must be accessed and mutated as an object property. - * hasSideEffects: - * Whether or not setting a value causes side effects such as triggering - * resources to be loaded or text selection changes. If true, we read from - * the DOM before updating to ensure that the value is only set if it has - * changed. - * hasBooleanValue: - * Whether the property should be removed when set to a falsey value. - * hasNumericValue: - * Whether the property must be numeric or parse as a numeric and should be - * removed when set to a falsey value. - * hasPositiveNumericValue: - * Whether the property must be positive numeric or parse as a positive - * numeric and should be removed when set to a falsey value. - * hasOverloadedBooleanValue: - * Whether the property can be used as a flag as well as with a value. - * Removed when strictly equal to false; present without a value when - * strictly equal to true; present with a value otherwise. - */ - properties: {}, - - /** - * Mapping from lowercase property names to the properly cased version, used - * to warn in the case of missing properties. Available only in __DEV__. - * @type {Object} - */ - getPossibleStandardName: "development" !== 'production' ? {} : null, - - /** - * All of the isCustomAttribute() functions that have been injected. - */ - _isCustomAttributeFunctions: [], - - /** - * Checks whether a property name is a custom attribute. - * @method - */ - isCustomAttribute: function (attributeName) { - for (var i = 0; i < DOMProperty._isCustomAttributeFunctions.length; i++) { - var isCustomAttributeFn = DOMProperty._isCustomAttributeFunctions[i]; - if (isCustomAttributeFn(attributeName)) { - return true; - } - } - return false; - }, - - injection: DOMPropertyInjection -}; - -module.exports = DOMProperty; -},{"158":158}],11:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule DOMPropertyOperations - */ - -'use strict'; - -var DOMProperty = _dereq_(10); -var ReactDOMInstrumentation = _dereq_(48); -var ReactPerf = _dereq_(82); - -var quoteAttributeValueForBrowser = _dereq_(136); -var warning = _dereq_(168); - -var VALID_ATTRIBUTE_NAME_REGEX = new RegExp('^[' + DOMProperty.ATTRIBUTE_NAME_START_CHAR + '][' + DOMProperty.ATTRIBUTE_NAME_CHAR + ']*$'); -var illegalAttributeNameCache = {}; -var validatedAttributeNameCache = {}; - -function isAttributeNameSafe(attributeName) { - if (validatedAttributeNameCache.hasOwnProperty(attributeName)) { - return true; - } - if (illegalAttributeNameCache.hasOwnProperty(attributeName)) { - return false; - } - if (VALID_ATTRIBUTE_NAME_REGEX.test(attributeName)) { - validatedAttributeNameCache[attributeName] = true; - return true; - } - illegalAttributeNameCache[attributeName] = true; - "development" !== 'production' ? warning(false, 'Invalid attribute name: `%s`', attributeName) : void 0; - return false; -} - -function shouldIgnoreValue(propertyInfo, value) { - return value == null || propertyInfo.hasBooleanValue && !value || propertyInfo.hasNumericValue && isNaN(value) || propertyInfo.hasPositiveNumericValue && value < 1 || propertyInfo.hasOverloadedBooleanValue && value === false; -} - -/** - * Operations for dealing with DOM properties. - */ -var DOMPropertyOperations = { - - /** - * Creates markup for the ID property. - * - * @param {string} id Unescaped ID. - * @return {string} Markup string. - */ - createMarkupForID: function (id) { - return DOMProperty.ID_ATTRIBUTE_NAME + '=' + quoteAttributeValueForBrowser(id); - }, - - setAttributeForID: function (node, id) { - node.setAttribute(DOMProperty.ID_ATTRIBUTE_NAME, id); - }, - - createMarkupForRoot: function () { - return DOMProperty.ROOT_ATTRIBUTE_NAME + '=""'; - }, - - setAttributeForRoot: function (node) { - node.setAttribute(DOMProperty.ROOT_ATTRIBUTE_NAME, ''); - }, - - /** - * Creates markup for a property. - * - * @param {string} name - * @param {*} value - * @return {?string} Markup string, or null if the property was invalid. - */ - createMarkupForProperty: function (name, value) { - if ("development" !== 'production') { - ReactDOMInstrumentation.debugTool.onCreateMarkupForProperty(name, value); - } - var propertyInfo = DOMProperty.properties.hasOwnProperty(name) ? DOMProperty.properties[name] : null; - if (propertyInfo) { - if (shouldIgnoreValue(propertyInfo, value)) { - return ''; - } - var attributeName = propertyInfo.attributeName; - if (propertyInfo.hasBooleanValue || propertyInfo.hasOverloadedBooleanValue && value === true) { - return attributeName + '=""'; - } - return attributeName + '=' + quoteAttributeValueForBrowser(value); - } else if (DOMProperty.isCustomAttribute(name)) { - if (value == null) { - return ''; - } - return name + '=' + quoteAttributeValueForBrowser(value); - } - return null; - }, - - /** - * Creates markup for a custom property. - * - * @param {string} name - * @param {*} value - * @return {string} Markup string, or empty string if the property was invalid. - */ - createMarkupForCustomAttribute: function (name, value) { - if (!isAttributeNameSafe(name) || value == null) { - return ''; - } - return name + '=' + quoteAttributeValueForBrowser(value); - }, - - /** - * Sets the value for a property on a node. - * - * @param {DOMElement} node - * @param {string} name - * @param {*} value - */ - setValueForProperty: function (node, name, value) { - if ("development" !== 'production') { - ReactDOMInstrumentation.debugTool.onSetValueForProperty(node, name, value); - } - var propertyInfo = DOMProperty.properties.hasOwnProperty(name) ? DOMProperty.properties[name] : null; - if (propertyInfo) { - var mutationMethod = propertyInfo.mutationMethod; - if (mutationMethod) { - mutationMethod(node, value); - } else if (shouldIgnoreValue(propertyInfo, value)) { - this.deleteValueForProperty(node, name); - } else if (propertyInfo.mustUseProperty) { - var propName = propertyInfo.propertyName; - // Must explicitly cast values for HAS_SIDE_EFFECTS-properties to the - // property type before comparing; only `value` does and is string. - if (!propertyInfo.hasSideEffects || '' + node[propName] !== '' + value) { - // Contrary to `setAttribute`, object properties are properly - // `toString`ed by IE8/9. - node[propName] = value; - } - } else { - var attributeName = propertyInfo.attributeName; - var namespace = propertyInfo.attributeNamespace; - // `setAttribute` with objects becomes only `[object]` in IE8/9, - // ('' + value) makes it output the correct toString()-value. - if (namespace) { - node.setAttributeNS(namespace, attributeName, '' + value); - } else if (propertyInfo.hasBooleanValue || propertyInfo.hasOverloadedBooleanValue && value === true) { - node.setAttribute(attributeName, ''); - } else { - node.setAttribute(attributeName, '' + value); - } - } - } else if (DOMProperty.isCustomAttribute(name)) { - DOMPropertyOperations.setValueForAttribute(node, name, value); - } - }, - - setValueForAttribute: function (node, name, value) { - if (!isAttributeNameSafe(name)) { - return; - } - if (value == null) { - node.removeAttribute(name); - } else { - node.setAttribute(name, '' + value); - } - }, - - /** - * Deletes the value for a property on a node. - * - * @param {DOMElement} node - * @param {string} name - */ - deleteValueForProperty: function (node, name) { - if ("development" !== 'production') { - ReactDOMInstrumentation.debugTool.onDeleteValueForProperty(node, name); - } - var propertyInfo = DOMProperty.properties.hasOwnProperty(name) ? DOMProperty.properties[name] : null; - if (propertyInfo) { - var mutationMethod = propertyInfo.mutationMethod; - if (mutationMethod) { - mutationMethod(node, undefined); - } else if (propertyInfo.mustUseProperty) { - var propName = propertyInfo.propertyName; - if (propertyInfo.hasBooleanValue) { - // No HAS_SIDE_EFFECTS logic here, only `value` has it and is string. - node[propName] = false; - } else { - if (!propertyInfo.hasSideEffects || '' + node[propName] !== '') { - node[propName] = ''; - } - } - } else { - node.removeAttribute(propertyInfo.attributeName); - } - } else if (DOMProperty.isCustomAttribute(name)) { - node.removeAttribute(name); - } - } - -}; - -ReactPerf.measureMethods(DOMPropertyOperations, 'DOMPropertyOperations', { - setValueForProperty: 'setValueForProperty', - setValueForAttribute: 'setValueForAttribute', - deleteValueForProperty: 'deleteValueForProperty' -}); - -module.exports = DOMPropertyOperations; -},{"10":10,"136":136,"168":168,"48":48,"82":82}],12:[function(_dereq_,module,exports){ -/** - * Copyright 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule Danger - */ - -'use strict'; - -var DOMLazyTree = _dereq_(8); -var ExecutionEnvironment = _dereq_(144); - -var createNodesFromMarkup = _dereq_(149); -var emptyFunction = _dereq_(150); -var getMarkupWrap = _dereq_(154); -var invariant = _dereq_(158); - -var OPEN_TAG_NAME_EXP = /^(<[^ \/>]+)/; -var RESULT_INDEX_ATTR = 'data-danger-index'; - -/** - * Extracts the `nodeName` from a string of markup. - * - * NOTE: Extracting the `nodeName` does not require a regular expression match - * because we make assumptions about React-generated markup (i.e. there are no - * spaces surrounding the opening tag and there is at least one attribute). - * - * @param {string} markup String of markup. - * @return {string} Node name of the supplied markup. - * @see http://jsperf.com/extract-nodename - */ -function getNodeName(markup) { - return markup.substring(1, markup.indexOf(' ')); -} - -var Danger = { - - /** - * Renders markup into an array of nodes. The markup is expected to render - * into a list of root nodes. Also, the length of `resultList` and - * `markupList` should be the same. - * - * @param {array} markupList List of markup strings to render. - * @return {array} List of rendered nodes. - * @internal - */ - dangerouslyRenderMarkup: function (markupList) { - !ExecutionEnvironment.canUseDOM ? "development" !== 'production' ? invariant(false, 'dangerouslyRenderMarkup(...): Cannot render markup in a worker ' + 'thread. Make sure `window` and `document` are available globally ' + 'before requiring React when unit testing or use ' + 'ReactDOMServer.renderToString for server rendering.') : invariant(false) : void 0; - var nodeName; - var markupByNodeName = {}; - // Group markup by `nodeName` if a wrap is necessary, else by '*'. - for (var i = 0; i < markupList.length; i++) { - !markupList[i] ? "development" !== 'production' ? invariant(false, 'dangerouslyRenderMarkup(...): Missing markup.') : invariant(false) : void 0; - nodeName = getNodeName(markupList[i]); - nodeName = getMarkupWrap(nodeName) ? nodeName : '*'; - markupByNodeName[nodeName] = markupByNodeName[nodeName] || []; - markupByNodeName[nodeName][i] = markupList[i]; - } - var resultList = []; - var resultListAssignmentCount = 0; - for (nodeName in markupByNodeName) { - if (!markupByNodeName.hasOwnProperty(nodeName)) { - continue; - } - var markupListByNodeName = markupByNodeName[nodeName]; - - // This for-in loop skips the holes of the sparse array. The order of - // iteration should follow the order of assignment, which happens to match - // numerical index order, but we don't rely on that. - var resultIndex; - for (resultIndex in markupListByNodeName) { - if (markupListByNodeName.hasOwnProperty(resultIndex)) { - var markup = markupListByNodeName[resultIndex]; - - // Push the requested markup with an additional RESULT_INDEX_ATTR - // attribute. If the markup does not start with a < character, it - // will be discarded below (with an appropriate console.error). - markupListByNodeName[resultIndex] = markup.replace(OPEN_TAG_NAME_EXP, - // This index will be parsed back out below. - '$1 ' + RESULT_INDEX_ATTR + '="' + resultIndex + '" '); - } - } - - // Render each group of markup with similar wrapping `nodeName`. - var renderNodes = createNodesFromMarkup(markupListByNodeName.join(''), emptyFunction // Do nothing special with + __( 'The rendered HTML for the post calendar.', 'js-widgets' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + 'default' => '', + ); + return $schema; + } + + /** + * Render a widget instance for a REST API response. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $item = parent::prepare_item_for_response( $instance, $request ); + $item['rendered'] = get_calendar( true, false ); + return $item; + } +} diff --git a/core-adapter-widgets/calendar/form.js b/core-adapter-widgets/calendar/form.js new file mode 100644 index 0000000..173616f --- /dev/null +++ b/core-adapter-widgets/calendar/form.js @@ -0,0 +1,23 @@ +/* global wp, module */ +/* eslint consistent-this: [ "error", "form" ] */ +/* eslint-disable strict */ +/* eslint-disable complexity */ + +wp.customize.Widgets.formConstructor.calendar = (function() { + 'use strict'; + + var CalendarWidgetForm; + + /** + * Calendar Widget Form. + * + * @constructor + */ + CalendarWidgetForm = wp.customize.Widgets.Form.extend( {} ); + + if ( 'undefined' !== typeof module ) { + module.exports = CalendarWidgetForm; + } + return CalendarWidgetForm; + +})(); diff --git a/core-adapter-widgets/categories/class.php b/core-adapter-widgets/categories/class.php new file mode 100644 index 0000000..a708c65 --- /dev/null +++ b/core-adapter-widgets/categories/class.php @@ -0,0 +1,220 @@ + array( + 'description' => __( 'Display as dropdown', 'default' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'count' => array( + 'description' => __( 'Show post counts', 'default' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'hierarchical' => array( + 'description' => __( 'Show hierarchy', 'default' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'terms' => array( + 'description' => __( 'The IDs for the category terms.', 'js-widgets' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + 'default' => array(), + ), + ) + ); + return $schema; + } + + /** + * Render a widget instance for a REST API response. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $item = parent::prepare_item_for_response( $instance, $request ); + + $cat_args = array( + 'orderby' => 'name', + 'show_count' => $instance['count'], + 'hierarchical' => $instance['hierarchical'], + ); + + /** This filter is documented in wp-includes/widgets/class-wp-widget-categories.php */ + $cat_args = apply_filters( 'widget_categories_args', $cat_args ); + + $item['terms'] = wp_list_pluck( $this->get_categories_list( $cat_args ), 'term_id' ); + + return $item; + } + + /** + * Get categories list. + * + * Adaptation of `wp_list_categories()` to return raw (unrendered) data. + * + * @see wp_list_categories() + * + * @param array $args Args. + * @return array|false Categories list or false on failure. + */ + public function get_categories_list( $args ) { + $defaults = array( + 'child_of' => 0, + 'current_category' => 0, + 'depth' => 0, + 'echo' => 1, + 'exclude' => '', + 'exclude_tree' => '', + 'feed' => '', + 'feed_image' => '', + 'feed_type' => '', + 'hide_empty' => 1, + 'hierarchical' => true, + 'order' => 'ASC', + 'orderby' => 'name', + 'style' => 'list', + 'taxonomy' => 'category', + 'use_desc_for_title' => 1, + ); + + $r = wp_parse_args( $args, $defaults ); + + if ( ! isset( $r['pad_counts'] ) && $r['show_count'] && $r['hierarchical'] ) { + $r['pad_counts'] = true; + } + + // Descendants of exclusions should be excluded too. + if ( true == $r['hierarchical'] ) { + $exclude_tree = array(); + + if ( $r['exclude_tree'] ) { + $exclude_tree = array_merge( $exclude_tree, wp_parse_id_list( $r['exclude_tree'] ) ); + } + + if ( $r['exclude'] ) { + $exclude_tree = array_merge( $exclude_tree, wp_parse_id_list( $r['exclude'] ) ); + } + + $r['exclude_tree'] = $exclude_tree; + $r['exclude'] = ''; + } + + if ( ! isset( $r['class'] ) ) { + $r['class'] = ( 'category' == $r['taxonomy'] ) ? 'categories' : $r['taxonomy']; + } + + if ( ! taxonomy_exists( $r['taxonomy'] ) ) { + return false; + } + + return get_categories( $r ); + } + + /** + * Prepare links for the response. + * + * @param WP_REST_Response $response Response. + * @param WP_REST_Request $request Request. + * @param JS_Widgets_REST_Controller $controller Controller. + * @return array Links for the given post. + */ + public function get_rest_response_links( $response, $request, $controller ) { + $links = array(); + + $links['wp:term'] = array(); + foreach ( $response->data['terms'] as $term_id ) { + $term = get_term( (int) $term_id ); + if ( empty( $term ) || is_wp_error( $term ) ) { + continue; + } + $obj = get_taxonomy( $term->taxonomy ); + if ( empty( $obj ) ) { + continue; + } + + $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; + $base = sprintf( '/wp/v2/%s', $rest_base ); + + $links['wp:term'][] = array( + 'href' => rest_url( trailingslashit( $base ) . $term_id ), + 'embeddable' => true, + 'taxonomy' => $term->taxonomy, + ); + } + return $links; + } + + /** + * Render JS Template. + */ + public function form_template() { + $item_schema = $this->get_item_schema(); + ?> + + array( + 'description' => __( 'Links contained inside of a Meta widget.', 'js-widgets' ), + 'type' => 'object', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ) + ); + return $schema; + } + + /** + * Render a widget instance for a REST API response. + * + * @inheritdoc + * + * This is adapted from `WP_Widget_Meta::widget()`. + * + * @see WP_Widget_Meta::widget() + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $item = parent::prepare_item_for_response( $instance, $request ); + + $meta_links = array(); + $meta_links['rss2'] = array( + 'label' => strip_tags( __( 'Entries RSS', 'default' ) ), + 'href' => get_bloginfo( 'rss2_url' ), + ); + $meta_links['comments_rss2_url'] = array( + 'label' => strip_tags( __( 'Comments RSS', 'default' ) ), + 'href' => get_bloginfo( 'comments_rss2_url' ), + ); + + if ( get_option( 'users_can_register' ) ) { + + // @todo If has_filter( 'register' ), apply filters on HTML for register link and parse out the URL and label? + $meta_links['register'] = array( + 'label' => __( 'Register', 'default' ), + 'href' => wp_registration_url(), + ); + } + if ( current_user_can( 'read' ) ) { + $meta_links['admin'] = array( + 'label' => __( 'Site Admin', 'default' ), + 'href' => admin_url(), + ); + } + + // @todo If has_filter( 'widget_meta_poweredby' ), apply filters on HTML parse out the URL and label? + $meta_links['poweredby'] = array( + 'label' => _x( 'WordPress.org', 'meta widget link text', 'default' ), + 'href' => __( 'https://wordpress.org/', 'default' ), + 'title' => __( 'Powered by WordPress, state-of-the-art semantic personal publishing platform.', 'default' ), + ); + + $item['meta_links'] = $meta_links; + + // @todo What about wp_meta()? Should action get done with output buffering to capture any links and parse out via DOMDocument? + return $item; + } +} diff --git a/core-adapter-widgets/meta/form.js b/core-adapter-widgets/meta/form.js new file mode 100644 index 0000000..67b87b7 --- /dev/null +++ b/core-adapter-widgets/meta/form.js @@ -0,0 +1,23 @@ +/* global wp, module */ +/* eslint consistent-this: [ "error", "form" ] */ +/* eslint-disable strict */ +/* eslint-disable complexity */ + +wp.customize.Widgets.formConstructor.meta = (function() { + 'use strict'; + + var MetaWidgetForm; + + /** + * Meta Widget Form. + * + * @constructor + */ + MetaWidgetForm = wp.customize.Widgets.Form.extend( {} ); + + if ( 'undefined' !== typeof module ) { + module.exports = MetaWidgetForm; + } + return MetaWidgetForm; + +})(); diff --git a/core-adapter-widgets/nav_menu/class.php b/core-adapter-widgets/nav_menu/class.php new file mode 100644 index 0000000..d3fe29c --- /dev/null +++ b/core-adapter-widgets/nav_menu/class.php @@ -0,0 +1,78 @@ + array( + 'description' => __( 'Selected nav menu', 'js-widgets' ), + 'type' => 'integer', + 'default' => 0, + 'context' => array( 'view', 'edit', 'embed' ), + ), + ) + ); + return $schema; + } + + /** + * Render JS Template. + */ + public function form_template() { + ?> + + ', { + text: navMenuModel.get( 'name' ), + value: navMenuModel.id + } ); + select.append( option ); + } ); + 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 ); + } + + }, classProps ); + + if ( 'undefined' !== typeof module ) { + module.exports = NavMenuWidgetForm; + } + return NavMenuWidgetForm; + +})( wp.customize, jQuery ); diff --git a/core-adapter-widgets/pages/class.php b/core-adapter-widgets/pages/class.php new file mode 100644 index 0000000..3e45087 --- /dev/null +++ b/core-adapter-widgets/pages/class.php @@ -0,0 +1,224 @@ + array( + 'description' => __( 'How to sort the pages.', 'js-widgets' ), + 'type' => 'string', + 'enum' => array( 'post_title', 'menu_order', 'ID' ), + 'default' => 'menu_order', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'exclude' => array( + 'description' => __( 'Page IDs to exclude.', 'js-widgets' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'sanitize_callback' => array( $this, 'sanitize_exclude' ), + ), + ), + 'pages' => array( + 'description' => __( 'The IDs for the listed pages.', 'js-widgets' ), + 'type' => 'array', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + 'default' => array(), + ), + ) + ); + return $schema; + } + + /** + * Sanitize exclude param. + * + * @param string $value Value to sanitize/validate. + * @param WP_REST_Request $request Request. + * @param string $param REST Param. + * @return string|WP_Error Exclude string or error. + */ + public function sanitize_exclude( $value, $request, $param ) { + if ( is_string( $value ) ) { + $value = trim( $value, ', ' ); + } + $validity = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $validity ) ) { + return $validity; + } + return join( ',', wp_parse_id_list( $value ) ); // String as needed by WP_Widget_Pages. + } + + /** + * Render a widget instance for a REST API response. + * + * Map the instance data to the REST resource fields and add rendered fields. + * The Text widget stores the `content` field in `text` and `auto_paragraph` in `filter`. + * + * This function contains some logic copied from `wp_list_pages()` and + * `WP_Widget_Pages::widget()` in order to obtain the necessary pages + * that would be rendered by the widget. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $instance = array_merge( $this->get_default_instance(), $instance ); + + if ( 'menu_order' === $instance['sortby'] ) { + $instance['sortby'] = 'menu_order, post_title'; + } + + /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */ + $wp_list_pages_args = apply_filters( 'widget_pages_args', array( + 'sort_column' => $instance['sortby'], + 'exclude' => $instance['exclude'], + ) ); + + $get_pages_args = wp_parse_args( $wp_list_pages_args, array( + 'depth' => 0, + 'child_of' => 0, + 'exclude' => '', + 'authors' => '', + 'sort_column' => 'menu_order, post_title', + 'walker' => '', + ) ); + + // Sanitize, mostly to keep spaces out. + $get_pages_args['exclude'] = preg_replace( '/[^0-9,]/', '', $get_pages_args['exclude'] ); + + // Allow plugins to filter an array of excluded pages (but don't put a nullstring into the array). + $exclude_array = ( $get_pages_args['exclude'] ) ? explode( ',', $get_pages_args['exclude'] ) : array(); + + /** This filter is documented in wp-includes/post-template.php */ + $get_pages_args['exclude'] = implode( ',', apply_filters( 'wp_list_pages_excludes', $exclude_array ) ); + + // Query pages. + $get_pages_args['hierarchical'] = 0; + + $pages = get_pages( $get_pages_args ); + + $item = array_merge( + parent::prepare_item_for_response( $instance, $request ), + array( + 'sortby' => $instance['sortby'], + 'exclude' => array_filter( wp_parse_id_list( $instance['exclude'] ) ), + 'pages' => wp_list_pluck( $pages, 'ID' ), + ) + ); + + return $item; + } + + /** + * Prepare links for the response. + * + * @param WP_REST_Response $response Response. + * @param WP_REST_Request $request Request. + * @param JS_Widgets_REST_Controller $controller Controller. + * @return array Links for the given post. + */ + public function get_rest_response_links( $response, $request, $controller ) { + $links = array(); + + $links['wp:page'] = array(); + foreach ( $response->data['pages'] as $post_id ) { + $post = get_post( $post_id ); + if ( empty( $post ) ) { + continue; + } + $obj = get_post_type_object( $post->post_type ); + if ( empty( $obj ) ) { + continue; + } + + $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; + $base = sprintf( '/wp/v2/%s', $rest_base ); + + $links['wp:page'][] = array( + 'href' => rest_url( trailingslashit( $base ) . $post_id ), + 'embeddable' => true, + 'post_type' => $post->post_type, + ); + } + return $links; + } + + /** + * Render JS Template. + */ + public function form_template() { + $item_schema = $this->get_item_schema(); + ?> + + array( + 'description' => __( 'The number of comments to display.', 'js-widgets' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'default' => 5, + 'minimum' => 1, + ), + 'comments' => array( + 'description' => __( 'The IDs for the recent comments.', 'js-widgets' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + 'default' => array(), + ), + ) + ); + return $schema; + } + + /** + * Render a widget instance for a REST API response. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $instance = array_merge( $this->get_default_instance(), $instance ); + + /** This filter is documented in wp-includes/widgets/class-wp-widget-recent-comments.php */ + $comments = get_comments( apply_filters( 'widget_comments_args', array( + 'number' => $instance['number'], + 'status' => 'approve', + 'post_status' => 'publish', + ) ) ); + + $item = array_merge( + parent::prepare_item_for_response( $instance, $request ), + array( + 'number' => $instance['number'], + 'comments' => array_map( 'intval', wp_list_pluck( $comments, 'comment_ID' ) ), + ) + ); + + return $item; + } + + /** + * Prepare links for the response. + * + * @param WP_REST_Response $response Response. + * @param WP_REST_Request $request Request. + * @param JS_Widgets_REST_Controller $controller Controller. + * @return array Links for the given post. + */ + public function get_rest_response_links( $response, $request, $controller ) { + $links = array(); + + $links['wp:comment'] = array(); + foreach ( $response->data['comments'] as $comment_id ) { + $links['wp:comment'][] = array( + 'href' => rest_url( "/wp/v2/comments/$comment_id" ), + 'embeddable' => true, + ); + } + return $links; + } + + /** + * Render JS Template. + */ + public function form_template() { + $item_schema = $this->get_item_schema(); + ?> + + array( + 'description' => __( 'The number of posts to display.', 'js-widgets' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'default' => 5, + 'minimum' => 1, + ), + 'show_date' => array( + 'description' => __( 'Whether the date should be shown.', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'posts' => array( + 'description' => __( 'The IDs for the recent posts.', 'js-widgets' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + 'default' => array(), + ), + ) + ); + return $schema; + } + + /** + * Render a widget instance for a REST API response. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $instance = array_merge( $this->get_default_instance(), $instance ); + + /** This filter is documented in src/wp-includes/widgets/class-wp-widget-recent-posts.php */ + $query = new WP_Query( apply_filters( 'widget_posts_args', array( + 'posts_per_page' => $instance['number'], + 'no_found_rows' => true, + 'post_status' => 'publish', + 'ignore_sticky_posts' => true, + 'update_post_meta_cache' => false, + 'update_term_meta_cache' => false, + ) ) ); + + $item = array_merge( + parent::prepare_item_for_response( $instance, $request ), + array( + 'number' => $instance['number'], + 'show_date' => (bool) $instance['show_date'], + 'posts' => wp_list_pluck( $query->posts, 'ID' ), + ) + ); + + return $item; + } + + /** + * Prepare links for the response. + * + * @param WP_REST_Response $response Response. + * @param WP_REST_Request $request Request. + * @param JS_Widgets_REST_Controller $controller Controller. + * @return array Links for the given post. + */ + public function get_rest_response_links( $response, $request, $controller ) { + $links = array(); + + $links['wp:post'] = array(); + foreach ( $response->data['posts'] as $post_id ) { + $post = get_post( $post_id ); + if ( empty( $post ) ) { + continue; + } + $obj = get_post_type_object( $post->post_type ); + if ( empty( $obj ) ) { + continue; + } + + $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; + $base = sprintf( '/wp/v2/%s', $rest_base ); + + $links['wp:post'][] = array( + 'href' => rest_url( trailingslashit( $base ) . $post_id ), + 'embeddable' => true, + 'post_type' => $post->post_type, + ); + } + return $links; + } + + /** + * Render JS Template. + */ + public function form_template() { + $item_schema = $this->get_item_schema(); + ?> + + array( + 'description' => __( 'The RSS feed URL.', 'js-widgets' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'edit' ), + 'default' => '', + 'arg_options' => array( + 'validate_callback' => array( $this, 'validate_feed_url' ), + ), + ), + 'error' => array( + 'description' => __( 'Any error when fetching the feed.', 'js-widgets' ), + 'type' => array( 'boolean', 'string' ), + 'readonly' => true, + 'context' => array( 'edit' ), + 'default' => false, + ), + 'items' => array( + 'description' => __( 'The number of RSS items to display.', 'js-widgets' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'minimum' => 1, + 'default' => 10, + 'maximum' => 20, + ), + 'show_summary' => array( + 'description' => __( 'Whether the summary should be shown.', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'show_author' => array( + 'description' => __( 'Whether the author should be shown.', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'show_date' => array( + 'description' => __( 'Whether the date should be shown.', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'rss_items' => array( + 'description' => __( 'The RSS items.', 'js-widgets' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + 'default' => array(), + ), + ) + ); + $schema['title']['properties']['raw']['default'] = ''; + return $schema; + } + + /** + * Validate a request argument based on details registered to the route. + * + * @param string $url Feed URL. + * @param WP_REST_Request $request Request. + * @param string $param Param name. + * @return WP_Error|boolean + */ + function validate_feed_url( $url, $request, $param ) { + $validity = rest_validate_request_arg( $url, $request, $param ); + if ( true === $validity ) { + if ( ! esc_url_raw( $url, array( 'http', 'https' ) ) ) { + return new WP_Error( 'invalid_url_protocol', __( 'Invalid URL protocol. Expected HTTP or HTTPS.', 'js-widgets' ) ); + } + } + return $validity; + } + + /** + * Sanitize instance data. + * + * Prevent an RSS widget with a feed URL with a fetch failure from being saved by invalidating the instance data. + * + * @inheritdoc + * + * @param array $new_instance New instance. + * @param array $old_instance Old instance. + * @return array|null|WP_Error Array instance if sanitization (and validation) passed. Returns `WP_Error` or `null` on failure. + */ + public function sanitize( $new_instance, $old_instance ) { + $instance = parent::sanitize( $new_instance, $old_instance ); + if ( is_array( $instance ) && ! empty( $instance['error'] ) ) { + return new WP_Error( 'fetch_feed_failure', $instance['error'] ); + } + return $instance; + } + + /** + * Render a widget instance for a REST API response. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $instance = array_merge( $this->get_default_instance(), $instance ); + + $item = array_merge( + parent::prepare_item_for_response( $instance, $request ), + wp_array_slice_assoc( $instance, array( + 'url', + 'items', + 'show_summary', + 'show_author', + 'show_date', + 'error', // This should always be false. + ) ) + ); + + if ( ! empty( $item['url'] ) && empty( $item['error'] ) ) { + $feed = $this->fetch_feed_only( $item['url'] ); + if ( ! is_wp_error( $feed ) ) { + foreach ( array_slice( $feed->get_items(), 0, $instance['items'] ) as $rss_item_obj ) { + /** + * RSS Item. + * + * @var SimplePie_Item $rss_item_obj + */ + $rss_item = array( + 'title' => $rss_item_obj->get_title(), + 'link' => $rss_item_obj->get_link(), + ); + if ( $instance['show_summary'] ) { + $rss_item['summary'] = html_entity_decode( $rss_item_obj->get_description(), ENT_QUOTES, 'utf-8' ); + } + if ( $instance['show_author'] ) { + $rss_item['author'] = $rss_item_obj->get_author()->name; + } + if ( $instance['show_date'] ) { + $rss_item['date'] = $rss_item_obj->get_date( 'c' ); + } + $item['rss_items'][] = $rss_item; + } + } + } + + return $item; + } + + /** + * Fetch feed. + * + * This is adapted from `fetch_feed()` to ensure that no headers are sent. + * + * @see fetch_feed() + * @link https://github.com/xwp/wordpress-develop/blob/16e8d82c8919070b65736b897df8540ca472a934/src/wp-includes/feed.php#L660-L711 + * + * @param string $url Feed URL. + * @return SimplePie|WP_Error Object or error. + */ + public function fetch_feed_only( $url ) { + if ( ! class_exists( 'SimplePie', false ) ) { + require_once( ABSPATH . WPINC . '/class-simplepie.php' ); + } + + require_once( ABSPATH . WPINC . '/class-wp-feed-cache.php' ); + require_once( ABSPATH . WPINC . '/class-wp-feed-cache-transient.php' ); + require_once( ABSPATH . WPINC . '/class-wp-simplepie-file.php' ); + require_once( ABSPATH . WPINC . '/class-wp-simplepie-sanitize-kses.php' ); + + $feed = new SimplePie(); + + $feed->set_sanitize_class( 'WP_SimplePie_Sanitize_KSES' ); + + /* + * We must manually overwrite $feed->sanitize because SimplePie's + * constructor sets it before we have a chance to set the sanitization class + */ + $feed->sanitize = new WP_SimplePie_Sanitize_KSES(); + + $feed->set_cache_class( 'WP_Feed_Cache' ); + $feed->set_file_class( 'WP_SimplePie_File' ); + + $feed->set_feed_url( $url ); + + /** This filter is documented in wp-includes/class-wp-feed-cache-transient.php */ + $feed->set_cache_duration( apply_filters( 'wp_feed_cache_transient_lifetime', 12 * HOUR_IN_SECONDS, $url ) ); + + /** This action is documented in wp-includes/feed.php */ + do_action_ref_array( 'wp_feed_options', array( &$feed, $url ) ); + $feed->init(); + + if ( $feed->error() ) { + return new WP_Error( 'simplepie-error', $feed->error() ); + } + + return $feed; + } + + /** + * Render JS Template. + */ + public function form_template() { + $item_schema = $this->get_item_schema(); + ?> + + true ), 'names' ); + if ( ! get_option( 'link_manager_enabled' ) ) { + unset( $taxonomies['link_category'] ); + } + $taxonomies = array_values( $taxonomies ); + + $schema = array_merge( + parent::get_item_schema(), + array( + 'taxonomy' => array( + 'description' => __( 'Taxonomy', 'js-widgets' ), + 'type' => 'string', + 'enum' => $taxonomies, + 'default' => 1 === count( $taxonomies ) ? current( $taxonomies ) : 'post_tag', + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'validate_callback' => array( $this, 'validate_taxonomy' ), + ), + ), + 'tag_links' => array( + 'description' => __( 'List of objects containing attributes for tag links generated by wp_generate_tag_cloud().', 'js-widgets' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + 'default' => array(), + ), + ) + ); + $schema['title']['properties']['raw']['default'] = ''; + return $schema; + } + + /** + * Validate taxonomy. + * + * Block a tag cloud widget from being saved if there are no tag cloud taxonomies. + * In reality, it would probably be better to just prevent the widget from being + * registered entirely. + * + * @param string $taxonomy Taxonomy. + * @param WP_REST_Request $request Request. + * @param string $param Param. + * @return true|WP_Error True if taxonomy is valid, or WP_Error. + */ + public function validate_taxonomy( $taxonomy, $request, $param ) { + $validity = rest_validate_request_arg( $taxonomy, $request, $param ); + if ( true !== $validity ) { + return $validity; + } + 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; + } + + /** + * Render a widget instance for a REST API response. + * + * @inheritdoc + * + * Code adapted from `WP_Widget_Tag_Cloud::widget()` and `wp_tag_cloud()`. + * + * @see WP_Widget_Tag_Cloud::widget() + * @see wp_tag_cloud() + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + if ( empty( $instance['title'] ) ) { + if ( 'post_tag' === $instance['taxonomy'] ) { + $instance['title'] = __( 'Tags', 'default' ); + } else { + $instance['title'] = get_taxonomy( $instance['taxonomy'] )->labels->name; + } + } + + $item = parent::prepare_item_for_response( $instance, $request ); + + /** This filter is documented in wp-includes/widgets/class-wp-widget-tag-cloud.php */ + $tag_cloud_args = apply_filters( 'widget_tag_cloud_args', array( + 'taxonomy' => $item['taxonomy'], + ) ); + + $tag_cloud_args = wp_parse_args( $tag_cloud_args, array( + 'smallest' => 8, + 'largest' => 22, + 'number' => 45, + 'format' => 'flat', + 'orderby' => 'name', + 'order' => 'ASC', + 'exclude' => '', + 'include' => '', + 'link' => 'view', + 'taxonomy' => 'post_tag', + 'post_type' => '', + 'separator' => '', + ) ); + + $tags = get_terms( + $tag_cloud_args['taxonomy'], + array_merge( $tag_cloud_args, array( 'orderby' => 'count', 'order' => 'DESC' ) ) // Always query top tags. + ); + + // @todo should these not be _links? + $item['tag_links'] = array(); + if ( ! empty( $tags ) && ! is_wp_error( $tags ) ) { + foreach ( $tags as $key => $tag ) { + $link = get_term_link( intval( $tag->term_id ), $tag->taxonomy ); + if ( ! is_wp_error( $link ) ) { + $tags[ $key ]->link = $link; + $tags[ $key ]->id = $tag->term_id; + } + } + $tag_cloud = wp_generate_tag_cloud( $tags, $tag_cloud_args ); + if ( ! empty( $tag_cloud ) ) { + $doc = new DOMDocument(); + $doctype = sprintf( '', get_bloginfo( 'charset' ) ); + $doc->loadHTML( $doctype . $tag_cloud ); + foreach ( $doc->getElementsByTagName( 'a' ) as $link ) { + $link_data = array( + 'label' => $link->textContent, + ); + foreach ( $link->attributes as $attribute ) { + $link_data[ $attribute->nodeName ] = $attribute->nodeValue; + } + $item['tag_links'][] = $link_data; + } + } + } + + return $item; + } + + /** + * Render JS Template. + */ + public function form_template() { + $item_schema = $this->get_item_schema(); + ?> + + array( + 'description' => __( 'The content for the widget.', 'js-widgets' ), + 'type' => array( 'string', 'object' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'properties' => array( + 'raw' => array( + 'description' => __( 'Content for the widget, as it exists in the database.', 'js-widgets' ), + 'type' => 'string', + 'context' => array( 'edit' ), + 'required' => true, + 'default' => '', + ), + 'rendered' => array( + 'description' => __( 'HTML content for the widget, transformed for display.', 'js-widgets' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ), + 'filter' => array( + 'description' => __( 'Whether paragraphs will be added for double line breaks (wpautop).', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'edit' ), + ), + ) + ); + $schema['title']['properties']['raw']['default'] = ''; + return $schema; + } + + /** + * Render a widget instance for a REST API response. + * + * Map the instance data to the REST resource fields and add rendered fields. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $instance = array_merge( $this->get_default_instance(), $instance ); + + /** This filter is documented in src/wp-includes/widgets/class-wp-widget-text.php */ + $content_rendered = apply_filters( 'widget_text', $instance['text'], $instance, $this->adapted_widget ); + if ( ! empty( $instance['filter'] ) ) { + $content_rendered = wpautop( $content_rendered ); + } + + $item = array_merge( + parent::prepare_item_for_response( $instance, $request ), + array( + 'text' => array( + 'raw' => $instance['text'], + 'rendered' => $content_rendered, + ), + 'filter' => ! empty( $instance['filter'] ), + ) + ); + + return $item; + } + + /** + * Render JS Template. + * + * This template is intended to be agnostic to the JS template technology used. + */ + public function form_template() { + ?> + + __( 'Protected HTML such as script tags will be stripped from the content.', 'js-widgets' ), + ) + ); + return $args; + } +} diff --git a/core-adapter-widgets/text/form.js b/core-adapter-widgets/text/form.js new file mode 100644 index 0000000..a708cc3 --- /dev/null +++ b/core-adapter-widgets/text/form.js @@ -0,0 +1,62 @@ +/* global wp, module */ +/* eslint consistent-this: [ "error", "form" ] */ +/* eslint-disable strict */ +/* eslint-disable complexity */ + +wp.customize.Widgets.formConstructor.text = (function( api, $ ) { + 'use strict'; + + var TextWidgetForm; + + /** + * Text Widget Form. + * + * @constructor + */ + TextWidgetForm = api.Widgets.Form.extend({ + + /** + * Sanitize the instance data. + * + * @param {object} oldInstance Unsanitized instance. + * @returns {object} Sanitized instance. + */ + sanitize: function( newInstance, oldInstance ) { + var form = this, instance, code, notification; + + instance = api.Widgets.Form.prototype.sanitize.call( form, newInstance, oldInstance ); + + if ( ! instance.text ) { + instance.text = ''; + } + + // Warn about unfiltered HTML. + if ( ! form.config.can_unfiltered_html ) { + code = 'unfilteredHtmlInvalid'; + if ( /<\/?(script|iframe)[^>]*>/i.test( instance.text ) ) { + notification = new api.Notification( code, { + message: form.config.l10n.text_unfiltered_html_invalid, + type: 'warning' + } ); + form.setting.notifications.add( code, notification ); + } else { + form.setting.notifications.remove( code ); + } + } + + /* + * Trim per sanitize_text_field(). + * Protip: This prevents the widget partial from refreshing after adding a space or adding a new paragraph. + */ + instance.text = $.trim( instance.text ); + + return instance; + } + }); + + if ( 'undefined' !== typeof module ) { + module.exports = TextWidgetForm; + } + return TextWidgetForm; + +})( wp.customize, jQuery ); diff --git a/dev-lib b/dev-lib index 30b4b22..c061303 160000 --- a/dev-lib +++ b/dev-lib @@ -1 +1 @@ -Subproject commit 30b4b229691a9f4b006ded13c738a06961085887 +Subproject commit c061303c696a327861604fc781c7585c76d24ade diff --git a/js-widgets.php b/js-widgets.php index ffb9597..12f2e70 100644 --- a/js-widgets.php +++ b/js-widgets.php @@ -3,12 +3,12 @@ * 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.1.1 - * Author: XWP + * Version: 0.2.0 + * Author: Weston Ruter, XWP * Author URI: https://make.xwp.co/ * License: GPLv2+ * - * @package JSWidgets + * @package JS_Widgets */ /* @@ -29,11 +29,7 @@ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ -require_once __DIR__ . '/php/class-js-widgets-plugin.php'; -require_once __DIR__ . '/php/class-wp-js-widget.php'; -require_once __DIR__ . '/php/widgets/class-wp-js-widget-text.php'; -require_once __DIR__ . '/php/widgets/class-wp-js-widget-recent-posts.php'; -require_once __DIR__ . '/php/widgets/class-wp-js-widget-post-collection.php'; +require_once dirname( __FILE__ ) . '/php/class-js-widgets-plugin.php'; global $js_widgets_plugin; $js_widgets_plugin = new JS_Widgets_Plugin(); diff --git a/js/customize-js-widgets.js b/js/customize-js-widgets.js index b3d7e89..5a52117 100644 --- a/js/customize-js-widgets.js +++ b/js/customize-js-widgets.js @@ -27,20 +27,54 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- _.extend( component.data, data ); } component.extendWidgetControl(); + + // Handle (re-)adding a (previously-removed) control. + api.control.bind( 'add', function( addedControl ) { + if ( component.isJsWidgetControl( addedControl ) && addedControl.widgetContentEmbedded ) { + addedControl.form.render(); + } + } ); + + // Destruct (unmount) a form when a widget control is removed. + api.control.bind( 'remove', function( removedControl ) { + if ( component.isJsWidgetControl( removedControl ) && removedControl.widgetContentEmbedded ) { + removedControl.form.destruct(); + } + } ); + }; + + /** + * Determine whether the given control is a JS Widget. + * + * @param {wp.customize.Control} widgetControl Widget control. + * @returns {boolean} Whether the control is a JS widget. + */ + component.isJsWidgetControl = function isJsWidgetControl( widgetControl ) { + return widgetControl.extended( api.Widgets.WidgetControl ) && component.data.id_bases[ widgetControl.params.widget_id_base ]; }; /** * Inject WidgetControl instances with our component.WidgetControl method overrides. + * + * @returns {void} */ component.extendWidgetControl = function extendWidgetControl() { + + /** + * Initialize JS widget control. + * + * @param {string} id Control ID. + * @param {object} options Control options. + * @returns {void} + */ api.Widgets.WidgetControl.prototype.initialize = function initializeWidgetControl( id, options ) { - var control = this; - control.isCustomizeControl = options.params.widget_id_base && component.data.id_bases[ options.params.widget_id_base ]; - if ( control.isCustomizeControl ) { + var control = this, isJsWidget; + isJsWidget = options.params.widget_id_base && component.data.id_bases[ options.params.widget_id_base ]; + if ( isJsWidget ) { _.extend( control, component.WidgetControl.prototype ); - return component.WidgetControl.prototype.initialize.call( control, id, options ); + component.WidgetControl.prototype.initialize.call( control, id, options ); } else { - return originalInitialize.call( control, id, options ); + originalInitialize.call( control, id, options ); } }; }; @@ -68,12 +102,12 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @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. * @param {string} [options.params.widget_content] - Handled internally, if supplied, an error will be thrown. - * @returns {*} + * @returns {void} */ - initialize: function( id, options ) { + initialize: function initializeWidgetControl( id, options ) { var control = this, elementId, elementClass, availableWidget, widgetNumber, widgetControlWrapperMarkup; - // @todo The arguments supplied via addWidget can just be ignored for Customize Widgets. + // @todo The arguments supplied via addWidget can just be ignored for JS Widgets. if ( ! options.params.widget_id ) { throw new Error( 'Missing widget_id param.' ); @@ -120,13 +154,15 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- } ); options.params.widget_control = widgetControlWrapperMarkup; - return originalInitialize.call( control, id, options ); + originalInitialize.call( control, id, options ); }, /** * Embed the actual widget form inside of .widget-content and finally trigger the widget-added event. + * + * @returns {void} */ - embedWidgetContent: function() { + embedWidgetContent: function embedWidgetContent() { var control = this, Form, widgetContent, formContainer; Form = api.Widgets.formConstructor[ control.params.widget_id_base ]; @@ -146,6 +182,7 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- container: formContainer, config: component.data.form_configs[ control.params.widget_id_base ] } ); + control.form.render(); // @todo What about extra inputs that are added via the in_widget_form action? These basically cannot be supported. @@ -162,8 +199,10 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * This mostly removes code located in `wp.customize.Widgets.WidgetControl.prototype._setupModel`, * as most of the code there is made obsolete by `wp.customize.Widgets.Form` which is responsible * for re-rendering the form when when the setting changes. + * + * @returns {void} */ - _setupModel: function() { + _setupModel: function _setupModel() { var control = this, rememberSavedWidgetId; // Remember saved widgets so we know which to trash (move to inactive widgets sidebar) @@ -181,11 +220,13 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * Override WidgetControl logic for setting up event handlers for widget updating. * * This is now handled entirely in the wp.customize.Widgets.Form instance. + * + * @returns {void} */ - _setupUpdateUI: function() { + _setupUpdateUI: function _setupUpdateUI() { var control = this, saveBtn; - // The save button is totally unused in Customize Widgets, so make it more disabled. + // The save button is totally unused in JS Widgets, so make it more disabled. saveBtn = control.container.find( '.widget-control-save' ); saveBtn.prop( 'disabled', true ); saveBtn.prop( 'hidden', true ); @@ -202,8 +243,9 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @param {object} [args] * @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} */ - updateWidget: function( args ) { + updateWidget: function updateWidget( args ) { var control = this; // The updateWidget logic requires that the form fields to be fully present. @@ -226,7 +268,7 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @deprecated * @private */ - _getInputs: function() { + _getInputs: function _getInputs() { throw new Error( 'The _getInputs method should not be called for customize widget instances.' ); }, @@ -238,7 +280,7 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @deprecated * @private */ - _getInputsSignature: function() { + _getInputsSignature: function _getInputsSignature() { throw new Error( 'The _getInputsSignature method should not be called for customize widget instances.' ); }, @@ -250,7 +292,7 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @deprecated * @private */ - _getInputState: function() { + _getInputState: function _getInputState() { throw new Error( 'The _getInputState method should not be called for customize widget instances.' ); }, @@ -262,7 +304,7 @@ var CustomizeJSWidgets = (function( api, $ ) { // eslint-disable-line no-unused- * @deprecated * @private */ - _setInputState: function() { + _setInputState: function _setInputState() { throw new Error( 'The _setInputState method should not be called for customize widget instances.' ); } }); diff --git a/js/customize-widget-control-form.js b/js/customize-widget-control-form.js index 9ae3ccb..1aa4a39 100644 --- a/js/customize-widget-control-form.js +++ b/js/customize-widget-control-form.js @@ -3,21 +3,26 @@ /* eslint consistent-this: [ "error", "form" ] */ /* eslint-disable complexity */ -wp.customize.Widgets.Form = (function( api ) { +wp.customize.Widgets.Form = (function( api, $ ) { 'use strict'; /** * Customize Widget Form. * - * @todo Should this be not a wp.customize.Class instance so that we can re-use it more easily in Shortcake and elsewhere? Customize Widget Proxy? - * @todo This can be a proxy/adapter for a more abstract form which is unaware of the Customizer specifics. - * * @constructor * @augments wp.customize.Widgets.WidgetControl */ return api.Class.extend({ - initialize: function( properties ) { + /** + * Initialize. + * + * @param {object} properties Properties. + * @param {wp.customize.Widgets.WidgetControl} properties.control Customize control. + * @param {object} properties.config Form config. + * @return {void} + */ + initialize: function initialize( properties ) { var form = this, args, previousValidate; args = _.extend( @@ -31,6 +36,9 @@ wp.customize.Widgets.Form = (function( api ) { properties ); + if ( ! args.control || ! args.control.extended( wp.customize.Widgets.WidgetControl ) ) { + throw new Error( 'Missing control param.' ); + } form.control = args.control; form.setting = form.control.setting; form.config = args.config; @@ -40,8 +48,17 @@ wp.customize.Widgets.Form = (function( api ) { } previousValidate = form.setting.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.setting.validate = function validate( value ) { - var setting = this, newValue, oldValue; // eslint-disable-line consistent-this + var setting = this, newValue, oldValue, error, code, notification; // eslint-disable-line consistent-this newValue = _.extend( {}, form.config.default_instance, value ); oldValue = _.extend( {}, setting() ); @@ -49,52 +66,79 @@ wp.customize.Widgets.Form = (function( api ) { 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; + } - // @todo Show error. + // If sanitize method returned an error/notification, block setting u0date. + if ( notification ) { newValue = null; } - return newValue; - }; + // Sync the notification into the setting's notifications collection. + if ( form.setting.notifications ) { - form.setting.bind( function() { - form.render(); - } ); - }, + // Remove all existing notifications added via sanitization since only one can be returned. + form.setting.notifications.each( function iterateNotifications( iteratedNotification ) { + if ( iteratedNotification.viaWidgetFormSanitizeReturn && ( ! notification || notification.code !== iteratedNotification.code ) ) { + form.setting.notifications.remove( iteratedNotification.code ); + } + } ); - /** - * Set validation message. - * - * See Customize Setting Validation plugin. - * - * @link https://github.com/xwp/wp-customize-setting-validation - * @link https://make.wordpress.org/core/2016/05/04/improving-setting-validation-in-the-customizer/ - * @link https://core.trac.wordpress.org/ticket/34893 - * - * @param {string} message Message. - * @returns {void} - */ - setValidationMessage: function( message ) { - var form = this; - if ( form.control.setting.validationMessage ) { - form.control.setting.validationMessage.set( message || '' ); - } else if ( message && 'undefined' !== typeof console && console.warn ) { - console.warn( message ); - } + // Add the new notification. + if ( notification ) { + notification.viaWidgetFormSanitizeReturn = true; + form.setting.notifications.add( notification.code, notification ); + } + } + + return newValue; + }; }, /** - * Sanitize widget instance data. + * Sanitize the instance data. * * @param {object} newInstance New instance. * @param {object} oldInstance Existing instance. - * @returns {object} Sanitized instance. + * @returns {object|Error|wp.customize.Notification} Sanitized instance or validation error/notification. */ - sanitize: function( newInstance, oldInstance ) { - if ( ! oldInstance ) { + sanitize: function sanitize( newInstance, oldInstance ) { + var form = this, instance, code, notification; + if ( _.isUndefined( oldInstance ) ) { throw new Error( 'Expected oldInstance' ); } - return newInstance; + instance = _.extend( {}, form.config.default_instance, newInstance ); + + if ( ! instance.title ) { + instance.title = ''; + } + + // Warn about markup in title. + code = 'markupTitleInvalid'; + if ( /<\/?\w+[^>]*>/.test( instance.title ) ) { + notification = new api.Notification( code, { + message: form.config.l10n.title_tags_invalid, + type: 'warning' + } ); + form.setting.notifications.add( code, notification ); + } else { + form.setting.notifications.remove( code ); + } + + /* + * 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 ); + + return instance; }, /** @@ -104,7 +148,7 @@ wp.customize.Widgets.Form = (function( api ) { * * @return {object} Instance. */ - getValue: function() { + getValue: function getValue() { var form = this; return _.extend( {}, @@ -121,7 +165,7 @@ wp.customize.Widgets.Form = (function( api ) { * @param {object} props Instance props. * @returns {void} */ - setState: function( props ) { + setState: function setState( props ) { var form = this, value; value = _.extend( form.getValue(), props || {} ); form.setting.set( value ); @@ -138,32 +182,123 @@ wp.customize.Widgets.Form = (function( api ) { * * @param {wp.customize.Value} root Root value instance. * @param {string} property Property name. - * @returns {wp.customize.Value} Property value instance. + * @returns {object} Property value instance. */ createSyncedPropertyValue: function createSyncedPropertyValue( root, property ) { - var propertyValue = new api.Value( root.get()[ property ] ); + var propertyValue, rootChangeListener, propertyChangeListener; + + propertyValue = new api.Value( root.get()[ property ] ); // Sync changes to the property back to the root value. - propertyValue.bind( function updatePropertyValue( newPropertyValue ) { + propertyChangeListener = function( newPropertyValue ) { var rootValue = _.clone( root.get() ); rootValue[ property ] = newPropertyValue; root.set( rootValue ); - } ); + }; + propertyValue.bind( propertyChangeListener ); // Sync changes in the root value to the model. - root.bind( function updateRootValue( newRootValue, oldRootValue ) { + rootChangeListener = function updateRootValue( newRootValue, oldRootValue ) { if ( ! _.isEqual( newRootValue[ property ], oldRootValue[ property ] ) ) { propertyValue.set( newRootValue[ property ] ); } + }; + root.bind( rootChangeListener ); + + return { + value: propertyValue, + propertyChangeListener: propertyChangeListener, + rootChangeListener: rootChangeListener + }; + }, + + /** + * Create elements to link setting value properties with corresponding inputs in the form. + * + * @returns {void} + */ + linkPropertyElements: function linkPropertyElements() { + var form = this, initialInstanceData; + initialInstanceData = form.getValue(); + form.syncedProperties = {}; + form.container.find( ':input[name]' ).each( function() { + var input = $( this ), name = input.prop( 'name' ), syncedProperty; + if ( _.isUndefined( initialInstanceData[ name ] ) ) { + return; + } + + syncedProperty = form.createSyncedPropertyValue( form.setting, name ); + syncedProperty.element = new api.Element( input ); + syncedProperty.element.set( initialInstanceData[ name ] ); + syncedProperty.element.sync( syncedProperty.value ); + form.syncedProperties[ name ] = syncedProperty; } ); + }, - return propertyValue; + /** + * Unlink setting value properties with corresponding inputs in the form. + * + * @returns {void} + */ + unlinkPropertyElements: function unlinkPropertyElements() { + var form = this; + _.each( form.syncedProperties, function( syncedProperty ) { + syncedProperty.element.unsync( syncedProperty.value ); + form.setting.unbind( syncedProperty.rootChangeListener ); + syncedProperty.value.callbacks.remove(); + } ); + form.syncedProperties = {}; }, - embed: function() {}, + /** + * Get template function. + * + * @returns {Function} Template function. + */ + getTemplate: function getTemplate() { + var form = this; + if ( ! form._template ) { + form._template = wp.template( 'customize-widget-form-' + form.control.params.widget_id_base ); + } + return form._template; + }, - render: function() {} + /** + * Embed. + * + * @deprecated + * @returns {void} + */ + embed: function embed() { + if ( 'undefined' !== typeof console ) { + console.warn( 'wp.customize.Widgets.Form#embed is deprecated.' ); + } + this.render(); + }, + + /** + * Render (mount) the form into the container. + * + * @returns {void} + */ + render: function render() { + var form = this, template = form.getTemplate(); + form.container.html( template( form ) ); + form.linkPropertyElements(); + }, + /** + * Destruct (unrender/unmount) the form. + * + * Subclasses can do cleanup of event listeners on other components, + * + * @returns {void} + */ + destruct: function destroy() { + var form = this; + form.container.empty(); + form.unlinkPropertyElements(); + } }); -} )( wp.customize ); +} )( wp.customize, jQuery ); diff --git a/js/trac-39389-controls.js b/js/trac-39389-controls.js new file mode 100644 index 0000000..9ae9fdb --- /dev/null +++ b/js/trac-39389-controls.js @@ -0,0 +1,39 @@ +/* global wp */ +/* eslint-disable strict */ +/* eslint consistent-this: [ "error", "partial" ] */ + +(function( api ) { + 'use strict'; + + var component = { + + /** + * Init component. + * + * @returns {void} + */ + init: function init() { + api.control.each( component.handleControlAddition ); + api.control.bind( 'add', component.handleControlAddition ); + }, + + /** + * Handle control addition. + * + * @param {wp.customize.Control} control Control. + * @returns {void} + */ + handleControlAddition: function handleControlAddition( control ) { + if ( ! control.extended( api.Widgets.WidgetControl ) ) { + return; + } + control.expanded.bind( function handleControlExpandedChange( isExpanded ) { + if ( isExpanded ) { + api.previewer.send( 'scroll-setting-related-partial-into-view', control.setting.id ); + } + } ); + } + }; + + api.bind( 'ready', component.init ); +} )( wp.customize ); diff --git a/js/trac-39389-preview.js b/js/trac-39389-preview.js new file mode 100644 index 0000000..69f89d5 --- /dev/null +++ b/js/trac-39389-preview.js @@ -0,0 +1,60 @@ +/* global wp, jQuery */ +/* eslint-disable strict */ +/* eslint consistent-this: [ "error", "partial" ] */ +/* eslint-disable complexity */ + +(function( api, WidgetPartial, $ ) { + 'use strict'; + + if ( ! WidgetPartial || WidgetPartial.prototype.scrollIntoView ) { + return; + } + + /** + * Scroll a widget placement container into view. + * + * @since 4.8.0 + * + * @param {Placement} [placement] Placement, if not provided then the first found placement will be used. + * @returns {void} + */ + WidgetPartial.prototype.scrollIntoView = function scrollIntoView( placement ) { + var partial = this, container, docViewTop, docViewBottom, elemTop, elemBottom, selectedPlacement; + selectedPlacement = placement || partial.placements()[0]; + if ( ! selectedPlacement ) { + return; + } + container = $( selectedPlacement.container ); + if ( ! container[0] ) { + return; + } + if ( container[0].scrollIntoViewIfNeeded ) { + container[0].scrollIntoViewIfNeeded(); + } else { + + // Props http://stackoverflow.com/a/488073/93579 + docViewTop = $( window ).scrollTop(); + docViewBottom = docViewTop + $( window ).height(); + elemTop = container.offset().top; + elemBottom = elemTop + container.height(); + if ( elemBottom > docViewBottom || elemTop < docViewTop ) { + container[0].scrollIntoView( elemTop < docViewTop ); + } + } + }; + + api.bind( 'preview-ready', function() { + api.preview.bind( 'scroll-setting-related-partial-into-view', function( settingId ) { + var relatedPartials = []; + api.selectiveRefresh.partial.each( function partialIterate( iteratedPartial ) { + if ( -1 !== iteratedPartial.params.settings.indexOf( settingId ) && iteratedPartial.scrollIntoView ) { + relatedPartials.push( iteratedPartial ); + } + } ); + if ( relatedPartials[0] ) { + relatedPartials[0].scrollIntoView(); + } + } ); + } ); + +} )( wp.customize, wp.customize.widgetsPreview.WidgetPartial, jQuery ); diff --git a/js/widgets/customize-widget-post-collection.js b/js/widgets/customize-widget-post-collection.js deleted file mode 100644 index c6c19d1..0000000 --- a/js/widgets/customize-widget-post-collection.js +++ /dev/null @@ -1,121 +0,0 @@ -/* global wp, module */ -/* eslint consistent-this: [ "error", "form" ] */ -/* eslint no-magic-numbers: [ "error", {"ignore":[0,1]} ] */ -/* eslint-disable strict */ -/* eslint-disable complexity */ - -wp.customize.Widgets.formConstructor['post-collection'] = (function( api, $ ) { - 'use strict'; - - var PostCollectionWidgetForm; - - /** - * Post Collection Widget Form. - * - * @constructor - */ - PostCollectionWidgetForm = api.Widgets.Form.extend({ - - /** - * Initialize. - * - * @param {object} properties Properties. - * @param {wp.customize.Widgets.WidgetControl} properties.control Customize control. - * @param {object} properties.config Form config. - * @return {void} - */ - initialize: function( properties ) { - var form = this; - - api.Widgets.Form.prototype.initialize.call( form, properties ); - - form.embed(); - }, - - /** - * Embed the form from the template and set up event handlers. - * - * @return {void} - */ - embed: function() { - var form = this, elementIdBase = 'el' + String( Math.random() ), initialInstanceData; - - form.template = wp.template( 'customize-widget-post-collection' ); - form.container.html( form.template( { - element_id_base: elementIdBase - } ) ); - - if ( ! api.ObjectSelectorComponent ) { - return; - } - - form.propertyValues = { - posts: form.createSyncedPropertyValue( form.setting, 'posts' ) - }; - - form.postsItemTemplate = wp.template( 'customize-widget-post-collection-select2-option' ); - form.postObjectSelector = new api.ObjectSelectorComponent({ - model: form.propertyValues.posts, - containing_construct: form.control, - post_query_vars: form.config.post_query_args, - select2_options: _.extend( - { - multiple: true, - width: '100%' - }, - form.config.select2_options - ), - select_id: elementIdBase + '_posts', - select2_result_template: form.postsItemTemplate, - select2_selection_template: form.postsItemTemplate - }); - - initialInstanceData = form.getValue(); - form.elements = {}; - form.container.find( ':input[name]' ).each( function() { - var input = $( this ), name = input.prop( 'name' ), propertyValue, propertyElement; - if ( _.isUndefined( initialInstanceData[ name ] ) ) { - return; - } - propertyValue = form.createSyncedPropertyValue( form.setting, name ); - propertyElement = new wp.customize.Element( input ); - propertyElement.set( initialInstanceData[ name ] ); - propertyElement.sync( propertyValue ); - - form.propertyValues[ name ] = propertyValue; - form.elements[ name ] = propertyElement; - - } ); - - form.postObjectSelector.embed( form.container.find( '.customize-object-selector-container:first' ) ); - }, - - /** - * Sanitize the instance data. - * - * @param {object} oldInstance Unsanitized instance. - * @returns {object} Sanitized instance. - */ - sanitize: function( oldInstance ) { - var form = this, newInstance; - newInstance = _.extend( {}, oldInstance ); - - if ( ! newInstance.title ) { - newInstance.title = ''; - } - - // Warn about markup in title. - if ( /<\/?\w+[^>]*>/.test( newInstance.title ) ) { - form.setValidationMessage( form.config.l10n.title_tags_invalid ); - } - - return newInstance; - } - }); - - if ( 'undefined' !== typeof module ) { - module.exports = PostCollectionWidgetForm; - } - return PostCollectionWidgetForm; - -})( wp.customize, jQuery ); diff --git a/js/widgets/customize-widget-recent-posts.js b/js/widgets/customize-widget-recent-posts.js deleted file mode 100644 index db2466b..0000000 --- a/js/widgets/customize-widget-recent-posts.js +++ /dev/null @@ -1,121 +0,0 @@ -/* global wp, module, React, ReactDOM, Redux, RecentPostsWidgetFormReactComponent */ -/* eslint consistent-this: [ "error", "form" ] */ -/* eslint-disable strict */ -/* eslint-disable complexity */ - -wp.customize.Widgets.formConstructor['recent-posts'] = (function( api ) { - 'use strict'; - - var RecentPostsWidgetForm; - - /** - * Text Widget Form. - * - * @constructor - */ - RecentPostsWidgetForm = api.Widgets.Form.extend({ - - /** - * Initialize. - * - * @param {object} properties Properties. - * @param {wp.customize.Widgets.WidgetControl} properties.control Customize control. - * @param {object} properties.config Form config. - * @return {void} - */ - initialize: function( properties ) { - var form = this; - - api.Widgets.Form.prototype.initialize.call( form, properties ); - - form.store = Redux.createStore( form.reducer, form.getValue() ); - - // Sync changes to the Customizer setting into the store. - form.control.setting.bind( function( instance ) { - form.store.dispatch( { - type: 'UPDATE', - props: instance - } ); - } ); - - // Sync changes to the store into the Customizer setting. - form.store.subscribe( function() { - form.control.setting.set( form.store.getState() ); - } ); - - form.store.subscribe( function() { - form.render(); - } ); - - form.render(); - }, - - /** - * Render and update the form. - * - * @returns {void} - */ - render: function() { - var form = this; - form.reactElement = React.createElement( RecentPostsWidgetFormReactComponent, { - labelTitle: form.config.l10n.label_title, - placeholderTitle: form.config.l10n.placeholder_title, - labelNumber: form.config.l10n.label_number, - labelShowDate: form.config.l10n.label_show_date, - minimumNumber: form.config.minimum_number, - store: form.store - } ); - form.reactComponent = ReactDOM.render( form.reactElement, form.container[0] ); - }, - - /** - * Redux reducer. - * - * See sanitize method for where the business logic for the form is handled. - * - * @param {object} oldState Old state. - * @param {object} action Action object. - * @param {string} action.type Action type. - * @param {mixed} action.props Value. - * @returns {object} New state. - */ - reducer: function( oldState, action ) { - var amendedState = {}; - if ( 'UPDATE' === action.type ) { - _.extend( amendedState, action.props ); - } - return _.extend( {}, oldState || {}, amendedState ); - }, - - /** - * Sanitize the instance data. - * - * @param {object} oldInstance Unsanitized instance. - * @returns {object} Sanitized instance. - */ - sanitize: function( oldInstance ) { - var form = this, newInstance; - newInstance = _.extend( {}, oldInstance ); - - if ( ! newInstance.title ) { - newInstance.title = ''; - } - - // Warn about markup in title. - if ( /<\/?\w+[^>]*>/.test( newInstance.title ) ) { - form.setValidationMessage( form.config.l10n.title_tags_invalid ); - } - - if ( ! newInstance.number || newInstance.number < form.config.minimum_number ) { - newInstance.number = form.config.minimum_number; - } - return newInstance; - } - }); - - if ( 'undefined' !== typeof module ) { - module.exports = RecentPostsWidgetForm; - } - return RecentPostsWidgetForm; - -})( wp.customize, jQuery ); diff --git a/js/widgets/customize-widget-text.js b/js/widgets/customize-widget-text.js deleted file mode 100644 index 812fca3..0000000 --- a/js/widgets/customize-widget-text.js +++ /dev/null @@ -1,120 +0,0 @@ -/* global wp, module */ -/* eslint consistent-this: [ "error", "form" ] */ -/* eslint-disable strict */ -/* eslint-disable complexity */ - -wp.customize.Widgets.formConstructor.text = (function( api, $ ) { - 'use strict'; - - var TextWidgetForm; - - /** - * Text Widget Form. - * - * @constructor - */ - TextWidgetForm = api.Widgets.Form.extend({ - - /** - * Initialize. - * - * @param {object} properties - * @param {wp.customize.Widgets.WidgetControl} properties.control - * @param {object} properties.config - */ - initialize: function( properties ) { - var form = this; - - api.Widgets.Form.prototype.initialize.call( form, properties ); - - form.embed(); - form.render(); - }, - - /** - * Embed the form from the template and set up event handlers. - */ - embed: function() { - var form = this, data; - form.template = wp.template( 'customize-widget-text' ); - - data = {}; - form.container.html( form.template( data ) ); - form.inputs = { - title: form.container.find( ':input[name=title]:first' ), - text: form.container.find( ':input[name=text]:first' ), - filter: form.container.find( ':input[name=filter]:first' ) - }; - - form.container.on( 'change', ':input', function() { - form.render(); - } ); - - form.inputs.title.on( 'input change', function() { - form.setState( { title: $( this ).val() } ); - } ); - form.inputs.text.on( 'input change', function() { - form.setState( { text: $( this ).val() } ); - } ); - form.inputs.filter.on( 'click', function() { - form.setState( { filter: $( this ).prop( 'checked' ) } ); - } ); - }, - - /** - * Render and update the form. - */ - render: function() { - var form = this, value = form.getValue(); - if ( ! form.inputs.title.is( document.activeElement ) ) { - form.inputs.title.val( value.title ); - } - if ( ! form.inputs.text.is( document.activeElement ) ) { - form.inputs.text.val( value.text ); - } - form.inputs.filter.prop( 'checked', value.filter ); - }, - - /** - * Sanitize the instance data. - * - * @param {object} newInstance Unsanitized instance. - * @returns {object} Sanitized instance. - */ - sanitize: function( newInstance ) { - var form = this; - - if ( ! newInstance.title ) { - newInstance.title = ''; - } - if ( ! newInstance.text ) { - newInstance.text = ''; - } - - // Warn about markup in title. - if ( /<\/?\w+[^>]*>/.test( newInstance.title ) ) { - form.setValidationMessage( form.config.l10n.title_tags_invalid ); - } - - // Warn about unfiltered HTML. - if ( ! form.config.can_unfiltered_html && /<\/?(script|iframe)[^>]*>/i.test( newInstance.text ) ) { - form.setValidationMessage( form.config.l10n.text_unfiltered_html_invalid ); - } - - /* - * Trim per sanitize_text_field(). - * Protip: This prevents the widget partial from refreshing after adding a space or adding a new paragraph. - */ - newInstance.title = $.trim( newInstance.title ); - newInstance.text = $.trim( newInstance.text ); - - return newInstance; - } - }); - - if ( 'undefined' !== typeof module ) { - module.exports = TextWidgetForm; - } - return TextWidgetForm; - -})( wp.customize, jQuery ); diff --git a/js/widgets/recent-posts-widget-form-react-component.jsx b/js/widgets/recent-posts-widget-form-react-component.jsx deleted file mode 100644 index c3cf6e4..0000000 --- a/js/widgets/recent-posts-widget-form-react-component.jsx +++ /dev/null @@ -1,85 +0,0 @@ -/* eslint-env node */ - -var React = require( 'react' ); - -var Form = React.createClass({ - - propTypes: { - labelTitle: React.PropTypes.string, - placeholderTitle: React.PropTypes.string, - labelNumber: React.PropTypes.string, - labelShowDate: React.PropTypes.string, - minimumNumber: React.PropTypes.number, - store: React.PropTypes.object - }, - - /** - * Default props. - * - * @returns {object} Default. - */ - getDefaultProps: function() { - return { - labelTitle: '', - placeholderTitle: '', - labelNumber: '', - labelShowDate: false, - minimumNumber: 1 - } - }, - - /** - * Handle field change. - * - * @param {object} e Event. - * @returns {void} - */ - onChange: function( e ) { - var value, props = {}; - if ( 'checkbox' === e.target.type ) { - value = e.target.checked; - } else { - value = e.target.value; - } - props[ e.target.name ] = value; - this.props.store.dispatch( { - 'type': 'UPDATE', - 'props': props - } ); - }, - - /** - * Render. - * - * @todo Break this up into a container component and three nested components: TitleInput, NumberInput, ShowDateInput. Or rather just TextInput and CheckboxInput. - * - * @returns {XML} Element. - */ - render: function() { - var state = this.props.store.getState(); - return ( -
-

- -

-

- -

-

- -

-
- ); - } -}); - -module.exports = Form; diff --git a/js/widgets/recent-posts-widget-frontend-react-component.jsx b/js/widgets/recent-posts-widget-frontend-react-component.jsx deleted file mode 100644 index f8bc109..0000000 --- a/js/widgets/recent-posts-widget-frontend-react-component.jsx +++ /dev/null @@ -1,2 +0,0 @@ - -module.exports = {}; diff --git a/package.json b/package.json index 462bed6..067a0c1 100644 --- a/package.json +++ b/package.json @@ -6,39 +6,18 @@ "type": "git", "url": "https://github.com/xwp/wp-js-widgets.git" }, - "version": "0.1.1", + "version": "0.2.0", "license": "GPL-2.0+", "private": true, "devDependencies": { - "babel-preset-react": "^6.5.0", - "babelify": "^7.3.0", - "browserify-shim": "^3.8.12", - "eslint": "^3.0.1", - "eslint-plugin-react": "^5.0.1", + "eslint": "^3.12.2", "grunt": "~0.4.5", - "grunt-browserify": "^5.0.0", - "grunt-checktextdomain": "~1.0.0", "grunt-contrib-clean": "~1.0.0", "grunt-contrib-copy": "~1.0.0", - "grunt-contrib-cssmin": "~1.0.0", "grunt-contrib-jshint": "~1.0.0", - "grunt-contrib-uglify": "~1.0.0", "grunt-contrib-watch": "^1.0.0", "grunt-shell": "~1.3.0", - "grunt-wp-deploy": "^1.1.0", - "react": "^15.0.2", - "react-dom": "^15.0.2" + "grunt-wp-deploy": "^1.1.0" }, - "author": "XWP", - "dependencies": { - "bower": "^1.7.9", - "redux": "^3.5.2" - }, - "browserify-shim": { - "react": "global:React", - "react-dom": "global:ReactDOM" - }, - "scripts": { - "postinstall": "if [ ! -e bower_components ]; then $(npm bin)/bower install; fi" - } + "author": "XWP" } diff --git a/php/class-js-widgets-plugin.php b/php/class-js-widgets-plugin.php index f688ee1..4075606 100644 --- a/php/class-js-widgets-plugin.php +++ b/php/class-js-widgets-plugin.php @@ -2,13 +2,13 @@ /** * Class JS_Widgets_Plugin. * - * @package JSWidgets + * @package JS_Widgets */ /** * Class JS_Widgets_Plugin * - * @package JSWidgets + * @package JS_Widgets */ class JS_Widgets_Plugin { @@ -64,6 +64,13 @@ class JS_Widgets_Plugin { */ protected $original_setting_values = array(); + /** + * Script handles. + * + * @var array + */ + public $script_handles = array(); + /** * Plugin constructor. */ @@ -77,8 +84,6 @@ public function __construct() { /** * Add hooks. * - * @todo Add a WP_Customize_Compat_Proxy_Widget which can wrap all recognized core widgets with WP_Customize_Widget implementations. - * * @access public */ public function init() { @@ -87,6 +92,8 @@ public function init() { return; } + require_once __DIR__ . '/class-wp-js-widget.php'; + add_filter( 'widget_customizer_setting_args', array( $this, 'filter_widget_customizer_setting_args' ), 100, 2 ); add_action( 'wp_default_scripts', array( $this, 'register_scripts' ), 20 ); add_action( 'wp_default_styles', array( $this, 'register_styles' ), 20 ); @@ -127,39 +134,27 @@ public function print_admin_notice_missing_wp_api_dependency() { */ public function register_scripts( WP_Scripts $wp_scripts ) { global $wp_widget_factory; - $suffix = ( SCRIPT_DEBUG ? '' : '.min' ) . '.js'; $plugin_dir_url = plugin_dir_url( dirname( __FILE__ ) ); - $handle = 'react'; - if ( ! $wp_scripts->query( $handle, 'registered' ) ) { - $src = $plugin_dir_url . 'bower_components/react/react' . $suffix; - $deps = array(); - $wp_scripts->add( $handle, $src, $deps, $this->version ); - } + $this->script_handles['control-form'] = 'customize-widget-control-form'; + $src = $plugin_dir_url . 'js/customize-widget-control-form.js'; + $deps = array( 'customize-base', 'wp-util', 'jquery' ); + $wp_scripts->add( $this->script_handles['control-form'], $src, $deps, $this->version ); - $handle = 'react-dom'; - if ( ! $wp_scripts->query( $handle, 'registered' ) ) { - $src = $plugin_dir_url . 'bower_components/react/react-dom' . $suffix; - $deps = array( 'react' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); - } + $this->script_handles['js-widgets'] = 'customize-js-widgets'; + $src = $plugin_dir_url . 'js/customize-js-widgets.js'; + $deps = array( 'customize-widgets', $this->script_handles['control-form'] ); + $wp_scripts->add( $this->script_handles['js-widgets'], $src, $deps, $this->version ); - $handle = 'redux'; - if ( ! $wp_scripts->query( $handle, 'registered' ) ) { - $src = $plugin_dir_url . 'bower_components/redux/index.js'; - $deps = array(); - $wp_scripts->add( $handle, $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' ); + $wp_scripts->add( $this->script_handles['trac-39389-controls'], $src, $deps, $this->version ); - $handle = 'customize-widget-control-form'; - $src = $plugin_dir_url . 'js/customize-widget-control-form' . $suffix; - $deps = array( 'customize-base' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); - - $handle = 'customize-js-widgets'; - $src = $plugin_dir_url . 'js/customize-js-widgets' . $suffix; - $deps = array( 'customize-widgets', 'customize-widget-control-form' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); + $this->script_handles['trac-39389-preview'] = 'js-widgets-trac-39389-preview'; + $src = $plugin_dir_url . 'js/trac-39389-preview.js'; + $deps = array( 'customize-preview-widgets' ); + $wp_scripts->add( $this->script_handles['trac-39389-preview'], $src, $deps, $this->version ); // Register scripts for widgets. foreach ( $wp_widget_factory->widgets as $widget ) { @@ -217,7 +212,7 @@ function enqueue_pane_scripts() { return; } - // Gather the id_bases (types) Customize Widgets and their form configs. + // Gather the id_bases (types) for JS Widgets and their form configs. $customize_widget_id_bases = array(); $form_configs = array(); foreach ( $wp_widget_factory->widgets as $widget ) { @@ -246,6 +241,8 @@ function enqueue_pane_scripts() { $widget->enqueue_control_scripts(); } } + + wp_enqueue_script( $this->script_handles['trac-39389-controls'] ); } /** @@ -261,6 +258,10 @@ function enqueue_frontend_scripts() { $widget->enqueue_frontend_scripts(); } } + + if ( is_customize_preview() ) { + wp_enqueue_script( $this->script_handles['trac-39389-preview'] ); + } } /** @@ -279,14 +280,15 @@ function print_widget_form_templates() { } /** - * Override core widgets with customize widgets. + * Override core widgets with JS Widgets. * * @global WP_Widget_Factory $wp_widget_factory */ public function upgrade_core_widgets() { global $wp_widget_factory; - register_widget( 'WP_JS_Widget_Post_Collection' ); + require_once dirname( __FILE__ ) . '/class-wp-js-widget.php'; + require_once dirname( __FILE__ ) . '/class-wp-adapter-js-widget.php'; $registered_widgets = array(); foreach ( $wp_widget_factory->widgets as $key => $widget ) { @@ -296,22 +298,20 @@ public function upgrade_core_widgets() { ); } - $proxy_core_widgets = array( - 'text' => 'WP_JS_Widget_Text', - 'recent-posts' => 'WP_JS_Widget_Recent_Posts', - ); - - foreach ( $proxy_core_widgets as $id_base => $proxy_core_widget_class ) { - if ( isset( $registered_widgets[ $id_base ] ) ) { - $key = $registered_widgets[ $id_base ]['key']; - $instance = $registered_widgets[ $id_base ]['instance']; - $wp_widget_factory->widgets[ $key ] = new $proxy_core_widget_class( $instance ); + foreach ( glob( dirname( dirname( __FILE__ ) ) . '/core-adapter-widgets/*', GLOB_ONLYDIR ) as $dir ) { + $id_base = basename( $dir ); + if ( ! isset( $registered_widgets[ $id_base ] ) ) { + continue; } + require_once $dir . '/class.php'; + $class_name = 'WP_JS_Widget_' . str_replace( '-', '_', $id_base ); + $widget = new $class_name( $this, $registered_widgets[ $id_base ]['instance'] ); + $wp_widget_factory->widgets[ $registered_widgets[ $id_base ]['key'] ] = $widget; } } /** - * Replace instances of `WP_Widget_Form_Customize_Control` for Customize Widgets to exclude PHP-generated content. + * Replace instances of `WP_Widget_Form_Customize_Control` for JS Widgets to exclude PHP-generated content. * * @access public * @global WP_Customize_Manager $wp_customize @@ -408,7 +408,6 @@ public function filter_widget_customizer_setting_args( $args, $setting_id ) { $this->original_customize_sanitize_js_callbacks[ $setting_id ] = $args['sanitize_js_callback']; $args['sanitize_callback'] = array( $this, 'sanitize_widget_instance' ); $args['sanitize_js_callback'] = array( $this, 'sanitize_widget_js_instance' ); - $args['validate_callback'] = array( $this, 'validate_widget_instance' ); } return $args; } @@ -416,7 +415,7 @@ public function filter_widget_customizer_setting_args( $args, $setting_id ) { /** * Get the REST Request for the PUT to update the widget resource. * - * The provided instance will be sanitized, filled with defaults. + * The provided instance will be populated with the instance, filled with defaults. * Applies the same logic as `WP_REST_Server::dispatch()`. Validation is * done in another method. * @@ -427,7 +426,7 @@ public function filter_widget_customizer_setting_args( $args, $setting_id ) { * @param WP_JS_Widget $widget Widget instance. * @return WP_REST_Request|null Sanitized request on success, or `null` if no schema. */ - public function get_sanitized_request( $instance, $widget ) { + public function get_put_request( $instance, $widget ) { $instance_schema = $widget->get_item_schema(); if ( empty( $instance_schema ) ) { return null; @@ -446,7 +445,6 @@ public function get_sanitized_request( $instance, $widget ) { } $request->set_attributes( $attributes ); $request->set_body_params( $instance ); - $request->sanitize_params(); $defaults = array(); foreach ( $attributes['args'] as $arg => $options ) { if ( isset( $options['default'] ) ) { @@ -457,36 +455,6 @@ public function get_sanitized_request( $instance, $widget ) { return $request; } - /** - * Sanitize the instance via the instance schema. - * - * @param array $instance Widget instance data. - * @param WP_JS_Widget $widget Widget object. - * @return array Sanitized instance. - */ - public function sanitize_via_instance_schema( $instance, $widget ) { - $request = $this->get_sanitized_request( $instance, $widget ); - if ( is_null( $request ) ) { - return $instance; - } - return $request->get_body_params(); - } - - /** - * Validate the instance via the instance schema. - * - * @param array $instance Widget instance data. - * @param WP_JS_Widget $widget Widget object. - * @return bool|WP_Error - */ - public function validate_via_instance_schema( $instance, $widget ) { - $request = $this->get_sanitized_request( $instance, $widget ); - if ( is_null( $request ) ) { - return true; - } - return $request->has_valid_params(); - } - /** * Capture the original widget instances before preview is applied to pass the old instance data. * @@ -587,7 +555,30 @@ public function sanitize_widget_instance( $new_instance, WP_Customize_Setting $s } else { $old_instance = array(); } - $instance = $this->sanitize_via_instance_schema( $new_instance, $widget ); + + if ( is_null( $new_instance ) ) { + return new WP_Error( 'invalid_value', __( 'Widget invalidated by widget_update_callback filter.', 'js-widgets' ) ); + } + + $request = $this->get_put_request( $new_instance, $widget ); + if ( $request ) { + $validity = $request->has_valid_params(); + if ( is_wp_error( $validity ) ) { + return $this->augment_error_with_rest_invalid_params( $validity ); + } + } + + $method_validity = $widget->validate( $new_instance ); + if ( is_wp_error( $method_validity ) ) { + return $method_validity; + } + + $validity = $request->sanitize_params(); + if ( is_wp_error( $validity ) ) { + return $this->augment_error_with_rest_invalid_params( $validity ); + } + + $instance = $request->get_body_params(); if ( is_array( $instance ) ) { $instance = $widget->sanitize( $instance, $old_instance ); @@ -617,6 +608,26 @@ public function sanitize_widget_instance( $new_instance, WP_Customize_Setting $s return $instance; } + /** + * Augment error with REST invalid param errors. + * + * @param WP_Error $error Error. + * @return WP_Error With errors added for rest_invalid_params. + */ + protected function augment_error_with_rest_invalid_params( WP_Error $error ) { + $data = $error->get_error_data( 'rest_invalid_param' ); + if ( ! empty( $data['params'] ) ) { + $error = clone $error; + foreach ( $data['params'] as $field => $error_message ) { + $error->add( + sprintf( 'rest_invalid_param[%s]', $field ), + $error_message + ); + } + } + return $error; + } + /** * Fallback validate callback. * @@ -631,52 +642,6 @@ public function fallback_validate_callback( $validity, $instance ) { return $validity; } - /** - * Validate widget instance. - * - * @param WP_Error $validity Validity. - * @param array|null $new_instance Widget instance. - * @param WP_Customize_Setting $setting Widget setting. - * @return true|WP_Error True if valid, or `WP_Error` if invalid. - */ - public function validate_widget_instance( $validity, $new_instance, $setting ) { - if ( isset( $this->original_customize_validate_callbacks[ $setting->id ] ) ) { - $original_validate_callback = $this->original_customize_validate_callbacks[ $setting->id ]; - } else { - $original_validate_callback = array( $this, 'fallback_validate_callback' ); - } - - $parsed_setting_id = $setting->manager->widgets->parse_widget_setting_id( $setting->id ); - if ( is_wp_error( $parsed_setting_id ) ) { - return call_user_func( $original_validate_callback, $validity, $new_instance, $setting ); - } - $widget = $this->get_widget_instance( $parsed_setting_id['id_base'] ); - if ( ! $widget || ! ( $widget instanceof WP_JS_Widget ) ) { - return call_user_func( $original_validate_callback, $validity, $new_instance, $setting ); - } - - if ( is_null( $new_instance ) ) { - $validity->add( 'invalid_value', __( 'Widget invalidated by widget_update_callback filter.', 'js-widgets' ) ); - } else { - - $schema_validity = $this->validate_via_instance_schema( $new_instance, $widget ); - if ( is_wp_error( $schema_validity ) ) { - foreach ( $schema_validity->errors as $code => $messages ) { - $validity->add( $code, join( ' ', $messages ), $schema_validity->get_error_data( $code ) ); - } - } - - $method_validity = $widget->validate( $new_instance ); - if ( is_wp_error( $method_validity ) ) { - foreach ( $method_validity->errors as $code => $messages ) { - $validity->add( $code, join( ' ', $messages ), $method_validity->get_error_data( $code ) ); - } - } - } - - return $validity; - } - /** * Converts a `$value` into a JSON-serializable value. * @@ -713,7 +678,7 @@ public function sanitize_widget_js_instance( $value, WP_Customize_Setting $setti /** * Start capturing all of the extra fields generated when rendering in `in_widget_form` for a Customize Widget. * - * Using this PHP-based hook is not supported by Customize Widgets. + * Using this PHP-based hook is not supported by JS Widgets. * * @param WP_Widget $widget Widget. */ @@ -726,7 +691,7 @@ public function start_capturing_in_widget_form( $widget ) { /** * Stop capturing all of the extra fields generated when rendering in `in_widget_form` for a Customize Widget. * - * Using this PHP-based hook is not supported by Customize Widgets. + * Using this PHP-based hook is not supported by JS Widgets. * * @param WP_Widget $widget Widget. */ diff --git a/php/class-js-widgets-rest-controller.php b/php/class-js-widgets-rest-controller.php index a8d1ec1..1832dda 100644 --- a/php/class-js-widgets-rest-controller.php +++ b/php/class-js-widgets-rest-controller.php @@ -2,13 +2,13 @@ /** * Class JS_Widgets_REST_Controller. * - * @package JSWidgets + * @package JS_Widgets */ /** * Class JS_Widgets_REST_Controller * - * @package JSWidgets + * @package JS_Widgets */ class JS_Widgets_REST_Controller extends WP_REST_Controller { @@ -69,17 +69,16 @@ protected function get_object_type() { /** * Get a widget object (resource) ID. * - * This is not great and shouldn't be long for this world. + * This simple re-uses a widget number as a widget ID, which will only be unique + * among the widgets of a given type. Eventually this ID should map to the post ID + * for a given widget_instance post type so that it is truly unique across all + * widget types in a site. * - * @param int $widget_number Widget number (or widget_instance post ID). - * @return string Widget object ID. + * @param int $widget_number Widget number. + * @return int Widget object ID. */ protected function get_object_id( $widget_number ) { - if ( post_type_exists( 'widget_instance' ) ) { - $widget_id = intval( $widget_number ); - } else { - $widget_id = $this->widget->id_base . '-' . $widget_number; - } + $widget_id = intval( $widget_number ); return $widget_id; } @@ -89,17 +88,14 @@ protected function get_object_id( $widget_number ) { * @return array */ public function get_item_schema() { - $has_widget_posts = post_type_exists( 'widget_instance' ); - $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => $this->get_object_type(), 'type' => 'object', 'properties' => array( - // @todo change to widget_id containing id_base-widget_number, with an id field only available if Widget Posts are enabled? 'id' => array( - 'description' => $has_widget_posts ? __( 'ID for widget_instance post', 'js-widgets' ) : __( 'Widget ID. Eventually this may be an integer if widgets are stored as posts. See WP Trac #35669.', 'js-widgets' ), - 'type' => $has_widget_posts ? 'integer' : 'string', + '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' ), + 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), @@ -243,7 +239,7 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE } $endpoint_args[ $field_id ] = array( - 'validate_callback' => 'rest_validate_request_arg', + 'validate_callback' => array( $this, 'rest_validate_request_arg' ), 'sanitize_callback' => 'rest_sanitize_request_arg', ); @@ -259,7 +255,7 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE $endpoint_args[ $field_id ]['required'] = true; } - foreach ( array( 'type', 'format', 'enum' ) as $schema_prop ) { + foreach ( array( 'type', 'format', 'enum', 'properties' ) as $schema_prop ) { // @todo Should this not be including everything? if ( isset( $params[ $schema_prop ] ) ) { $endpoint_args[ $field_id ][ $schema_prop ] = $params[ $schema_prop ]; } @@ -280,6 +276,94 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE return $endpoint_args; } + + /** + * Validate a request argument based on details registered to the route. + * + * This is a replacement for `rest_validate_request_arg()` to take advantage of `WP_JS_Widget::rest_validate_value_from_schema()` + * + * @param mixed $value Value. + * @param WP_REST_Request $request Request. + * @param string $param Param name. + * @return WP_Error|true Error on fail; true on success. + */ + public function rest_validate_request_arg( $value, $request, $param ) { + $attributes = $request->get_attributes(); + if ( ! isset( $attributes['args'][ $param ] ) || ! is_array( $attributes['args'][ $param ] ) ) { + return true; + } + $args = $attributes['args'][ $param ]; + + return $this->rest_validate_value_from_schema( $value, $args, $param ); + } + + /** + * Validate a value based on a schema, with augmented support for type arrays and object types. + * + * @link https://core.trac.wordpress.org/ticket/38583 + * + * @param mixed $value The value to validate. + * @param array $args Schema array to use for validation. + * @param string $param The parameter name, used in error messages. + * @return true|WP_Error + */ + protected function rest_validate_value_from_schema( $value, $args, $param ) { + + if ( ! isset( $args['type'] ) ) { + return true; + } + $validity = rest_validate_value_from_schema( $value, $args, $param ); + if ( is_wp_error( $validity ) ) { + return $validity; + } + + // Implement validation for multi-type arrays. + if ( is_array( $args['type'] ) ) { + $has_valid_type = false; + $errors = array(); + foreach ( $args['type'] as $type ) { + $validity = $this->rest_validate_value_from_schema( $value, array_merge( $args, compact( 'type' ) ), $param ); + if ( ! is_wp_error( $validity ) ) { + $has_valid_type = true; + break; + } else { + $errors[] = $validity; + } + } + if ( ! $has_valid_type ) { + $error_messages = array( sprintf( __( 'Expected %1$s param to be of one types: %2$s', 'js-widgets' ), $param, join( ', ', $args['type'] ) ) ); + foreach ( $errors as $sub_error ) { + foreach ( $sub_error->get_error_messages( 'rest_invalid_param' ) as $error_message ) { + $error_messages[] = $error_message; + } + } + return new WP_Error( 'rest_invalid_param', join( '; ', $error_messages ) ); + } + return true; + } + + // Validate object types. + if ( 'object' === $args['type'] ) { + if ( ! is_array( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( 'Expected object but got %s.', 'js-widgets' ), gettype( $value ) ) ); + } + if ( ! empty( $value ) && wp_is_numeric_array( $value ) ) { + return new WP_Error( 'rest_invalid_param', __( 'Expected object but got positional array.', 'js-widgets' ) ); + } + + foreach ( $value as $sub_key => $sub_value ) { + if ( ! isset( $args['properties'][ $sub_key ] ) ) { + continue; + } + $validity = $this->rest_validate_value_from_schema( $sub_value, $args['properties'][ $sub_key ], "$param.$sub_key" ); + if ( is_wp_error( $validity ) ) { + return $validity; + } + } + } + return true; + } + /** * Return whether the current user can manage widgets. * @@ -391,6 +475,7 @@ public function update_item( $request ) { // Note that $new_instance has gone through the validate and sanitize callbacks defined on the instance schema. $new_instance = $this->widget->prepare_item_for_database( $request ); + $new_instance = array_merge( $old_instance, $new_instance ); // Allow instances to be patched. $instance = $this->widget->sanitize( $new_instance, $old_instance ); if ( is_wp_error( $instance ) ) { diff --git a/php/class-wp-adapter-js-widget.php b/php/class-wp-adapter-js-widget.php new file mode 100644 index 0000000..164a5fa --- /dev/null +++ b/php/class-wp-adapter-js-widget.php @@ -0,0 +1,137 @@ +plugin = $plugin; + $this->adapted_widget = $adapted_widget; + $this->id_base = $adapted_widget->id_base; + $this->name = $adapted_widget->name; + $this->widget_options = $adapted_widget->widget_options; + $this->control_options = $adapted_widget->control_options; + parent::__construct(); + } + + /** + * Get instance schema properties. + * + * Subclasses are required to implement this method since it is used for sanitization. + * + * @return array Schema. + */ + public function get_item_schema() { + $schema = parent::get_item_schema(); + $schema['title']['properties']['raw']['default'] = $this->name; + return $schema; + } + + /** + * Register scripts. + * + * @param WP_Scripts $wp_scripts Scripts. + */ + public function register_scripts( $wp_scripts ) { + $reflection_class = new ReflectionClass( get_class( $this ) ); + $plugin_dir_url = plugin_dir_url( $reflection_class->getFileName() ); + $handle = "customize-widget-form-{$this->id_base}"; + $src = $plugin_dir_url . 'form.js'; + $deps = array( $this->plugin->script_handles['control-form'] ); + $wp_scripts->add( $handle, $src, $deps, $this->plugin->version ); + } + + /** + * Enqueue scripts needed for the control.s + */ + public function enqueue_control_scripts() { + wp_enqueue_script( "customize-widget-form-{$this->id_base}" ); + } + + /** + * Sanitize instance data. + * + * @inheritdoc + * + * @param array $new_instance New instance. + * @param array $old_instance Old instance. + * @return array|null|WP_Error Array instance if sanitization (and validation) passed. Returns `WP_Error` or `null` on failure. + */ + public function sanitize( $new_instance, $old_instance ) { + $default_instance = $this->get_default_instance(); + $new_instance = array_merge( $default_instance, $new_instance ); + $old_instance = array_merge( $default_instance, $old_instance ); + $instance = $this->adapted_widget->update( $new_instance, $old_instance ); + return $instance; + } + + /** + * Render widget. + * + * @param array $args Widget args. + * @param array $instance Widget instance. + * @return void + */ + public function render( $args, $instance ) { + $this->adapted_widget->widget( $args, $instance ); + } + + /** + * Prepare a widget instance for a REST API response. + * + * @inheritdoc + * + * @param array $instance Raw database instance. + * @param WP_REST_Request $request REST request. + * @return array Widget item. + */ + public function prepare_item_for_response( $instance, $request ) { + $item = parent::prepare_item_for_response( $instance, $request ); + $schema = $this->get_item_schema(); + foreach ( $schema as $field_id => $field_schema ) { + if ( ! isset( $item[ $field_id ] ) ) { + continue; + } + + // Ensure strict types since core widgets aren't always strict. + if ( 'boolean' === $field_schema['type'] ) { + $item[ $field_id ] = (bool) $item[ $field_id ]; + } elseif ( 'integer' === $field_schema['type'] ) { + $item[ $field_id ] = (int) $item[ $field_id ]; + } + } + return $item; + } +} diff --git a/php/class-wp-customize-js-widget-control.php b/php/class-wp-customize-js-widget-control.php index 1c6a10c..34cf9d3 100644 --- a/php/class-wp-customize-js-widget-control.php +++ b/php/class-wp-customize-js-widget-control.php @@ -2,13 +2,13 @@ /** * Class WP_Customize_Widget_Control. * - * @package JSWidgets + * @package JS_Widgets */ /** * Class WP_Customize_Widget_Control * - * @package JSWidgets + * @package JS_Widgets */ class WP_Customize_JS_Widget_Control extends WP_Widget_Form_Customize_Control { diff --git a/php/class-wp-js-widget.php b/php/class-wp-js-widget.php index 0071fdc..eed9b73 100644 --- a/php/class-wp-js-widget.php +++ b/php/class-wp-js-widget.php @@ -1,14 +1,14 @@ array( + 'description' => __( 'The title for the widget.', 'js-widgets' ), + 'type' => array( 'string', 'object' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'properties' => array( + 'raw' => array( + 'description' => __( 'Title for the widget, as it exists in the database.', 'js-widgets' ), + 'type' => 'string', + 'context' => array( 'edit' ), + 'default' => '', + ), + 'rendered' => array( + 'description' => __( 'HTML title for the widget, transformed for display.', 'js-widgets' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ), + ); + return $schema; + } /** * Get default instance data. @@ -143,15 +166,72 @@ public function get_default_instance() { * underlying instance data have different structures, or if additional * dynamic (readonly) fields should be included in the response. * - * @see WP_JS_Widget::render() + * @inheritdoc * - * @param array $instance Raw (legacy) instance. + * @param array $instance Raw database instance without rendered properties. * @param WP_REST_Request $request REST request. * @return array Widget item. */ public function prepare_item_for_response( $instance, $request ) { unset( $request ); - return $instance; + $schema = $this->get_item_schema(); + $instance = array_merge( $this->get_default_instance(), $instance ); + + $item = array(); + if ( isset( $schema['title']['properties']['raw'] ) ) { + $title_rendered = ''; + if ( ! empty( $instance['title'] ) ) { + $title_rendered = $instance['title']; + } elseif ( isset( $schema['title']['properties']['rendered']['default'] ) ) { + $title_rendered = $schema['title']['properties']['rendered']['default']; + } elseif ( isset( $schema['title']['properties']['raw']['default'] ) ) { + $title_rendered = $schema['title']['properties']['raw']['default']; + } + + /** This filter is documented in src/wp-includes/widgets/class-wp-widget-pages.php */ + $title_rendered = apply_filters( 'widget_title', $title_rendered, $instance, $this->id_base ); + $title_rendered = html_entity_decode( $title_rendered, ENT_QUOTES, 'utf-8' ); + + $item['title'] = array( + 'raw' => $instance['title'], + 'rendered' => $title_rendered, + ); + unset( $schema['title'] ); + } + + foreach ( $schema as $field_id => $field_attributes ) { + $field_value = null; + if ( ! isset( $instance[ $field_id ] ) ) { + // @todo Add recursive method to compute default value. + if ( isset( $field_attributes['properties'] ) ) { + $field_value = array(); + foreach ( $field_attributes['properties'] as $prop_id => $prop_attributes ) { + $prop_value = null; + if ( isset( $item[ $field_id ]['default'] ) ) { + $prop_value = $item[ $field_id ]['default']; + } + $field_value[ $prop_id ] = $prop_value; + } + } elseif ( isset( $field_attributes['default'] ) ) { + $field_value = $field_attributes['default']; + } + } else { + if ( isset( $field_attributes['properties'] ) ) { + $field_value = array(); + if ( isset( $field_attributes['properties']['raw'] ) ) { + $field_value['raw'] = $instance[ $field_id ]; + } + if ( isset( $field_attributes['properties']['rendered'] ) ) { + $field_value['rendered'] = null; // A subclass must render the value. + } + } else { + $field_value = $instance[ $field_id ]; + } + } + $item[ $field_id ] = $field_value; + } + + return $item; } /** @@ -165,6 +245,7 @@ public function prepare_item_for_response( $instance, $request ) { * will be flattened for sending to the DB. * * @see WP_JS_Widget::sanitize() + * @see WP_REST_Posts_Controller::prepare_item_for_database() * * @param WP_REST_Request $request Request object. * @return WP_Error|array Error or array data. @@ -176,8 +257,14 @@ public function prepare_item_for_database( $request ) { if ( ! isset( $schema[ $key ] ) || ! empty( $schema[ $key ]['readonly'] ) ) { continue; } - if ( isset( $value['raw'] ) && isset( $schema[ $key ]['properties']['raw'] ) ) { - $value = $value['raw']; + if ( is_array( $value ) && isset( $schema[ $key ]['properties']['raw'] ) ) { + if ( isset( $value['raw'] ) ) { + $value = $value['raw']; + } elseif ( isset( $value['rendered'] ) && isset( $schema[ $key ]['properties']['rendered'] ) ) { + $value = $value['rendered']; + } else { + continue; + } } $instance[ $key ] = $value; } @@ -259,23 +346,6 @@ final public function update( $new_instance, $old_instance = array() ) { return false; } - /** - * Return whether strict draconian validation should be performed. - * - * When true, the instance data will go through additional validation checks - * before being sent through sanitize which will scrub the data lossily. - * - * This is experimental and is only intended to apply in REST API requests, - * not in normal widget updates as performed through the Customizer. - * - * @param WP_REST_Request $request Request. - * @return bool - */ - public function should_validate_strictly( $request ) { - $query_params = $request->get_query_params(); - return isset( $query_params['strict'] ) && (int) $query_params['strict']; - } - /** * Sanitize instance data. * @@ -289,14 +359,17 @@ public function should_validate_strictly( $request ) { * `WP_JS_Widget::update()` method is final, deprecated, and returns false. * * @see WP_JS_Widget::get_item_schema() - * @see JS_Widgets_Plugin::sanitize_and_validate_via_instance_schema() * * @param array $new_instance New instance. * @param array $old_instance Old instance. * @return array|null|WP_Error Array instance if sanitization (and validation) passed. Returns `WP_Error` or `null` on failure. */ public function sanitize( $new_instance, $old_instance ) { - unset( $old_instance, $setting ); + unset( $old_instance ); + $new_instance = array_merge( $this->get_default_instance(), $new_instance ); + if ( isset( $instance['title'] ) ) { + $instance['title'] = sanitize_text_field( $instance['title'] ); + } return $new_instance; } @@ -322,8 +395,6 @@ public function validate( $value ) { * This method is now deprecated in favor of `WP_Customize_Widget::render()`, * as `render` is a more accurate name than `widget` for what this method does. * - * @todo The else condition in this method needs to be eliminated. - * * @inheritdoc * * @access public @@ -339,22 +410,11 @@ public function validate( $value ) { * @param array $instance The settings for the particular instance of the widget. */ final public function widget( $args, $instance ) { - ob_start(); - $data = $this->render( $args, $instance ); - $rendered = ob_get_clean(); - if ( $rendered ) { - echo $rendered; // XSS OK. - } elseif ( ! is_null( $data ) ) { - echo $args['before_widget']; // WPCS: XSS OK. - echo ''; - echo $args['after_widget']; // WPCS: XSS OK. - } + $this->render( $args, $instance ); } /** - * Render the widget content or return the data for the widget to render. + * Render the widget content. * * @param array $args { * Display arguments. @@ -365,14 +425,122 @@ final public function widget( $args, $instance ) { * @type string $after_widget After widget. * } * @param array $instance The settings for the particular instance of the widget. - * @return void|array Return nothing if rendering, otherwise return data to be rendered on the client via JS template. + * @return void */ abstract public function render( $args, $instance ); /** - * Render JS template. + * Render title form field. + * + * @param array $input_attrs Input attributes. + */ + protected function render_title_form_field_template( $input_attrs = array() ) { + $this->render_form_field_template( array_merge( + array( + 'name' => 'title', + 'label' => __( 'Title:', 'default' ), + 'type' => 'text', + ), + $input_attrs + ) ); + } + + /** + * Render input attributes. + * + * @param array $input_attrs Input attributes. + */ + protected function render_input_attrs( $input_attrs ) { + $input_attrs_str = ''; + foreach ( $input_attrs as $key => $value ) { + $input_attrs_str .= sprintf( ' %s="%s"', $key, esc_attr( $value ) ); + } + echo $input_attrs_str; // WPCS: XSS OK. + } + + /** + * Render form field template. + * + * @todo Use a random string for a common name prefix to ensure that radio buttons will work properly. + * + * @param array $args Args. + */ + protected function render_form_field_template( $args = array() ) { + $defaults = array( + 'name' => '', + 'label' => '', + 'type' => 'text', + 'choices' => array(), + 'value' => '', + 'placeholder' => '', + 'help' => '', + ); + if ( ! isset( $args['type'] ) || ( 'checkbox' !== $args['type'] && 'radio' !== $args['type'] ) ) { + $defaults['class'] = 'widefat'; + } + $args = wp_parse_args( $args, $defaults ); + + $input_attrs = $args; + unset( $input_attrs['label'], $input_attrs['choices'], $input_attrs['type'] ); + + echo '

'; + echo '<# (function( domId ) { #>'; + if ( 'checkbox' === $args['type'] ) { + ?> + render_input_attrs( $input_attrs ); ?> > + + + + + + + + + + render_input_attrs( $input_attrs ); ?> > + +
+ + '; + echo '

'; + } + + /** + * Render JS Template. */ - public function form_template() {} + public function form_template() { + $placeholder = ''; + if ( isset( $item_schema['title']['properties']['raw']['default'] ) ) { + $placeholder = $item_schema['title']['properties']['raw']['default']; + } elseif ( isset( $item_schema['title']['properties']['rendered']['default'] ) ) { + $placeholder = $item_schema['title']['properties']['rendered']['default']; + } + + ?> + + array( + + // @todo Move this to the component level. + 'title_tags_invalid' => __( 'Tags will be stripped from the title.', 'js-widgets' ), + ), + ); } } diff --git a/php/widgets/class-wp-js-widget-recent-posts.php b/php/widgets/class-wp-js-widget-recent-posts.php deleted file mode 100644 index 741b357..0000000 --- a/php/widgets/class-wp-js-widget-recent-posts.php +++ /dev/null @@ -1,292 +0,0 @@ -proxied_widget = $proxied_widget; - parent::__construct( $proxied_widget->id_base, $proxied_widget->name, $proxied_widget->widget_options, $proxied_widget->control_options ); - } - - /** - * Register scripts. - * - * @param WP_Scripts $wp_scripts Scripts. - */ - public function register_scripts( $wp_scripts ) { - $suffix = ( SCRIPT_DEBUG ? '' : '.min' ) . '.js'; - $plugin_dir_url = plugin_dir_url( dirname( dirname( __FILE__ ) ) ); - - $handle = 'recent-posts-widget-form-react-component'; - $src = $plugin_dir_url . 'js/widgets/recent-posts-widget-form-react-component-browserified' . $suffix; - $deps = array( 'react', 'react-dom' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); - - $handle = 'recent-posts-widget-frontend-react-component'; - $src = $plugin_dir_url . 'js/widgets/recent-posts-widget-frontend-react-component-browserified' . $suffix; - $deps = array( 'react', 'react-dom' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); - - $handle = 'customize-widget-recent-posts'; - $src = $plugin_dir_url . 'js/widgets/customize-widget-recent-posts' . $suffix; - $deps = array( 'customize-js-widgets', 'redux', 'recent-posts-widget-form-react-component' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); - } - - /** - * Enqueue scripts needed for the control.s - */ - public function enqueue_control_scripts() { - wp_enqueue_script( 'customize-widget-recent-posts' ); - } - - /** - * Get instance schema properties. - * - * @return array Schema. - */ - public function get_item_schema() { - $schema = array( - 'title' => array( - 'description' => __( 'The title for the widget.', 'js-widgets' ), - 'type' => 'object', - 'context' => array( 'view', 'edit', 'embed' ), - 'properties' => array( - 'raw' => array( - 'description' => __( 'Title for the widget, as it exists in the database.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'edit' ), - 'default' => '', - 'arg_options' => array( - 'validate_callback' => array( $this, 'validate_title_field' ), - ), - ), - 'rendered' => array( - 'description' => __( 'HTML title for the widget, transformed for display.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'view', 'edit', 'embed' ), - 'default' => __( 'Recent Posts', 'js-widgets' ), - 'readonly' => true, - ), - ), - ), - 'number' => array( - 'description' => __( 'The number of posts to display.', 'js-widgets' ), - 'type' => 'integer', - 'context' => array( 'view', 'edit', 'embed' ), - 'default' => 5, - 'minimum' => 1, - 'arg_options' => array( - 'validate_callback' => 'rest_validate_request_arg', - ), - ), - 'show_date' => array( - 'description' => __( 'Whether the date should be shown.', 'js-widgets' ), - 'type' => 'boolean', - 'default' => false, - 'context' => array( 'view', 'edit', 'embed' ), - 'arg_options' => array( - 'validate_callback' => 'rest_validate_request_arg', - ), - ), - 'posts' => array( - 'description' => __( 'The IDs for the recent posts.', 'js-widgets' ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', - ), - 'context' => array( 'view', 'edit', 'embed' ), - 'readonly' => true, - 'default' => array(), - ), - ); - return $schema; - } - - /** - * Render a widget instance for a REST API response. - * - * Map the instance data to the REST resource fields and add rendered fields. - * The Text widget stores the `content` field in `text` and `auto_paragraph` in `filter`. - * - * @inheritdoc - * - * @param array $instance Raw database instance. - * @param WP_REST_Request $request REST request. - * @return array Widget item. - */ - public function prepare_item_for_response( $instance, $request ) { - $schema = $this->get_item_schema(); - $instance = array_merge( $this->get_default_instance(), $instance ); - - $title_rendered = $instance['title'] ? $instance['title'] : $schema['title']['properties']['rendered']['default']; - /** This filter is documented in src/wp-includes/widgets/class-wp-widget-pages.php */ - $title_rendered = apply_filters( 'widget_title', $title_rendered, $instance, $this->id_base ); - - $number = max( intval( $instance['number'] ), $schema['number']['minimum'] ); - - /** This filter is documented in src/wp-includes/widgets/class-wp-widget-recent-posts.php */ - $query = new WP_Query( apply_filters( 'widget_posts_args', array( - 'posts_per_page' => $number, - 'no_found_rows' => true, - 'post_status' => 'publish', - 'ignore_sticky_posts' => true, - 'update_post_meta_cache' => false, - 'update_term_meta_cache' => false, - ) ) ); - - $item = array( - 'title' => array( - 'raw' => $instance['title'], - 'rendered' => $title_rendered, - ), - 'number' => $number, - 'show_date' => boolval( $instance['number'] ), - 'posts' => wp_list_pluck( $query->posts, 'ID' ), - ); - - return $item; - } - - /** - * Prepare links for the response. - * - * @param WP_REST_Response $response Response. - * @param WP_REST_Request $request Request. - * @param JS_Widgets_REST_Controller $controller Controller. - * @return array Links for the given post. - */ - public function get_rest_response_links( $response, $request, $controller ) { - $links = array(); - - $links['wp:post'] = array(); - foreach ( $response->data['posts'] as $post_id ) { - $post = get_post( $post_id ); - if ( empty( $post ) ) { - continue; - } - $obj = get_post_type_object( $post->post_type ); - if ( empty( $obj ) ) { - continue; - } - - $rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name; - $base = sprintf( '/wp/v2/%s', $rest_base ); - - $links['wp:post'][] = array( - 'href' => rest_url( trailingslashit( $base ) . $post_id ), - 'embeddable' => true, - 'post_type' => $post->post_type, - ); - } - return $links; - } - - /** - * Validate a title request argument based on details registered to the route. - * - * @param mixed $value Value. - * @param WP_REST_Request $request Request. - * @param string $param Param. - * @return WP_Error|boolean - */ - public function validate_title_field( $value, $request, $param ) { - $valid = rest_validate_request_arg( $value, $request, $param ); - if ( is_wp_error( $valid ) ) { - return $valid; - } - - if ( $this->should_validate_strictly( $request ) ) { - if ( preg_match( '##', $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s cannot contain markup', 'js-widgets' ), $param ) ); - } - if ( trim( $value ) !== $value ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s contains whitespace padding', 'js-widgets' ), $param ) ); - } - if ( preg_match( '/%[a-f0-9]{2}/i', $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s contains illegal characters (octets)', 'js-widgets' ), $param ) ); - } - } - return true; - } - - /** - * Sanitize instance data. - * - * @inheritdoc - * - * @param array $new_instance New instance. - * @param array $old_instance Old instance. - * @return array|null|WP_Error Array instance if sanitization (and validation) passed. Returns `WP_Error` or `null` on failure. - */ - public function sanitize( $new_instance, $old_instance ) { - $default_instance = $this->get_default_instance(); - $new_instance = array_merge( $default_instance, $new_instance ); - $old_instance = array_merge( $default_instance, $old_instance ); - $instance = $this->proxied_widget->update( $new_instance, $old_instance ); - return $instance; - } - - /** - * Render widget. - * - * @param array $args Widget args. - * @param array $instance Widget instance. - * @return void - */ - public function render( $args, $instance ) { - $this->proxied_widget->widget( $args, $instance ); - } - - /** - * Get configuration data for the form. - * - * @return array - */ - public function get_form_args() { - $item_schema = $this->get_item_schema(); - return array( - 'minimum_number' => $item_schema['number']['minimum'], - 'l10n' => array( - 'title_tags_invalid' => __( 'Tags will be stripped from the title.', 'js-widgets' ), - 'label_title' => __( 'Title:', 'js-widgets' ), - 'placeholder_title' => $item_schema['title']['properties']['rendered']['default'], - 'label_number' => __( 'Number:', 'js-widgets' ), - 'label_show_date' => __( 'Show date', 'js-widgets' ), - ), - ); - } -} diff --git a/php/widgets/class-wp-js-widget-text.php b/php/widgets/class-wp-js-widget-text.php deleted file mode 100644 index 1ee8fa2..0000000 --- a/php/widgets/class-wp-js-widget-text.php +++ /dev/null @@ -1,330 +0,0 @@ -proxied_widget = $proxied_widget; - parent::__construct( $proxied_widget->id_base, $proxied_widget->name, $proxied_widget->widget_options, $proxied_widget->control_options ); - } - - /** - * Register scripts. - * - * @param WP_Scripts $wp_scripts Scripts. - */ - public function register_scripts( $wp_scripts ) { - $suffix = ( SCRIPT_DEBUG ? '' : '.min' ) . '.js'; - $plugin_dir_url = plugin_dir_url( dirname( dirname( __FILE__ ) ) ); - - $handle = 'customize-widget-text'; - $src = $plugin_dir_url . 'js/widgets/customize-widget-text' . $suffix; - $deps = array( 'customize-js-widgets' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); - } - - /** - * Enqueue scripts needed for the control.s - */ - public function enqueue_control_scripts() { - wp_enqueue_script( 'customize-widget-text' ); - } - - /** - * Get instance schema properties. - * - * @return array Schema. - */ - public function get_item_schema() { - $schema = array( - 'title' => array( - 'description' => __( 'The title for the widget.', 'js-widgets' ), - 'type' => 'object', - 'context' => array( 'view', 'edit', 'embed' ), - 'properties' => array( - 'raw' => array( - 'description' => __( 'Title for the widget, as it exists in the database.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'edit' ), - 'default' => '', - 'arg_options' => array( - 'validate_callback' => array( $this, 'validate_title_field' ), - ), - ), - 'rendered' => array( - 'description' => __( 'HTML title for the widget, transformed for display.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'view', 'edit', 'embed' ), - 'default' => '', - 'readonly' => true, - ), - ), - ), - 'content' => array( - 'description' => __( 'The content for the widget.', 'js-widgets' ), - 'type' => 'object', - 'context' => array( 'view', 'edit', 'embed' ), - 'properties' => array( - 'raw' => array( - 'description' => __( 'Content for the widget, as it exists in the database.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'edit' ), - 'required' => true, - 'default' => '', - 'arg_options' => array( - 'validate_callback' => array( $this, 'validate_content_field' ), - ), - ), - 'rendered' => array( - 'description' => __( 'HTML content for the widget, transformed for display.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'view', 'edit', 'embed' ), - 'readonly' => true, - ), - ), - ), - 'auto_paragraph' => array( - 'description' => __( 'Whether paragraphs will be added for double line breaks (wpautop).', 'js-widgets' ), - 'type' => 'boolean', - 'default' => false, - 'context' => array( 'edit' ), - 'arg_options' => array( - 'validate_callback' => 'rest_validate_request_arg', - ), - ), - ); - return $schema; - } - - /** - * Get default instance from schema. - * - * @return array - */ - public function get_default_instance() { - $schema = $this->get_item_schema(); - return array( - 'title' => $schema['title']['properties']['raw']['default'], - 'text' => $schema['content']['properties']['raw']['default'], - 'filter' => $schema['auto_paragraph']['default'], - ); - } - - /** - * Render a widget instance for a REST API response. - * - * Map the instance data to the REST resource fields and add rendered fields. - * The Text widget stores the `content` field in `text` and `auto_paragraph` in `filter`. - * - * @inheritdoc - * - * @param array $instance Raw database instance. - * @param WP_REST_Request $request REST request. - * @return array Widget item. - */ - public function prepare_item_for_response( $instance, $request ) { - $schema = $this->get_item_schema(); - $instance = array_merge( $this->get_default_instance(), $instance ); - - $title_rendered = $instance['title'] ? $instance['title'] : $schema['title']['rendered']['default']; - /** This filter is documented in src/wp-includes/widgets/class-wp-widget-pages.php */ - $title_rendered = apply_filters( 'widget_title', $title_rendered, $instance, $this->id_base ); - - /** This filter is documented in src/wp-includes/widgets/class-wp-widget-text.php */ - $content_rendered = apply_filters( 'widget_text', $instance['text'], $instance, $this->proxied_widget ); - if ( ! empty( $instance['filter'] ) ) { - $content_rendered = wpautop( $content_rendered ); - } - - $item = array( - 'title' => array( - 'raw' => $instance['title'], - 'rendered' => $title_rendered, - ), - 'content' => array( - 'raw' => $instance['text'], - 'rendered' => $content_rendered, - ), - 'auto_paragraph' => ! empty( $instance['filter'] ), - ); - - return $item; - } - - /** - * Map the REST resource fields back to the internal instance data. - * - * The Text widget stores the `content` field in `text` and `auto_paragraph` in `filter`. - * The return value will be passed through the sanitize method. - * - * @inheritdoc - * - * @param WP_REST_Request $request Request object. - * @return WP_Error|array Error or array data. - */ - public function prepare_item_for_database( $request ) { - return array( - 'title' => $request['title']['raw'], - 'text' => $request['content']['raw'], - 'filter' => $request['auto_paragraph'], - ); - } - - /** - * Validate a title request argument based on details registered to the route. - * - * @param mixed $value Value. - * @param WP_REST_Request $request Request. - * @param string $param Param. - * @return WP_Error|boolean - */ - public function validate_title_field( $value, $request, $param ) { - $valid = rest_validate_request_arg( $value, $request, $param ); - if ( is_wp_error( $valid ) ) { - return $valid; - } - - if ( $this->should_validate_strictly( $request ) ) { - if ( preg_match( '##', $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s cannot contain markup', 'js-widgets' ), $param ) ); - } - if ( trim( $value ) !== $value ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s contains whitespace padding', 'js-widgets' ), $param ) ); - } - if ( preg_match( '/%[a-f0-9]{2}/i', $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s contains illegal characters (octets)', 'js-widgets' ), $param ) ); - } - } - return true; - } - - /** - * Validate a content request argument based on details registered to the route. - * - * @param mixed $value Value. - * @param WP_REST_Request $request Request. - * @param string $param Param. - * @return WP_Error|boolean - */ - public function validate_content_field( $value, $request, $param ) { - $valid = rest_validate_request_arg( $value, $request, $param ); - if ( is_wp_error( $valid ) ) { - return $valid; - } - - if ( $this->should_validate_strictly( $request ) ) { - if ( ! current_user_can( 'unfiltered_html' ) && wp_kses_post( $value ) !== $value ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s contains illegal markup', 'js-widgets' ), $param ) ); - } - } - return true; - } - - /** - * Sanitize instance data. - * - * @inheritdoc - * - * @param array $new_instance New instance. - * @param array $old_instance Old instance. - * @return array|null|WP_Error Array instance if sanitization (and validation) passed. Returns `WP_Error` or `null` on failure. - */ - public function sanitize( $new_instance, $old_instance ) { - $default_instance = $this->get_default_instance(); - $new_instance = array_merge( $default_instance, $new_instance ); - $old_instance = array_merge( $default_instance, $old_instance ); - $instance = $this->proxied_widget->update( $new_instance, $old_instance ); - return $instance; - } - - /** - * Render JS Template. - * - * This template is intended to be agnostic to the JS template technology used. - */ - public function form_template() { - ?> - - proxied_widget->widget( $args, $instance ); - } - - /** - * Get configuration data for the form. - * - * This can include information such as whether the user can do `unfiltered_html`. - * - * @return array - */ - public function get_form_args() { - return array( - 'can_unfiltered_html' => current_user_can( 'unfiltered_html' ), - 'l10n' => array( - 'title_tags_invalid' => __( 'Tags will be stripped from the title.', 'js-widgets' ), - 'text_unfiltered_html_invalid' => __( 'Protected HTML such as script tags will be stripped from the content.', 'js-widgets' ), - ), - ); - } -} diff --git a/phpcs.ruleset.xml b/phpcs.ruleset.xml index 219a459..b2a57ab 100644 --- a/phpcs.ruleset.xml +++ b/phpcs.ruleset.xml @@ -4,7 +4,12 @@ - + + + + + + diff --git a/post-collection-widget.php b/post-collection-widget.php new file mode 100644 index 0000000..3be9c0c --- /dev/null +++ b/post-collection-widget.php @@ -0,0 +1,37 @@ +version = $matches[1]; + } + } + + /** + * Add hooks. + * + * @access public + */ + public function init() { + if ( ! class_exists( 'JS_Widgets_Plugin' ) ) { + add_action( 'admin_notices', array( $this, 'print_admin_notice_missing_plugin_dependency' ) ); + return; + } + + add_action( 'widgets_init', array( $this, 'register_widget' ) ); + } + + /** + * Show admin notice when the JS Widgets plugin is not active. + */ + public function print_admin_notice_missing_plugin_dependency() { + ?> +
+

+
+ widget = new WP_JS_Widget_Post_Collection( $this ); + register_widget( $this->widget ); + } +} diff --git a/php/widgets/class-wp-js-widget-post-collection.php b/post-collection-widget/class-widget.php similarity index 57% rename from php/widgets/class-wp-js-widget-post-collection.php rename to post-collection-widget/class-widget.php index ed2b163..a7f0b1e 100644 --- a/php/widgets/class-wp-js-widget-post-collection.php +++ b/post-collection-widget/class-widget.php @@ -2,22 +2,22 @@ /** * Class WP_JS_Widget_Recent_Posts. * - * @package JSWidgets + * @package JS_Widgets */ /** * Class WP_JS_Widget_Recent_Posts * - * @package JSWidgets + * @package JS_Widgets */ class WP_JS_Widget_Post_Collection extends WP_JS_Widget { /** * Version of widget. * - * @var string + * @var Post_Collection_JS_Widgets_Plugin */ - public $version = '0.1'; + public $plugin; /** * ID Base. @@ -50,8 +50,12 @@ class WP_JS_Widget_Post_Collection extends WP_JS_Widget { /** * Widget constructor. + * + * @param Post_Collection_JS_Widgets_Plugin $plugin Plugin instance. */ - public function __construct() { + public function __construct( Post_Collection_JS_Widgets_Plugin $plugin ) { + $this->plugin = $plugin; + if ( ! isset( $this->name ) ) { $this->name = __( 'Post Collection', 'js-widgets' ); } @@ -64,13 +68,11 @@ public function __construct() { * @param WP_Scripts $wp_scripts Scripts. */ public function register_scripts( $wp_scripts ) { - $suffix = ( SCRIPT_DEBUG ? '' : '.min' ) . '.js'; - $plugin_dir_url = plugin_dir_url( dirname( dirname( __FILE__ ) ) ); - - $handle = 'customize-widget-post-collection'; - $src = $plugin_dir_url . 'js/widgets/customize-widget-post-collection' . $suffix; + $plugin_dir_url = plugin_dir_url( __FILE__ ); + $handle = 'customize-widget-form-post-collection'; + $src = $plugin_dir_url . 'form.js'; $deps = array( 'customize-js-widgets' ); - $wp_scripts->add( $handle, $src, $deps, $this->version ); + $wp_scripts->add( $handle, $src, $deps, $this->plugin->version ); } /** @@ -79,18 +81,17 @@ public function register_scripts( $wp_scripts ) { * @param WP_Styles $wp_styles Styles. */ public function register_styles( $wp_styles ) { - $suffix = ( SCRIPT_DEBUG ? '' : '.min' ) . '.css'; - $plugin_dir_url = plugin_dir_url( dirname( dirname( __FILE__ ) ) ); + $plugin_dir_url = plugin_dir_url( __FILE__ ); - $handle = 'customize-widget-post-collection'; - $src = $plugin_dir_url . 'css/customize-widget-post-collection' . $suffix; + $handle = 'customize-widget-form-post-collection'; + $src = $plugin_dir_url . 'form.css'; $deps = array( 'select2', 'customize-object-selector' ); - $wp_styles->add( $handle, $src, $deps, $this->version ); + $wp_styles->add( $handle, $src, $deps, $this->plugin->version ); $handle = 'frontend-widget-post-collection'; - $src = $plugin_dir_url . 'css/frontend-widget-post-collection' . $suffix; + $src = $plugin_dir_url . 'view.css'; $deps = array(); - $wp_styles->add( $handle, $src, $deps, $this->version ); + $wp_styles->add( $handle, $src, $deps, $this->plugin->version ); } /** @@ -99,14 +100,14 @@ public function register_styles( $wp_styles ) { public function enqueue_control_scripts() { // Gracefully handle the customize-object-selector plugin not being active. - $handle = 'customize-widget-post-collection'; + $handle = 'customize-widget-form-post-collection'; $external_dep_handle = 'customize-object-selector-component'; if ( wp_scripts()->query( $external_dep_handle ) ) { wp_scripts()->query( $handle )->deps[] = $external_dep_handle; } wp_enqueue_script( $handle ); - wp_enqueue_style( 'customize-widget-post-collection' ); + wp_enqueue_style( 'customize-widget-form-post-collection' ); } /** @@ -122,66 +123,46 @@ public function enqueue_frontend_scripts() { * @return array Schema. */ public function get_item_schema() { - $schema = array( - 'title' => array( - 'description' => __( 'The title for the widget.', 'js-widgets' ), - 'type' => 'object', - 'context' => array( 'view', 'edit', 'embed' ), - 'properties' => array( - 'raw' => array( - 'description' => __( 'Title for the widget, as it exists in the database.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'edit' ), - 'default' => '', - 'arg_options' => array( - 'validate_callback' => array( $this, 'validate_title_field' ), - ), - ), - 'rendered' => array( - 'description' => __( 'HTML title for the widget, transformed for display.', 'js-widgets' ), - 'type' => 'string', - 'context' => array( 'view', 'edit', 'embed' ), - 'default' => __( 'Recent Posts', 'js-widgets' ), - 'readonly' => true, + $schema = array_merge( + parent::get_item_schema(), + array( + 'show_date' => array( + 'description' => __( 'Whether the date should be shown.', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'validate_callback' => 'rest_validate_request_arg', ), ), - ), - 'show_date' => array( - 'description' => __( 'Whether the date should be shown.', 'js-widgets' ), - 'type' => 'boolean', - 'default' => false, - 'context' => array( 'view', 'edit', 'embed' ), - 'arg_options' => array( - 'validate_callback' => 'rest_validate_request_arg', - ), - ), - 'show_featured_image' => array( - 'description' => __( 'Whether the featured image is shown.', 'js-widgets' ), - 'type' => 'boolean', - 'default' => false, - 'context' => array( 'view', 'edit', 'embed' ), - 'arg_options' => array( - 'validate_callback' => 'rest_validate_request_arg', + 'show_featured_image' => array( + 'description' => __( 'Whether the featured image is shown.', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'validate_callback' => 'rest_validate_request_arg', + ), ), - ), - 'show_author' => array( - 'description' => __( 'Whether the author is shown.', 'js-widgets' ), - 'type' => 'boolean', - 'default' => false, - 'context' => array( 'view', 'edit', 'embed' ), - 'arg_options' => array( - 'validate_callback' => 'rest_validate_request_arg', + 'show_author' => array( + 'description' => __( 'Whether the author is shown.', 'js-widgets' ), + 'type' => 'boolean', + 'default' => false, + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'validate_callback' => 'rest_validate_request_arg', + ), ), - ), - 'posts' => array( - 'description' => __( 'The IDs for the collected posts.', 'js-widgets' ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', + 'posts' => array( + 'description' => __( 'The IDs for the collected posts.', 'js-widgets' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'default' => array(), ), - 'context' => array( 'view', 'edit', 'embed' ), - 'default' => array(), - ), + ) ); return $schema; } @@ -199,26 +180,15 @@ public function get_item_schema() { * @return array Widget item. */ public function prepare_item_for_response( $instance, $request ) { - unset( $request ); - - $schema = $this->get_item_schema(); - $instance = array_merge( $this->get_default_instance(), $instance ); - - $title_rendered = $instance['title'] ? $instance['title'] : $schema['title']['properties']['rendered']['default']; - /** This filter is documented in src/wp-includes/widgets/class-wp-widget-pages.php */ - $title_rendered = apply_filters( 'widget_title', $title_rendered, $instance, $this->id_base ); - - $item = array( - 'title' => array( - 'raw' => $instance['title'], - 'rendered' => $title_rendered, - ), - 'posts' => $instance['posts'], - 'show_date' => $instance['show_date'], - 'show_featured_image' => $instance['show_featured_image'], - 'show_author' => $instance['show_author'], + $item = array_merge( + parent::prepare_item_for_response( $instance, $request ), + array( + 'posts' => $instance['posts'], + 'show_date' => $instance['show_date'], + 'show_featured_image' => $instance['show_featured_image'], + 'show_author' => $instance['show_author'], + ) ); - return $item; } @@ -257,34 +227,6 @@ public function get_rest_response_links( $response, $request, $controller ) { return $links; } - /** - * Validate a title request argument based on details registered to the route. - * - * @param mixed $value Value. - * @param WP_REST_Request $request Request. - * @param string $param Param. - * @return WP_Error|boolean - */ - public function validate_title_field( $value, $request, $param ) { - $valid = rest_validate_request_arg( $value, $request, $param ); - if ( is_wp_error( $valid ) ) { - return $valid; - } - - if ( $this->should_validate_strictly( $request ) ) { - if ( preg_match( '##', $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s cannot contain markup', 'js-widgets' ), $param ) ); - } - if ( trim( $value ) !== $value ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s contains whitespace padding', 'js-widgets' ), $param ) ); - } - if ( preg_match( '/%[a-f0-9]{2}/i', $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s contains illegal characters (octets)', 'js-widgets' ), $param ) ); - } - } - return true; - } - /** * Sanitize instance data. * @@ -295,12 +237,10 @@ public function validate_title_field( $value, $request, $param ) { * @return array|null|WP_Error Array instance if sanitization (and validation) passed. Returns `WP_Error` or `null` on failure. */ public function sanitize( $new_instance, $old_instance ) { - unset( $old_instance ); - $instance = array_merge( $this->get_default_instance(), $new_instance ); - $instance['title'] = sanitize_text_field( $instance['title'] ); + $instance = parent::sanitize( $new_instance, $old_instance ); $instance['posts'] = array_filter( wp_parse_id_list( $instance['posts'] ) ); foreach ( array( 'show_date', 'show_featured_image', 'show_author' ) as $field ) { - $instance[ $field ] = boolval( $instance[ $field ] ); + $instance[ $field ] = (bool) $instance[ $field ]; } return $instance; } @@ -406,7 +346,7 @@ public function get_form_args() { */ public function form_template() { ?> - diff --git a/css/customize-widget-post-collection.css b/post-collection-widget/form.css similarity index 100% rename from css/customize-widget-post-collection.css rename to post-collection-widget/form.css diff --git a/post-collection-widget/form.js b/post-collection-widget/form.js new file mode 100644 index 0000000..e97356e --- /dev/null +++ b/post-collection-widget/form.js @@ -0,0 +1,93 @@ +/* global wp, module */ +/* eslint consistent-this: [ "error", "form" ] */ +/* eslint no-magic-numbers: [ "error", {"ignore":[0,1]} ] */ +/* eslint-disable strict */ +/* eslint-disable complexity */ + +wp.customize.Widgets.formConstructor['post-collection'] = (function( api, $ ) { + 'use strict'; + + var PostCollectionWidgetForm; + + /** + * Post Collection Widget Form. + * + * @constructor + */ + PostCollectionWidgetForm = api.Widgets.Form.extend({ + + /** + * Initialize. + * + * @param {object} properties Properties. + * @param {wp.customize.Widgets.WidgetControl} properties.control Customize control. + * @param {object} properties.config Form config. + * @return {void} + */ + initialize: function initializePostCollectionWidgetForm( properties ) { + var form = this, props; + + props = _.clone( properties ); + props.config = _.clone( props.config ); + props.config.select_id = 'select' + String( Math.random() ); + + api.Widgets.Form.prototype.initialize.call( form, props ); + }, + + /** + * Render. + * + * @inheritDoc + * @returns {void} + */ + render: function render() { + var form = this, selectorContainer; + api.Widgets.Form.prototype.render.call( form ); + + if ( api.ObjectSelectorComponent ) { + + if ( ! form.postsItemTemplate ) { + form.postsItemTemplate = wp.template( 'customize-widget-post-collection-select2-option' ); + } + + form.postObjectSelector = new api.ObjectSelectorComponent({ + model: form.syncedProperties.posts.value, + containing_construct: form.control, + post_query_vars: form.config.post_query_args, + select2_options: _.extend( + { + multiple: true, + width: '100%' + }, + form.config.select2_options + ), + select_id: form.config.select_id, + select2_result_template: form.postsItemTemplate, + select2_selection_template: form.postsItemTemplate + }); + selectorContainer = form.container.find( '.customize-object-selector-container:first' ); + form.postObjectSelector.embed( selectorContainer ); + } + }, + + /** + * Link property elements. + * + * @returns {void} + */ + linkPropertyElements: function linkPropertyElements() { + var form = this; + + api.Widgets.Form.prototype.linkPropertyElements.call( form ); + if ( api.ObjectSelectorComponent ) { + form.syncedProperties.posts = form.createSyncedPropertyValue( form.setting, 'posts' ); + } + } + }); + + if ( 'undefined' !== typeof module ) { + module.exports = PostCollectionWidgetForm; + } + return PostCollectionWidgetForm; + +})( wp.customize, jQuery ); diff --git a/css/frontend-widget-post-collection.css b/post-collection-widget/view.css similarity index 100% rename from css/frontend-widget-post-collection.css rename to post-collection-widget/view.css diff --git a/readme.md b/readme.md index 158733f..8eb3541 100644 --- a/readme.md +++ b/readme.md @@ -5,9 +5,9 @@ The next generation of widgets in core, embracing JS for UI and powering the Wid **Contributors:** [xwp](https://profiles.wordpress.org/xwp), [westonruter](https://profiles.wordpress.org/westonruter) **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.5 -**Tested up to:** 4.7-alpha -**Stable tag:** 0.1.1 +**Requires at least:** 4.7.0 +**Tested up to:** 4.7.0 +**Stable tag:** 0.2.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) @@ -22,28 +22,57 @@ This plugin implements: * [WP-CORE#35574](https://core.trac.wordpress.org/ticket/35574): Add REST API JSON schema information to WP_Widget. * [WP-API#19](https://github.com/WP-API/WP-API/issues/19): Add widget endpoints to the WP REST API. -Plugin Dependencies: - -* [WordPress REST API v2](https://wordpress.org/plugins/rest-api/) -* [Customize Setting Validation](https://github.com/xwp/wp-customize-setting-validation) (recommended) - Features: -* Widget instance settings in the Customizer are exported from PHP as regular JSON without any PHP-serialized base64-encoded `encoded_serialized_instance`. -* Customizer settings can be directly mutated via JavaScript instead of needing to do an `update-widget` Admin Ajax roundtrip; this greatly speeds up previewing. -* Widget have a technology-agnostic JS API for building their forms, allowing Backbone, React, or any other frontend technology to be used for managing the form. +* 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. +* 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 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. +* A widget instance can be blocked from being saved by returning a `WP_Error` from its `validate` or `sanitize` method. For example, the RSS widget will show an error message if the feed URL provided is invalid and the widget will block from saving until the URL is corrected. +* Widgets are exposed under the `js-widgets/v1` namespace, for example to list all Recent Posts widgets via the `/js-widgets/v1/widgets/recent-posts` or to get the Text widget with the “ID” (number) of 6, `/js-widgets/v1/widgets/text/6`. +* Customizer settings for widget instances (`widget_{id_base}[{number}]`) are directly mutated via JavaScript instead of needing to do an `update-widget` Admin Ajax roundtrip; this greatly speeds up previewing. +* Widget control forms can be extended to employ any JS framework for managing the UI, allowing Backbone, React, or any other frontend technology to be used. * Compatible with widgets stored in a custom post type instead of options, via the Widget Posts module in the [Customize Widgets Plus](https://github.com/xwp/wp-customize-widgets-plus) plugin. * Compatible with [Customize Snapshots](https://github.com/xwp/wp-customize-snapshots), allowing changes made in the Customizer to be applied to requests for widgets via the REST API. -* Compatible with [Customize Setting Validation](https://github.com/xwp/wp-customize-setting-validation). -* Includes (eventually) re-implementation of all core widgets using the new `WP_JS_Widget` API. +* Includes adaptations of all core widgets using the new `WP_JS_Widget` API. +* The adapted core widgets include additional raw data in their REST API item responses so that JS can render them client-side. +* The Notifications API is utilized to display warnings when a user attempts to provide markup in a core widget title or illegal HTML in a Text widget's content. +* The Pages widget in Core is enhanced to make use of [Customize Object Selector](https://wordpress.org/plugins/customize-object-selector/) if available to display a Select2 UI for selecting pages to exclude instead of providing page IDs. +* An bonus bundled plugin provides a “Post Collection” widget which, if the [Customize Object Selector](https://wordpress.org/plugins/customize-object-selector/) plugin is installed, will provide a UI for curating an arbitrary list of posts to display. + +This plugin doesn't yet implement any widgets that use JS templating for _frontend_ rendering of the widgets. For that, please see the [Next Recent Posts Widget](https://github.com/xwp/wp-next-recent-posts-widget) plugin. Limitations/Caveats: * Widgets that extend `WP_JS_Widget` will not be editable from widgets admin page. A link to edit the widget in the Customizer will be displayed instead. -* Only widgets that extend `WP_JS_Widget` will be exposed via the REST API. The plugin includes a `WP_JS_Widget` proxy class which demonstrates how to adapt existing `WP_Widget` classes for the new widget functionality. +* Only widgets that extend `WP_JS_Widget` will be exposed via the REST API. The plugin includes a `WP_JS_Widget` adapter class which demonstrates how to adapt existing `WP_Widget` classes for the new widget functionality. ## Changelog ## +### 0.2.0 - 2017-01-02 ### +* Important: Update minimum WordPress core version to 4.7.0. +* Eliminate `Form#embed` JS method in favor of just `Form#render`. Introduce `Form#destruct` to handle unmounting a rendered form. +* Implement ability for sanitize to return error/notification and display in control's notifications. +* Show warning when attempting to add HTML to widget titles and when adding illegal HTML to Text widget content. This is a UX improvement over silently failing. +* Add adapters for all of the core widgets (aside from Links). Include as much raw data as possible in the REST responses so that JS clients can construct widgets using client-side templates. +* Add integration between the Pages widget's `exclude` param and the [Customize Object Selector](https://wordpress.org/plugins/customize-object-selector/) plugin to provide a Select2 UI for selecting pages to exclude instead of listing out page IDs. +* Ensure old encoded instance data setting value format is supported (such as in starter content). +* Move Post Collection widget into separate embedded plugin so that it is not active by default. +* Inject rest_controller object dependency on `WP_JS_Widget` upon `rest_api_init`. +* Ensure that default instance values populate forms for newly-added widgets. +* Remove React/Redux for implementing the Recent Posts widget. +* Reorganize core adapter widgets and introduce `WP_Adapter_JS_Widget` class. +* Eliminate uglification and CSS minification. +* Use widget number as integer ID for widgets of a given type. +* Update integration with REST API to take advantage of sanitization callbacks being able to do validation. +* Replace Backbone implementation for Text widget with Customize `Element` implementation. +* Reduce duplication by moving methods to base classes. +* Add form field template generator helper methods. +* Implement [WP Core Trac #39389](https://core.trac.wordpress.org/ticket/39389): Scroll widget partial into view when control expanded. +* Allow widget instances to be patched without providing full instance. +* Remove prototype strict validity for REST item updates. +* Add support for validating schemas with type arrays and object types; allow strings or objects with `raw`/`rendered` properties for titles & Text widget's text field. +* Eliminate returning data from `WP_JS_Widget::render()` for client templates to render until a clear use case and pattern can be derived. + ### 0.1.1 - 2016-10-03 ### * Add 100% width to object-selector. * Fix typo in sanitizing Post Collection input. diff --git a/readme.txt b/readme.txt index c55f21b..ad28aeb 100644 --- a/readme.txt +++ b/readme.txt @@ -1,9 +1,9 @@ === JS Widgets === Contributors: xwp, westonruter Tags: customizer, widgets, rest-api -Requires at least: 4.5 -Tested up to: 4.7-alpha -Stable tag: 0.1.1 +Requires at least: 4.7.0 +Tested up to: 4.7.0 +Stable tag: 0.2.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -19,28 +19,58 @@ This plugin implements: * [WP-CORE#35574](https://core.trac.wordpress.org/ticket/35574): Add REST API JSON schema information to WP_Widget. * [WP-API#19](https://github.com/WP-API/WP-API/issues/19): Add widget endpoints to the WP REST API. -Plugin Dependencies: - -* [WordPress REST API v2](https://wordpress.org/plugins/rest-api/) -* [Customize Setting Validation](https://github.com/xwp/wp-customize-setting-validation) (recommended) - Features: -* Widget instance settings in the Customizer are exported from PHP as regular JSON without any PHP-serialized base64-encoded `encoded_serialized_instance`. -* Customizer settings can be directly mutated via JavaScript instead of needing to do an `update-widget` Admin Ajax roundtrip; this greatly speeds up previewing. -* Widget have a technology-agnostic JS API for building their forms, allowing Backbone, React, or any other frontend technology to be used for managing the form. +* 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. +* 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 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. +* A widget instance can be blocked from being saved by returning a `WP_Error` from its `validate` or `sanitize` method. For example, the RSS widget will show an error message if the feed URL provided is invalid and the widget will block from saving until the URL is corrected. +* Widgets are exposed under the `js-widgets/v1` namespace, for example to list all Recent Posts widgets via the `/js-widgets/v1/widgets/recent-posts` or to get the Text widget with the “ID” (number) of 6, `/js-widgets/v1/widgets/text/6`. +* Customizer settings for widget instances (`widget_{id_base}[{number}]`) are directly mutated via JavaScript instead of needing to do an `update-widget` Admin Ajax roundtrip; this greatly speeds up previewing. +* Widget control forms can be extended to employ any JS framework for managing the UI, allowing Backbone, React, or any other frontend technology to be used. * Compatible with widgets stored in a custom post type instead of options, via the Widget Posts module in the [Customize Widgets Plus](https://github.com/xwp/wp-customize-widgets-plus) plugin. * Compatible with [Customize Snapshots](https://github.com/xwp/wp-customize-snapshots), allowing changes made in the Customizer to be applied to requests for widgets via the REST API. -* Compatible with [Customize Setting Validation](https://github.com/xwp/wp-customize-setting-validation). -* Includes (eventually) re-implementation of all core widgets using the new `WP_JS_Widget` API. +* Includes adaptations of all core widgets using the new `WP_JS_Widget` API. +* The adapted core widgets include additional raw data in their REST API item responses so that JS can render them client-side. +* The Notifications API is utilized to display warnings when a user attempts to provide markup in a core widget title or illegal HTML in a Text widget's content. +* The Pages widget in Core is enhanced to make use of [Customize Object Selector](https://wordpress.org/plugins/customize-object-selector/) if available to display a Select2 UI for selecting pages to exclude instead of providing page IDs. +* An bonus bundled plugin provides a “Post Collection” widget which, if the [Customize Object Selector](https://wordpress.org/plugins/customize-object-selector/) plugin is installed, will provide a UI for curating an arbitrary list of posts to display. + +This plugin doesn't yet implement any widgets that use JS templating for _frontend_ rendering of the widgets. For that, please see the [Next Recent Posts Widget](https://github.com/xwp/wp-next-recent-posts-widget) plugin. Limitations/Caveats: * Widgets that extend `WP_JS_Widget` will not be editable from widgets admin page. A link to edit the widget in the Customizer will be displayed instead. -* Only widgets that extend `WP_JS_Widget` will be exposed via the REST API. The plugin includes a `WP_JS_Widget` proxy class which demonstrates how to adapt existing `WP_Widget` classes for the new widget functionality. +* Only widgets that extend `WP_JS_Widget` will be exposed via the REST API. The plugin includes a `WP_JS_Widget` adapter class which demonstrates how to adapt existing `WP_Widget` classes for the new widget functionality. == Changelog == += 0.2.0 - 2017-01-02 = + +* Important: Update minimum WordPress core version to 4.7.0. +* Eliminate `Form#embed` JS method in favor of just `Form#render`. Introduce `Form#destruct` to handle unmounting a rendered form. +* Implement ability for sanitize to return error/notification and display in control's notifications. +* Show warning when attempting to add HTML to widget titles and when adding illegal HTML to Text widget content. This is a UX improvement over silently failing. +* Add adapters for all of the core widgets (aside from Links). Include as much raw data as possible in the REST responses so that JS clients can construct widgets using client-side templates. +* Add integration between the Pages widget's `exclude` param and the [Customize Object Selector](https://wordpress.org/plugins/customize-object-selector/) plugin to provide a Select2 UI for selecting pages to exclude instead of listing out page IDs. +* Ensure old encoded instance data setting value format is supported (such as in starter content). +* Move Post Collection widget into separate embedded plugin so that it is not active by default. +* Inject rest_controller object dependency on `WP_JS_Widget` upon `rest_api_init`. +* Ensure that default instance values populate forms for newly-added widgets. +* Remove React/Redux for implementing the Recent Posts widget. +* Reorganize core adapter widgets and introduce `WP_Adapter_JS_Widget` class. +* Eliminate uglification and CSS minification. +* Use widget number as integer ID for widgets of a given type. +* Update integration with REST API to take advantage of sanitization callbacks being able to do validation. +* Replace Backbone implementation for Text widget with Customize `Element` implementation. +* Reduce duplication by moving methods to base classes. +* Add form field template generator helper methods. +* Implement [WP Core Trac #39389](https://core.trac.wordpress.org/ticket/39389): Scroll widget partial into view when control expanded. +* Allow widget instances to be patched without providing full instance. +* Remove prototype strict validity for REST item updates. +* Add support for validating schemas with type arrays and object types; allow strings or objects with `raw`/`rendered` properties for titles & Text widget's text field. +* Eliminate returning data from `WP_JS_Widget::render()` for client templates to render until a clear use case and pattern can be derived. + = 0.1.1 - 2016-10-03 = * Add 100% width to object-selector.