diff --git a/bin/webpack.metaboxes.js b/bin/webpack.metaboxes.js index f0ad20604..ca1834021 100644 --- a/bin/webpack.metaboxes.js +++ b/bin/webpack.metaboxes.js @@ -15,6 +15,10 @@ const config = { entry: { metaboxes: './packages/metaboxes/index.js' }, + output: { + library: [ 'cf', '[name]' ], + libraryTarget: 'this' + }, externals: { 'react': [ 'cf', 'vendor', 'react' ], 'react-dom': [ 'cf', 'vendor', 'react-dom' ], diff --git a/core/REST_API/Router.php b/core/REST_API/Router.php index 187aaae85..cf23f6039 100644 --- a/core/REST_API/Router.php +++ b/core/REST_API/Router.php @@ -287,25 +287,37 @@ public function get_comment_meta( $data ) { /** * Get Carbon Fields association options data. * + * @access public + * * @return array */ public function get_association_data() { $container_id = $_GET['container_id']; $field_id = $_GET['field_id']; - $options = isset( $_GET['options'] ) ? $_GET['options'] : array(); + $options = isset( $_GET['options'] ) ? explode( ';', $_GET['options'] ) : array(); $return_value = array(); $field = Helper::get_field( null, $container_id, $field_id ); - foreach ( $options as $entry ) { + $options = array_map( function ( $option ) { + $option = explode( ':', $option ); + + return [ + 'id' => $option[0], + 'type' => $option[1], + 'subtype' => $option[2], + ]; + }, $options ); + + foreach ( $options as $option ) { $item = array( - 'type' => $entry['type'], - 'subtype' => $entry['subtype'], - 'thumbnail' => $field->get_thumbnail_by_type( $entry['id'], $entry['type'], $entry['subtype'] ), - 'id' => intval( $entry['id'] ), - 'title' => $field->get_title_by_type( $entry['id'], $entry['type'], $entry['subtype'] ), - 'label' => $field->get_item_label( $entry['id'], $entry['type'], $entry['subtype'] ), - 'is_trashed' => ( $entry['type'] == 'post' && get_post_status( $entry['id'] ) === 'trash' ), + 'type' => $option['type'], + 'subtype' => $option['subtype'], + 'thumbnail' => $field->get_thumbnail_by_type( $option['id'], $option['type'], $option['subtype'] ), + 'id' => intval( $option['id'] ), + 'title' => $field->get_title_by_type( $option['id'], $option['type'], $option['subtype'] ), + 'label' => $field->get_item_label( $option['id'], $option['type'], $option['subtype'] ), + 'is_trashed' => ( $option['type'] == 'post' && get_post_status( $option['id'] ) === 'trash' ), ); $return_value[] = $item; diff --git a/packages/core/fields/association/index.js b/packages/core/fields/association/index.js index c15fd288a..fabafbc78 100644 --- a/packages/core/fields/association/index.js +++ b/packages/core/fields/association/index.js @@ -18,7 +18,6 @@ import { without, isMatch, isEmpty - } from 'lodash'; import { combine, @@ -44,6 +43,13 @@ class AssociationField extends Component { */ selectedList = createRef(); + /** + * Keeps reference to the DOM bnode that contains the options. + * + * @type {Object} + */ + sourceList = createRef(); + /** * Lifecycle hook. * @@ -65,6 +71,47 @@ class AssociationField extends Component { if ( value ) { fetchSelectedOptions(); } + + this.sourceList.current.addEventListener( 'scroll', this.handleSourceListScroll ); + } + + /** + * Lifecycle hook. + * + * @return {void} + */ + componentWillUnmount() { + this.sourceList.current.removeEventListener( 'scroll', this.handleSourceListScroll ); + } + + /** + * Handles the scroll event of the source list. + * + * @return {void} + */ + handleSourceListScroll = () => { + const { + fetchOptions, + setState, + options, + page, + queryTerm + } = this.props; + + const sourceList = this.sourceList.current; + + if ( sourceList.offsetHeight + sourceList.scrollTop === sourceList.scrollHeight ) { + setState( { + page: page + 1 + } ); + + fetchOptions( { + type: 'append', + options: options, + queryTerm, + page: page + 1 + } ); + } } /** @@ -79,8 +126,16 @@ class AssociationField extends Component { setState } = this.props; - setState( { queryTerm } ); - fetchOptions( { queryTerm } ); + setState( { + page: 1, + queryTerm + } ); + + fetchOptions( { + type: 'replace', + page: 1, + queryTerm + } ); } /** @@ -172,7 +227,8 @@ class AssociationField extends Component { field, totalOptionsCount, selectedOptions, - queryTerm + queryTerm, + isLoading } = this.props; let { options } = this.props; @@ -199,6 +255,12 @@ class AssociationField extends Component { onChange={ this.handleSearchChange } /> + { + isLoading + ? + : '' + } + { sprintf( __( 'Showing %1$d of %2$d results', 'carbon-fields-ui' ), @@ -209,7 +271,7 @@ class AssociationField extends Component {
-
+
{ options.map( ( option, index ) => { return ( @@ -220,7 +282,9 @@ class AssociationField extends Component {
- { option.title } + + { option.title } + @@ -368,10 +432,15 @@ function handler( props ) { switch ( type ) { case 'FETCH_OPTIONS': + setState( { + isLoading: true + } ); + // eslint-disable-next-line const request = window.jQuery.get( window.ajaxurl, { action: 'carbon_fields_fetch_association_options', term: payload.queryTerm, + page: payload.page || 1, container_id: props.containerId, field_name: hierarchyResolver() }, null, 'json' ); @@ -382,7 +451,7 @@ function handler( props ) { request.done( ( response ) => { if ( response && response.success ) { setState( { - options: response.data.options, + options: payload.type === 'replace' ? response.data.options : [ ...payload.options, ...response.data.options ], totalOptionsCount: response.data.total_options } ); } else { @@ -391,6 +460,11 @@ function handler( props ) { } ); request.fail( errorHandler ); + request.always( () => { + setState( { + isLoading: false + } ); + } ); break; case 'FETCH_SELECTED_OPTIONS': @@ -399,7 +473,7 @@ function handler( props ) { 'get', { container_id: props.containerId, - options: props.value, + options: props.value.map( ( option ) => `${ option.id }:${ option.type }:${ option.subtype }` ).join( ';' ), field_id: hierarchyResolver() } ) @@ -418,7 +492,9 @@ const applyWithState = withState( { options: [], selectedOptions: [], totalOptionsCount: 0, - queryTerm: '' + queryTerm: '', + page: 1, + isLoading: false } ); const applyWithEffects = withEffects( aperture, { handler } ); diff --git a/packages/core/fields/association/style.scss b/packages/core/fields/association/style.scss index df0d1af27..889d06c16 100644 --- a/packages/core/fields/association/style.scss +++ b/packages/core/fields/association/style.scss @@ -5,17 +5,49 @@ .cf-association__bar { position: relative; z-index: 1; + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row; + border-color: $wp-color-gray-light-500; + border-style: solid; + border-width: 1px 1px 0; + box-shadow: inset 0 1px 2px rgba( 0, 0, 0, 0.07 ); + + .cf-search-input { + flex: 1 1 auto; + } + + .cf-search-input__inner { + border: 0; + box-shadow: none; + + &:focus { + border-color: none; + box-shadow: none; + outline: none; + } + } + + &:focus-within { + border-color: #5b9dd9; + box-shadow: 0 0 2px rgba( 30, 140, 190, 0.8 ); + outline: 2px solid transparent; + } } .cf-association__counter { - position: absolute; - top: 50%; - right: 8px; font-size: 12px; - line-height: 1; color: $wp-color-dark-gray; - transform: translateY(-50%); pointer-events: none; + margin-right: 5px; + margin-left: 5px; +} + +.cf-association__spinner { + float: none; + margin: 0; + margin-left: 5px; } .cf-association__cols { @@ -23,7 +55,7 @@ position: relative; z-index: 0; display: flex; - border-width: 0 1px 1px; + border-width: 1px; border-style: solid; border-color: $wp-color-gray-light-500; @@ -83,12 +115,8 @@ } .cf-association__option-title { - overflow: hidden; - font-size: $wp-font-size; - line-height: $wp-line-height; - color: $wp-color-base-gray; - white-space: nowrap; - text-overflow: ellipsis; + flex: 1; + position: relative; margin-right: $size-base; .cf-association__option--selected & { @@ -96,6 +124,20 @@ } } +.cf-association__option-title-inner { + position: absolute; + top: 0; + left: 0; + width: 100%; + font-size: $wp-font-size; + line-height: $wp-line-height; + color: $wp-color-base-gray; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + transform: translateY(-50%); +} + .cf-association__option-type { font-size: 9px; line-height: 1; diff --git a/packages/metaboxes/containers/index.js b/packages/metaboxes/containers/index.js index d1d13a635..e86e3d171 100644 --- a/packages/metaboxes/containers/index.js +++ b/packages/metaboxes/containers/index.js @@ -45,7 +45,10 @@ export function renderContainer( container, context ) { if ( node ) { render( , - node + node, + () => { + node.dataset.mounted = true; + } ); } else { // eslint-disable-next-line no-console diff --git a/packages/metaboxes/index.js b/packages/metaboxes/index.js index 4f4fe1832..4d85d20c1 100644 --- a/packages/metaboxes/index.js +++ b/packages/metaboxes/index.js @@ -13,6 +13,11 @@ import initializeMonitors from './monitors'; import initializeContainers from './containers'; import isGutenberg from './utils/is-gutenberg'; +/** + * Public API. + */ +export { registerContainerType, getContainerType } from './containers/registry'; + /** * Sets the locale data for the package type */ diff --git a/packages/metaboxes/monitors/conditional-display/aperture/post-template.js b/packages/metaboxes/monitors/conditional-display/aperture/post-template.js index a7bf4ea71..c5f0f9678 100644 --- a/packages/metaboxes/monitors/conditional-display/aperture/post-template.js +++ b/packages/metaboxes/monitors/conditional-display/aperture/post-template.js @@ -35,8 +35,15 @@ const INITIAL_STATE = { * @return {Object} */ function getPostTemplateFromSelect( node ) { + let { value } = node; + + // In Gutenberg for the "Default" template is used an empty string. + if ( value === 'default' ) { + value = ''; + } + return { - post_template: node.value + post_template: value }; } diff --git a/packages/metaboxes/monitors/conditional-display/conditions/post-template.js b/packages/metaboxes/monitors/conditional-display/conditions/post-template.js new file mode 100644 index 000000000..d6e4c9fc1 --- /dev/null +++ b/packages/metaboxes/monitors/conditional-display/conditions/post-template.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies. + */ +import base from './base'; + +export default { + ...base, + + /** + * @inheritdoc + */ + isFulfiled( definition, values ) { + definition = { ...definition }; + + // In Gutenberg for the "Default" template is used an empty string. + if ( definition.value === 'default' ) { + definition.value = ''; + } + + return base.isFulfiled( definition, values ); + } +}; diff --git a/packages/metaboxes/monitors/conditional-display/handler/index.js b/packages/metaboxes/monitors/conditional-display/handler/index.js index 22b324f47..f6bba8aaf 100644 --- a/packages/metaboxes/monitors/conditional-display/handler/index.js +++ b/packages/metaboxes/monitors/conditional-display/handler/index.js @@ -1,7 +1,6 @@ /** * External dependencies. */ -import { unmountComponentAtNode } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { get, map } from 'lodash'; @@ -12,6 +11,7 @@ import { renderContainer } from '../../../containers'; import base from '../conditions/base'; import boolean from '../conditions/boolean'; import postTerm from '../conditions/post-term'; +import postTemplate from '../conditions/post-template'; import postAncestorId from '../conditions/post-ancestor-id'; import termParentId from '../conditions/term-parent-id'; import termAncestorId from '../conditions/term-ancestor-id'; @@ -28,7 +28,7 @@ const conditions = { post_parent_id: base, post_level: base, post_format: base, - post_template: base, + post_template: postTemplate, term_level: base, term_parent: termParentId, term_ancestor: termAncestorId, @@ -96,22 +96,25 @@ export default function handler( { containers, context } ) { results.forEach( ( [ id, result ] ) => { const postboxNode = document.getElementById( id ); const containerNode = document.querySelector( `.container-${ id }` ); + const isMounted = !! containerNode.dataset.mounted; if ( postboxNode ) { postboxNode.hidden = ! result; } if ( containerNode ) { - if ( result && ! containerNode.dataset.mounted ) { - containerNode.dataset.mounted = true; - + if ( result && ! isMounted ) { renderContainer( containers[ id ], context ); } - if ( ! result && containerNode.dataset.mounted ) { + if ( ! result && isMounted ) { delete containerNode.dataset.mounted; - unmountComponentAtNode( containerNode ); + // Rely on React's internals instead of `unmountComponentAtNode` + // due to https://github.com/facebook/react/issues/13690. + // TODO: Conditionally render the fields in the container, this way + // we can move away from mount/unmount cycles. + containerNode._reactRootContainer.unmount(); } } } ); diff --git a/packages/metaboxes/utils/is-gutenberg.js b/packages/metaboxes/utils/is-gutenberg.js index 63eeeb46b..2e0de431b 100644 --- a/packages/metaboxes/utils/is-gutenberg.js +++ b/packages/metaboxes/utils/is-gutenberg.js @@ -6,11 +6,8 @@ import { isUndefined } from 'lodash'; /** * Returns true if Gutenberg is presented. * - * For some reason the Gutenberg package uses `_wpLoadGutenbergEditor` - * while WordPress@5 uses `_wpLoadBlockEditor`. - * * @return {boolean} */ export default function isGutenberg() { - return ! isUndefined( window._wpLoadGutenbergEditor ) || ! isUndefined( window._wpLoadBlockEditor ); + return ! isUndefined( window._wpLoadBlockEditor ); }