diff --git a/lib/init.php b/lib/init.php index c6a7c66af502f..a834a946805b2 100644 --- a/lib/init.php +++ b/lib/init.php @@ -187,6 +187,33 @@ function register_site_icon_url( $response ) { add_filter( 'rest_index', 'register_site_icon_url' ); +/** + * Exposes the site logo to the Gutenberg editor through the WordPress REST + * API. This is used for fetching this information when user has no rights + * to update settings. + * + * @since 10.9 + * + * @param WP_REST_Response $response Response data served by the WordPress REST index endpoint. + * @return WP_REST_Response + */ +function register_site_logo_to_rest_index( $response ) { + $site_logo_id = get_theme_mod( 'custom_logo' ); + $response->data['site_logo'] = $site_logo_id; + if ( $site_logo_id ) { + $response->add_link( + 'https://api.w.org/featuredmedia', + rest_url( 'wp/v2/media/' . $site_logo_id ), + array( + 'embeddable' => true, + ) + ); + } + return $response; +} + +add_filter( 'rest_index', 'register_site_logo_to_rest_index' ); + add_theme_support( 'widgets-block-editor' ); /** diff --git a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js index fec66c1ef0913..178f42ec04b15 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js +++ b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js @@ -59,7 +59,7 @@ function useInsertionPoint( { let _destinationRootClientId = rootClientId; let _destinationIndex; - if ( insertionIndex ) { + if ( insertionIndex !== undefined ) { // Insert into a specific index. _destinationIndex = insertionIndex; } else if ( clientId ) { diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index 62e13c9b6b0af..a034f1af2d7e7 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.js @@ -43,11 +43,6 @@ export function getNearestBlockIndex( elements, position, orientation ) { let candidateDistance; elements.forEach( ( element, index ) => { - // Ensure the element is a block. It should have the `wp-block` class. - if ( ! element.classList.contains( 'wp-block' ) ) { - return; - } - const rect = element.getBoundingClientRect(); const [ distance, edge ] = getDistanceToNearestEdge( position, @@ -105,7 +100,10 @@ export default function useBlockDropZone( { const onBlockDrop = useOnBlockDrop( targetRootClientId, targetBlockIndex ); const throttled = useThrottle( useCallback( ( event, currentTarget ) => { - const blockElements = Array.from( currentTarget.children ); + const blockElements = Array.from( currentTarget.children ).filter( + // Ensure the element is a block. It should have the `wp-block` class. + ( element ) => element.classList.contains( 'wp-block' ) + ); const targetIndex = getNearestBlockIndex( blockElements, { x: event.clientX, y: event.clientY }, diff --git a/packages/block-editor/src/components/use-block-drop-zone/test/index.js b/packages/block-editor/src/components/use-block-drop-zone/test/index.js index 42a9476be0cd4..d60ee4ba574b5 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/test/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/test/index.js @@ -83,22 +83,6 @@ describe( 'getNearestBlockIndex', () => { expect( result ).toBeUndefined(); } ); - it( 'returns `undefined` if the elements do not have the `wp-block` class', () => { - const nonBlockElements = [ - { classList: createMockClassList( 'some-other-class' ) }, - ]; - const position = { x: 0, y: 0 }; - const orientation = 'horizontal'; - - const result = getNearestBlockIndex( - nonBlockElements, - position, - orientation - ); - - expect( result ).toBeUndefined(); - } ); - describe( 'Vertical block lists', () => { const orientation = 'vertical'; diff --git a/packages/block-library/src/categories/edit.js b/packages/block-library/src/categories/edit.js index 12adf03e1ffce..1b49ceae36acf 100644 --- a/packages/block-library/src/categories/edit.js +++ b/packages/block-library/src/categories/edit.js @@ -28,7 +28,7 @@ export default function CategoriesEdit( { const { categories, isRequesting } = useSelect( ( select ) => { const { getEntityRecords } = select( coreStore ); const { isResolving } = select( 'core/data' ); - const query = { per_page: -1, hide_empty: true }; + const query = { per_page: -1, hide_empty: true, context: 'view' }; return { categories: getEntityRecords( 'taxonomy', 'category', query ), isRequesting: isResolving( 'core', 'getEntityRecords', [ @@ -151,7 +151,7 @@ export default function CategoriesEdit( { ) } - { ! isRequesting && categories.length === 0 && ( + { ! isRequesting && categories?.length === 0 && (

{ __( 'Your site does not have any posts, so there is nothing to display here at the moment.' @@ -159,7 +159,7 @@ export default function CategoriesEdit( {

) } { ! isRequesting && - categories.length > 0 && + categories?.length > 0 && ( displayAsDropdown ? renderCategoryDropdown() : renderCategoryList() ) } diff --git a/packages/block-library/src/post-terms/edit.js b/packages/block-library/src/post-terms/edit.js index 3532fb747f759..aa75e2f55177e 100644 --- a/packages/block-library/src/post-terms/edit.js +++ b/packages/block-library/src/post-terms/edit.js @@ -2,7 +2,6 @@ * External dependencies */ import classnames from 'classnames'; -import { find } from 'lodash'; /** * WordPress dependencies @@ -21,7 +20,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import useTermLinks from './use-term-links'; +import usePostTerms from './use-post-terms'; export default function PostTermsEdit( { attributes, @@ -34,28 +33,18 @@ export default function PostTermsEdit( { const selectedTerm = useSelect( ( select ) => { if ( ! term ) return {}; - const taxonomies = select( coreStore ).getTaxonomies( { - per_page: -1, - } ); - return ( - find( - taxonomies, - ( taxonomy ) => - taxonomy.slug === term && taxonomy.visibility.show_ui - ) || {} - ); + const { getTaxonomy } = select( coreStore ); + const taxonomy = getTaxonomy( term ); + return taxonomy?.visibility?.show_ui ? taxonomy : {}; }, [ term ] ); - - const { termLinks, isLoadingTermLinks } = useTermLinks( { + const { postTerms, hasPostTerms, isLoading } = usePostTerms( { postId, postType, term: selectedTerm, } ); - const hasPost = postId && postType; - const hasTermLinks = termLinks && termLinks.length > 0; const blockProps = useBlockProps( { className: classnames( { [ `has-text-align-${ textAlign }` ]: textAlign, @@ -89,19 +78,22 @@ export default function PostTermsEdit( { />
- { isLoadingTermLinks && } - - { hasTermLinks && - ! isLoadingTermLinks && - termLinks.reduce( ( prev, curr ) => [ - prev, - ' | ', - curr, - ] ) } - - { ! isLoadingTermLinks && - ! hasTermLinks && - // eslint-disable-next-line camelcase + { isLoading && } + { ! isLoading && + hasPostTerms && + postTerms + .map( ( postTerm ) => ( + event.preventDefault() } + > + { postTerm.name } + + ) ) + .reduce( ( prev, curr ) => [ prev, ' | ', curr ] ) } + { ! isLoading && + ! hasPostTerms && ( selectedTerm?.labels?.no_terms || __( 'Term items not found.' ) ) }
diff --git a/packages/block-library/src/post-terms/use-post-terms.js b/packages/block-library/src/post-terms/use-post-terms.js new file mode 100644 index 0000000000000..facfffa21117f --- /dev/null +++ b/packages/block-library/src/post-terms/use-post-terms.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { useEntityProp, store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +export default function usePostTerms( { postId, postType, term } ) { + const { rest_base: restBase, slug } = term; + const [ termIds ] = useEntityProp( 'postType', postType, restBase, postId ); + return useSelect( + ( select ) => { + if ( ! termIds ) { + // Waiting for post terms to be fetched. + return { isLoading: true }; + } + if ( ! termIds.length ) { + return { isLoading: false }; + } + const { getEntityRecords, isResolving } = select( coreStore ); + const taxonomyArgs = [ + 'taxonomy', + slug, + { + include: termIds, + context: 'view', + }, + ]; + const terms = getEntityRecords( ...taxonomyArgs ); + const _isLoading = isResolving( 'getEntityRecords', taxonomyArgs ); + return { + postTerms: terms, + isLoading: _isLoading, + hasPostTerms: !! terms?.length, + }; + }, + [ termIds ] + ); +} diff --git a/packages/block-library/src/post-terms/use-term-links.js b/packages/block-library/src/post-terms/use-term-links.js deleted file mode 100644 index 387e8cf92d3c4..0000000000000 --- a/packages/block-library/src/post-terms/use-term-links.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * External dependencies - */ -import { map } from 'lodash'; - -/** - * WordPress dependencies - */ -import { useEntityProp, store as coreStore } from '@wordpress/core-data'; -import { useSelect } from '@wordpress/data'; - -export default function useTermLinks( { postId, postType, term } ) { - const { rest_base: restBase, slug } = term; - - const [ termItems ] = useEntityProp( - 'postType', - postType, - restBase, - postId - ); - - const { termLinks, isLoadingTermLinks } = useSelect( - ( select ) => { - const { getEntityRecord } = select( coreStore ); - - let loaded = true; - - const links = map( termItems, ( itemId ) => { - const item = getEntityRecord( 'taxonomy', slug, itemId ); - - if ( ! item ) { - return ( loaded = false ); - } - - return ( - event.preventDefault() } - > - { item.name } - - ); - } ); - - return { - termLinks: links, - isLoadingTermLinks: ! loaded, - }; - }, - [ termItems ] - ); - - return { termLinks, isLoadingTermLinks }; -} diff --git a/packages/block-library/src/site-logo/edit.js b/packages/block-library/src/site-logo/edit.js index 05e225513392d..aecda3ec75d2b 100644 --- a/packages/block-library/src/site-logo/edit.js +++ b/packages/block-library/src/site-logo/edit.js @@ -17,6 +17,7 @@ import { ResizableBox, Spinner, ToggleControl, + Icon, } from '@wordpress/components'; import { useViewportMatch } from '@wordpress/compose'; import { @@ -73,7 +74,7 @@ const SiteLogo = ( { title: siteEntities.title, ...pick( getSettings(), [ 'imageSizes', 'maxWidth' ] ), }; - } ); + }, [] ); function onResizeStart() { toggleSelection( false ); @@ -255,27 +256,38 @@ export default function LogoEdit( { const [ logoUrl, setLogoUrl ] = useState(); const [ error, setError ] = useState(); const ref = useRef(); - const { mediaItemData, siteLogo, url } = useSelect( ( select ) => { - const siteSettings = select( coreStore ).getEditedEntityRecord( - 'root', - 'site' - ); - const mediaItem = siteSettings.site_logo - ? select( coreStore ).getEntityRecord( + + const { siteLogoId, canUserEdit, url, mediaItemData } = useSelect( + ( select ) => { + const { canUser, getEntityRecord, getEditedEntityRecord } = select( + coreStore + ); + const siteSettings = getEditedEntityRecord( 'root', 'site' ); + const siteData = getEntityRecord( 'root', '__unstableBase' ); + const _siteLogo = siteSettings?.site_logo; + const _readOnlyLogo = siteData?.site_logo; + const _canUserEdit = canUser( 'update', 'settings' ); + const _siteLogoId = _siteLogo || _readOnlyLogo; + const mediaItem = + _siteLogoId && + select( coreStore ).getEntityRecord( 'root', 'media', - siteSettings.site_logo - ) - : null; - return { - mediaItemData: mediaItem && { - url: mediaItem.source_url, - alt: mediaItem.alt_text, - }, - siteLogo: siteSettings.site_logo, - url: siteSettings.url, - }; - }, [] ); + _siteLogoId, + { context: 'view' } + ); + return { + siteLogoId: _siteLogoId, + canUserEdit: _canUserEdit, + url: siteData?.url, + mediaItemData: mediaItem && { + url: mediaItem.source_url, + alt: mediaItem.alt_text, + }, + }; + }, + [] + ); const { editEntityRecord } = useDispatch( coreStore ); const setLogo = ( newValue ) => @@ -290,7 +302,6 @@ export default function LogoEdit( { setLogoUrl( mediaItemData.url ); } } - const onSelectLogo = ( media ) => { if ( ! media ) { return; @@ -311,7 +322,7 @@ export default function LogoEdit( { setError( message[ 2 ] ? message[ 2 ] : null ); }; - const controls = logoUrl && ( + const controls = canUserEdit && logoUrl && ( ; } - if ( !! logoUrl ) { logoImage = ( ); } - - const mediaPlaceholder = ( - } - labels={ { - title: label, - instructions: __( - 'Upload an image, or pick one from your media library, to be your site logo' - ), - } } - onSelect={ onSelectLogo } - accept={ ACCEPT_MEDIA_STRING } - allowedTypes={ ALLOWED_MEDIA_TYPES } - mediaPreview={ logoImage } - notices={ - error && ( - - { error } - - ) - } - onError={ onUploadError } - /> - ); - const classes = classnames( className, { 'is-default-size': ! width, } ); - const blockProps = useBlockProps( { ref, className: classes, } ); - return (
{ controls } - { logoUrl && logoImage } - { ! logoUrl && mediaPlaceholder } + { !! logoUrl && logoImage } + { ! logoUrl && ! canUserEdit && ( +
+ +

{ __( 'Site Logo' ) }

+
+ ) } + { ! logoUrl && canUserEdit && ( + } + labels={ { + title: label, + instructions: __( + 'Upload an image, or pick one from your media library, to be your site logo' + ), + } } + onSelect={ onSelectLogo } + accept={ ACCEPT_MEDIA_STRING } + allowedTypes={ ALLOWED_MEDIA_TYPES } + mediaPreview={ logoImage } + notices={ + error && ( + + { error } + + ) + } + onError={ onUploadError } + /> + ) }
); } diff --git a/packages/block-library/src/site-logo/editor.scss b/packages/block-library/src/site-logo/editor.scss index 694cc98031283..de72e08f7cbff 100644 --- a/packages/block-library/src/site-logo/editor.scss +++ b/packages/block-library/src/site-logo/editor.scss @@ -80,3 +80,23 @@ } } } +.editor-styles-wrapper { + .site-logo_placeholder { + display: flex; + flex-direction: row; + align-items: flex-start; + border-radius: $radius-block-ui; + background-color: $white; + box-shadow: inset 0 0 0 $border-width $gray-900; + padding: $grid-unit-15; + svg { + margin-right: $grid-unit-15; + } + p { + font-family: $default-font; + font-size: $default-font-size; + margin: 0; + line-height: initial; + } + } +} diff --git a/packages/core-data/src/queried-data/get-query-parts.js b/packages/core-data/src/queried-data/get-query-parts.js index 9b54bd4c7cfa2..1cd9631495505 100644 --- a/packages/core-data/src/queried-data/get-query-parts.js +++ b/packages/core-data/src/queried-data/get-query-parts.js @@ -41,6 +41,7 @@ export function getQueryParts( query ) { perPage: 10, fields: null, include: null, + context: 'default', }; // Ensure stable key by sorting keys. Also more efficient for iterating. @@ -65,6 +66,10 @@ export function getQueryParts( query ) { ); break; + case 'context': + parts.context = value; + break; + default: // While in theory, we could exclude "_fields" from the stableKey // because two request with different fields have the same results diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js index 9198b9f16fa12..12ad7d69fac5f 100644 --- a/packages/core-data/src/queried-data/reducer.js +++ b/packages/core-data/src/queried-data/reducer.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { map, flowRight, omit, forEach, filter } from 'lodash'; +import { map, flowRight, omit, filter, mapValues } from 'lodash'; /** * WordPress dependencies @@ -20,6 +20,16 @@ import { import { DEFAULT_ENTITY_KEY } from '../entities'; import getQueryParts from './get-query-parts'; +function getContextFromAction( action ) { + const { query } = action; + if ( ! query ) { + return 'default'; + } + + const queryParts = getQueryParts( query ); + return queryParts.context; +} + /** * Returns a merged array of item IDs, given details of the received paginated * items. The array is sparse-like with `undefined` entries where holes exist. @@ -71,24 +81,30 @@ export function getMergedItemIds( itemIds, nextItemIds, page, perPage ) { * * @return {Object} Next state. */ -function items( state = {}, action ) { +export function items( state = {}, action ) { switch ( action.type ) { - case 'RECEIVE_ITEMS': + case 'RECEIVE_ITEMS': { + const context = getContextFromAction( action ); const key = action.key || DEFAULT_ENTITY_KEY; return { ...state, - ...action.items.reduce( ( accumulator, value ) => { - const itemId = value[ key ]; - accumulator[ itemId ] = conservativeMapItem( - state[ itemId ], - value - ); - return accumulator; - }, {} ), + [ context ]: { + ...state[ context ], + ...action.items.reduce( ( accumulator, value ) => { + const itemId = value[ key ]; + accumulator[ itemId ] = conservativeMapItem( + state?.[ context ]?.[ itemId ], + value + ); + return accumulator; + }, {} ), + }, }; + } case 'REMOVE_ITEMS': - const newState = omit( state, action.itemIds ); - return newState; + return mapValues( state, ( contextState ) => + omit( contextState, action.itemIds ) + ); } return state; } @@ -106,32 +122,45 @@ function items( state = {}, action ) { * @return {Object} Next state. */ export function itemIsComplete( state = {}, action ) { - const { type, query, key = DEFAULT_ENTITY_KEY } = action; - if ( type !== 'RECEIVE_ITEMS' ) { - return state; + switch ( action.type ) { + case 'RECEIVE_ITEMS': { + const context = getContextFromAction( action ); + const { query, key = DEFAULT_ENTITY_KEY } = action; + + // An item is considered complete if it is received without an associated + // fields query. Ideally, this would be implemented in such a way where the + // complete aggregate of all fields would satisfy completeness. Since the + // fields are not consistent across all entity types, this would require + // introspection on the REST schema for each entity to know which fields + // compose a complete item for that entity. + const queryParts = query ? getQueryParts( query ) : {}; + const isCompleteQuery = + ! query || ! Array.isArray( queryParts.fields ); + + return { + ...state, + [ context ]: { + ...state[ context ], + ...action.items.reduce( ( result, item ) => { + const itemId = item[ key ]; + + // Defer to completeness if already assigned. Technically the + // data may be outdated if receiving items for a field subset. + result[ itemId ] = + state?.[ context ]?.[ itemId ] || isCompleteQuery; + + return result; + }, {} ), + }, + }; + } + case 'REMOVE_ITEMS': + return mapValues( state, ( contextState ) => + omit( contextState, action.itemIds ) + ); } - // An item is considered complete if it is received without an associated - // fields query. Ideally, this would be implemented in such a way where the - // complete aggregate of all fields would satisfy completeness. Since the - // fields are not consistent across all entity types, this would require - // introspection on the REST schema for each entity to know which fields - // compose a complete item for that entity. - const isCompleteQuery = - ! query || ! Array.isArray( getQueryParts( query ).fields ); - - return { - ...state, - ...action.items.reduce( ( result, item ) => { - const itemId = item[ key ]; - - // Defer to completeness if already assigned. Technically the - // data may be outdated if receiving items for a field subset. - result[ itemId ] = state[ itemId ] || isCompleteQuery; - - return result; - }, {} ), - }; + return state; } /** @@ -163,6 +192,8 @@ const receiveQueries = flowRight( [ return action; } ), + onSubKey( 'context' ), + // Queries shape is shared, but keyed by query `stableKey` part. Original // reducer tracks only a single query object. onSubKey( 'stableKey' ), @@ -194,17 +225,18 @@ const queries = ( state = {}, action ) => { case 'RECEIVE_ITEMS': return receiveQueries( state, action ); case 'REMOVE_ITEMS': - const newState = { ...state }; const removedItems = action.itemIds.reduce( ( result, itemId ) => { result[ itemId ] = true; return result; }, {} ); - forEach( newState, ( queryItems, key ) => { - newState[ key ] = filter( queryItems, ( queryId ) => { - return ! removedItems[ queryId ]; + + return mapValues( state, ( contextQueries ) => { + return mapValues( contextQueries, ( queryItems ) => { + return filter( queryItems, ( queryId ) => { + return ! removedItems[ queryId ]; + } ); } ); } ); - return newState; default: return state; } diff --git a/packages/core-data/src/queried-data/selectors.js b/packages/core-data/src/queried-data/selectors.js index 63468bc65491b..2e9ee2538160a 100644 --- a/packages/core-data/src/queried-data/selectors.js +++ b/packages/core-data/src/queried-data/selectors.js @@ -28,10 +28,16 @@ const queriedItemsCacheByState = new WeakMap(); * @return {?Array} Query items. */ function getQueriedItemsUncached( state, query ) { - const { stableKey, page, perPage, include, fields } = getQueryParts( - query - ); + const { + stableKey, + page, + perPage, + include, + fields, + context, + } = getQueryParts( query ); let itemIds; + if ( Array.isArray( include ) && ! stableKey ) { // If the parsed query yields a set of IDs, but otherwise no filtering, // it's safe to consider targeted item IDs as the include set. This @@ -40,8 +46,8 @@ function getQueriedItemsUncached( state, query ) { itemIds = include; // TODO: Avoid storing the empty stable string in reducer, since it // can be computed dynamically here always. - } else if ( state.queries[ stableKey ] ) { - itemIds = state.queries[ stableKey ]; + } else if ( state.queries?.[ context ]?.[ stableKey ] ) { + itemIds = state.queries[ context ][ stableKey ]; } if ( ! itemIds ) { @@ -61,11 +67,11 @@ function getQueriedItemsUncached( state, query ) { continue; } - if ( ! state.items.hasOwnProperty( itemId ) ) { + if ( ! state.items[ context ]?.hasOwnProperty( itemId ) ) { return null; } - const item = state.items[ itemId ]; + const item = state.items[ context ][ itemId ]; let filteredItem; if ( Array.isArray( fields ) ) { @@ -79,7 +85,7 @@ function getQueriedItemsUncached( state, query ) { } else { // If expecting a complete item, validate that completeness, or // otherwise abort. - if ( ! state.itemIsComplete[ itemId ] ) { + if ( ! state.itemIsComplete[ context ]?.[ itemId ] ) { return null; } diff --git a/packages/core-data/src/queried-data/test/get-query-parts.js b/packages/core-data/src/queried-data/test/get-query-parts.js index 156e18c995cc4..0de6036d696b3 100644 --- a/packages/core-data/src/queried-data/test/get-query-parts.js +++ b/packages/core-data/src/queried-data/test/get-query-parts.js @@ -8,6 +8,7 @@ describe( 'getQueryParts', () => { const parts = getQueryParts( { page: 2, per_page: 2 } ); expect( parts ).toEqual( { + context: 'default', page: 2, perPage: 2, stableKey: '', @@ -20,6 +21,7 @@ describe( 'getQueryParts', () => { const parts = getQueryParts( { include: [ 1 ] } ); expect( parts ).toEqual( { + context: 'default', page: 1, perPage: 10, stableKey: '', @@ -34,6 +36,7 @@ describe( 'getQueryParts', () => { expect( first ).toEqual( second ); expect( first ).toEqual( { + context: 'default', page: 1, perPage: 10, stableKey: '%3F=%26&b=2', @@ -46,6 +49,7 @@ describe( 'getQueryParts', () => { const parts = getQueryParts( { a: [ 1, 2 ] } ); expect( parts ).toEqual( { + context: 'default', page: 1, perPage: 10, stableKey: 'a%5B0%5D=1&a%5B1%5D=2', @@ -60,6 +64,7 @@ describe( 'getQueryParts', () => { expect( first ).toEqual( second ); expect( first ).toEqual( { + context: 'default', page: 1, perPage: 10, stableKey: 'b=2', @@ -72,6 +77,7 @@ describe( 'getQueryParts', () => { const parts = getQueryParts( { b: 2, page: 1, per_page: -1 } ); expect( parts ).toEqual( { + context: 'default', page: 1, perPage: -1, stableKey: 'b=2', @@ -84,6 +90,7 @@ describe( 'getQueryParts', () => { const parts = getQueryParts( { _fields: [ 'id', 'title' ] } ); expect( parts ).toEqual( { + context: 'default', page: 1, perPage: 10, stableKey: '_fields=id%2Ctitle', @@ -91,4 +98,17 @@ describe( 'getQueryParts', () => { include: null, } ); } ); + + it( 'returns the context as a dedicated query part', () => { + const parts = getQueryParts( { context: 'view' } ); + + expect( parts ).toEqual( { + page: 1, + perPage: 10, + stableKey: '', + include: null, + fields: null, + context: 'view', + } ); + } ); } ); diff --git a/packages/core-data/src/queried-data/test/reducer.js b/packages/core-data/src/queried-data/test/reducer.js index 71f5a205e3995..63a4f4bd259ce 100644 --- a/packages/core-data/src/queried-data/test/reducer.js +++ b/packages/core-data/src/queried-data/test/reducer.js @@ -75,7 +75,7 @@ describe( 'itemIsComplete', () => { } ); expect( state ).toEqual( { - 1: true, + default: { 1: true }, } ); } ); @@ -85,12 +85,13 @@ describe( 'itemIsComplete', () => { type: 'RECEIVE_ITEMS', query: { per_page: 5, + context: 'edit', }, items: [ { id: 1, content: 'chicken', author: 'bob' } ], } ); expect( state ).toEqual( { - 1: true, + edit: { 1: true }, } ); } ); @@ -105,13 +106,13 @@ describe( 'itemIsComplete', () => { } ); expect( state ).toEqual( { - 1: false, + default: { 1: false }, } ); } ); it( 'should defer to existing completeness when receiving filtered query', () => { const original = deepFreeze( { - 1: true, + default: { 1: true }, } ); const state = itemIsComplete( original, { type: 'RECEIVE_ITEMS', @@ -122,7 +123,7 @@ describe( 'itemIsComplete', () => { } ); expect( state ).toEqual( { - 1: true, + default: { 1: true }, } ); } ); } ); @@ -133,16 +134,16 @@ describe( 'reducer', () => { expect( state ).toEqual( { items: {}, - itemIsComplete: {}, queries: {}, + itemIsComplete: {}, } ); } ); it( 'receives a page of queried data', () => { const original = deepFreeze( { - items: {}, + items: { default: {} }, queries: {}, - itemIsComplete: {}, + itemIsComplete: { default: {} }, } ); const state = reducer( original, { type: 'RECEIVE_ITEMS', @@ -152,22 +153,22 @@ describe( 'reducer', () => { expect( state ).toEqual( { items: { - 1: { id: 1, name: 'abc' }, + default: { 1: { id: 1, name: 'abc' } }, }, itemIsComplete: { - 1: true, + default: { 1: true }, }, queries: { - 's=a': [ 1 ], + default: { 's=a': [ 1 ] }, }, } ); } ); it( 'receives an unqueried page of items', () => { const original = deepFreeze( { - items: {}, + items: { default: {} }, queries: {}, - itemIsComplete: {}, + itemIsComplete: { default: {} }, } ); const state = reducer( original, { type: 'RECEIVE_ITEMS', @@ -176,10 +177,10 @@ describe( 'reducer', () => { expect( state ).toEqual( { items: { - 1: { id: 1, name: 'abc' }, + default: { 1: { id: 1, name: 'abc' } }, }, itemIsComplete: { - 1: true, + default: { 1: true }, }, queries: {}, } ); @@ -190,14 +191,18 @@ describe( 'reducer', () => { const name = 'menu'; const original = deepFreeze( { items: { - 1: { id: 1, name: 'abc' }, - 2: { id: 2, name: 'def' }, - 3: { id: 3, name: 'ghi' }, - 4: { id: 4, name: 'klm' }, + default: { + 1: { id: 1, name: 'abc' }, + 2: { id: 2, name: 'def' }, + 3: { id: 3, name: 'ghi' }, + 4: { id: 4, name: 'klm' }, + }, }, queries: { - '': [ 1, 2, 3, 4 ], - 's=a': [ 1, 3 ], + default: { + '': [ 1, 2, 3, 4 ], + 's=a': [ 1, 3 ], + }, }, } ); const state = reducer( original, removeItems( kind, name, 3 ) ); @@ -205,13 +210,17 @@ describe( 'reducer', () => { expect( state ).toEqual( { itemIsComplete: {}, items: { - 1: { id: 1, name: 'abc' }, - 2: { id: 2, name: 'def' }, - 4: { id: 4, name: 'klm' }, + default: { + 1: { id: 1, name: 'abc' }, + 2: { id: 2, name: 'def' }, + 4: { id: 4, name: 'klm' }, + }, }, queries: { - '': [ 1, 2, 4 ], - 's=a': [ 1 ], + default: { + '': [ 1, 2, 4 ], + 's=a': [ 1 ], + }, }, } ); } ); diff --git a/packages/core-data/src/queried-data/test/selectors.js b/packages/core-data/src/queried-data/test/selectors.js index 76dec520a7358..39b46a97a9395 100644 --- a/packages/core-data/src/queried-data/test/selectors.js +++ b/packages/core-data/src/queried-data/test/selectors.js @@ -19,15 +19,21 @@ describe( 'getQueriedItems', () => { it( 'should return an array of items', () => { const state = { items: { - 1: { id: 1 }, - 2: { id: 2 }, + default: { + 1: { id: 1 }, + 2: { id: 2 }, + }, }, itemIsComplete: { - 1: true, - 2: true, + default: { + 1: true, + 2: true, + }, }, queries: { - '': [ 1, 2 ], + default: { + '': [ 1, 2 ], + }, }, }; @@ -39,12 +45,16 @@ describe( 'getQueriedItems', () => { it( 'should cache on query by state', () => { const state = { items: { - 1: { id: 1 }, - 2: { id: 2 }, + default: { + 1: { id: 1 }, + 2: { id: 2 }, + }, }, itemIsComplete: { - 1: true, - 2: true, + default: { + 1: true, + 2: true, + }, }, queries: [ 1, 2 ], }; @@ -58,15 +68,21 @@ describe( 'getQueriedItems', () => { it( 'should return items queried by include', () => { const state = { items: { - 1: { id: 1 }, - 2: { id: 2 }, + default: { + 1: { id: 1 }, + 2: { id: 2 }, + }, }, itemIsComplete: { - 1: true, - 2: true, + default: { + 1: true, + 2: true, + }, }, queries: { - '': [ 1, 2 ], + default: { + '': [ 1, 2 ], + }, }, }; @@ -78,23 +94,29 @@ describe( 'getQueriedItems', () => { it( 'should dynamically construct fields-filtered item from available data', () => { const state = { items: { - 1: { - id: 1, - content: 'chicken', - author: 'bob', - }, - 2: { - id: 2, - content: 'ribs', - author: 'sally', + default: { + 1: { + id: 1, + content: 'chicken', + author: 'bob', + }, + 2: { + id: 2, + content: 'ribs', + author: 'sally', + }, }, }, itemIsComplete: { - 1: true, - 2: true, + default: { + 1: true, + 2: true, + }, }, queries: { - '_fields=content': [ 1, 2 ], + default: { + '_fields=content': [ 1, 2 ], + }, }, }; @@ -109,31 +131,37 @@ describe( 'getQueriedItems', () => { it( 'should dynamically construct fields-filtered item from available data with nested fields', () => { const state = { items: { - 1: { - id: 1, - content: 'chicken', - author: 'bob', - meta: { - template: 'single', - _private: 'unused', + default: { + 1: { + id: 1, + content: 'chicken', + author: 'bob', + meta: { + template: 'single', + _private: 'unused', + }, }, - }, - 2: { - id: 2, - content: 'ribs', - author: 'sally', - meta: { - template: 'single', - _private: 'unused', + 2: { + id: 2, + content: 'ribs', + author: 'sally', + meta: { + template: 'single', + _private: 'unused', + }, }, }, }, itemIsComplete: { - 1: true, - 2: true, + default: { + 1: true, + 2: true, + }, }, queries: { - '_fields=content%2Cmeta.template': [ 1, 2 ], + default: { + '_fields=content%2Cmeta.template': [ 1, 2 ], + }, }, }; @@ -150,21 +178,27 @@ describe( 'getQueriedItems', () => { it( 'should return null if attempting to filter by yet-unknown fields', () => { const state = { items: { - 1: { - id: 1, - author: 'bob', - }, - 2: { - id: 2, - author: 'sally', + default: { + 1: { + id: 1, + author: 'bob', + }, + 2: { + id: 2, + author: 'sally', + }, }, }, itemIsComplete: { - 1: false, - 2: false, + default: { + 1: false, + 2: false, + }, }, queries: { - '': [ 1, 2 ], + default: { + '': [ 1, 2 ], + }, }, }; @@ -176,21 +210,27 @@ describe( 'getQueriedItems', () => { it( 'should return null if querying non-filtered data for incomplete item', () => { const state = { items: { - 1: { - id: 1, - author: 'bob', - }, - 2: { - id: 2, - author: 'sally', + default: { + 1: { + id: 1, + author: 'bob', + }, + 2: { + id: 2, + author: 'sally', + }, }, }, itemIsComplete: { - 1: false, - 2: false, + default: { + 1: false, + 2: false, + }, }, queries: { - '': [ 1, 2 ], + default: { + '': [ 1, 2 ], + }, }, }; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 51c43a529d9ab..c60a9583c7ad9 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -197,6 +197,11 @@ function entity( entityConfig ) { edits: ( state = {}, action ) => { switch ( action.type ) { case 'RECEIVE_ITEMS': + const context = action?.query?.context ?? 'default'; + if ( context !== 'default' ) { + return state; + } + const nextState = { ...state }; for ( const record of action.items ) { diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 877f94e0a5ebc..8ed8d1474e864 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -193,8 +193,8 @@ export function* getEntityRecords( kind, name, query = {} ) { } const path = addQueryArgs( entity.baseURL, { + ...entity.baseURLParams, ...query, - context: 'edit', } ); let records = Object.values( yield apiFetch( { path } ) ); @@ -217,7 +217,7 @@ export function* getEntityRecords( kind, name, query = {} ) { // When requesting all fields, the list of results can be used to // resolve the `getEntityRecord` selector in addition to `getEntityRecords`. // See https://github.com/WordPress/gutenberg/pull/26575 - if ( ! query?._fields ) { + if ( ! query?._fields && ! query.context ) { const key = entity.key || DEFAULT_ENTITY_KEY; const resolutionsArgs = records .filter( ( record ) => record[ key ] ) diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 4dd61d8526539..3208ea7988216 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -151,17 +151,18 @@ export function getEntityRecord( state, kind, name, key, query ) { if ( ! queriedState ) { return undefined; } + const context = query?.context ?? 'default'; if ( query === undefined ) { // If expecting a complete item, validate that completeness. - if ( ! queriedState.itemIsComplete[ key ] ) { + if ( ! queriedState.itemIsComplete[ context ]?.[ key ] ) { return undefined; } - return queriedState.items[ key ]; + return queriedState.items[ context ][ key ]; } - const item = queriedState.items[ key ]; + const item = queriedState.items[ context ]?.[ key ]; if ( item && query._fields ) { const filteredItem = {}; const fields = getNormalizedCommaSeparable( query._fields ); diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index c8cd519e16bcd..e3665d619d668 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -67,12 +67,16 @@ describe( 'entities', () => { expect( state.data.root.postType.queriedData ).toEqual( { items: { - b: { slug: 'b', title: 'beach' }, - s: { slug: 's', title: 'sun' }, + default: { + b: { slug: 'b', title: 'beach' }, + s: { slug: 's', title: 'sun' }, + }, }, itemIsComplete: { - b: true, - s: true, + default: { + b: true, + s: true, + }, }, queries: {}, } ); @@ -85,10 +89,14 @@ describe( 'entities', () => { postType: { queriedData: { items: { - w: { slug: 'w', title: 'water' }, + default: { + w: { slug: 'w', title: 'water' }, + }, }, itemIsComplete: { - w: true, + default: { + w: true, + }, }, queries: {}, }, @@ -105,12 +113,16 @@ describe( 'entities', () => { expect( state.data.root.postType.queriedData ).toEqual( { items: { - w: { slug: 'w', title: 'water' }, - b: { slug: 'b', title: 'beach' }, + default: { + w: { slug: 'w', title: 'water' }, + b: { slug: 'b', title: 'beach' }, + }, }, itemIsComplete: { - w: true, - b: true, + default: { + w: true, + b: true, + }, }, queries: {}, } ); diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index e22958c387f83..0cb095dc5fb87 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -120,8 +120,18 @@ describe( 'getEntityRecords', () => { page: { slug: 'page', id: 2 }, }; const ENTITIES = [ - { name: 'postType', kind: 'root', baseURL: '/wp/v2/types' }, - { name: 'postType', kind: 'root', baseURL: '/wp/v2/types' }, + { + name: 'postType', + kind: 'root', + baseURL: '/wp/v2/types', + baseURLParams: { context: 'edit' }, + }, + { + name: 'postType', + kind: 'root', + baseURL: '/wp/v2/types', + baseURLParams: { context: 'edit' }, + }, ]; it( 'yields with requested post type', async () => { diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index cce4293885f32..68878692351a0 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -74,10 +74,14 @@ describe.each( [ postType: { queriedData: { items: { - post: { slug: 'post' }, + default: { + post: { slug: 'post' }, + }, }, itemIsComplete: { - post: true, + default: { + post: true, + }, }, queries: {}, }, @@ -105,14 +109,18 @@ describe.each( [ post: { queriedData: { items: { - 1: { - id: 1, - content: 'chicken', - author: 'bob', + default: { + 1: { + id: 1, + content: 'chicken', + author: 'bob', + }, }, }, itemIsComplete: { - 1: true, + default: { + 1: true, + }, }, queries: {}, }, @@ -168,15 +176,21 @@ describe( 'hasEntityRecords', () => { postType: { queriedData: { items: { - post: { slug: 'post' }, - page: { slug: 'page' }, + default: { + post: { slug: 'post' }, + page: { slug: 'page' }, + }, }, itemIsComplete: { - post: true, - page: true, + default: { + post: true, + page: true, + }, }, queries: { - '': [ 'post', 'page' ], + default: { + '': [ 'post', 'page' ], + }, }, }, }, @@ -227,15 +241,21 @@ describe( 'getEntityRecords', () => { postType: { queriedData: { items: { - post: { slug: 'post' }, - page: { slug: 'page' }, + default: { + post: { slug: 'post' }, + page: { slug: 'page' }, + }, }, itemIsComplete: { - post: true, - page: true, + default: { + post: true, + page: true, + }, }, queries: { - '': [ 'post', 'page' ], + default: { + '': [ 'post', 'page' ], + }, }, }, }, @@ -257,17 +277,23 @@ describe( 'getEntityRecords', () => { post: { queriedData: { items: { - 1: { - id: 1, - content: 'chicken', - author: 'bob', + default: { + 1: { + id: 1, + content: 'chicken', + author: 'bob', + }, }, }, itemIsComplete: { - 1: true, + default: { + 1: true, + }, }, queries: { - '_fields=id%2Ccontent': [ 1 ], + default: { + '_fields=id%2Ccontent': [ 1 ], + }, }, }, }, @@ -335,16 +361,20 @@ describe( '__experimentalGetDirtyEntityRecords', () => { someName: { queriedData: { items: { - someKey: { - someProperty: 'somePersistedValue', - someRawProperty: { - raw: 'somePersistedRawValue', + default: { + someKey: { + someProperty: 'somePersistedValue', + someRawProperty: { + raw: 'somePersistedRawValue', + }, + id: 'someKey', }, - id: 'someKey', }, }, itemIsComplete: { - someKey: true, + default: { + someKey: true, + }, }, }, edits: { @@ -475,10 +505,17 @@ describe( 'canUserEditEntityRecord', () => { postType: { queriedData: { items: { - post: { slug: 'post', __unstable: 'posts' }, + default: { + post: { + slug: 'post', + __unstable: 'posts', + }, + }, }, itemIsComplete: { - post: true, + default: { + post: true, + }, }, queries: {}, }, diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index a7581187b7116..259d376c190e1 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -23,7 +23,6 @@ import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; /** * Internal dependencies */ -import TemplateTitle from '../template-title'; import { store as editPostStore } from '../../../store'; function HeaderToolbar() { @@ -156,8 +155,6 @@ function HeaderToolbar() { ) } - - ); } diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index c24e41ec59b41..40e5fa91239e0 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -21,6 +21,7 @@ import PostPublishButtonOrToggle from './post-publish-button-or-toggle'; import { default as DevicePreview } from '../device-preview'; import MainDashboardButton from './main-dashboard-button'; import { store as editPostStore } from '../../store'; +import TemplateTitle from './template-title'; function Header( { setEntitiesSavedStatesCallback } ) { const { @@ -59,6 +60,7 @@ function Header( { setEntitiesSavedStatesCallback } ) {
+
{ ! isPublishSidebarOpened && ( diff --git a/packages/edit-post/src/components/header/template-title/index.js b/packages/edit-post/src/components/header/template-title/index.js index a2cd8b0ca07f7..21431ee1e319c 100644 --- a/packages/edit-post/src/components/header/template-title/index.js +++ b/packages/edit-post/src/components/header/template-title/index.js @@ -3,7 +3,7 @@ */ import { __, sprintf } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; -import { Dropdown, ToolbarItem, Button } from '@wordpress/components'; +import { Dropdown, Button } from '@wordpress/components'; import { chevronDown } from '@wordpress/icons'; /** @@ -47,59 +47,50 @@ function TemplateTitle() { } return ( - - { ( toolbarItemHTMLProps ) => { - return ( - ( - <> - - - +
+ + ( + + ) } + renderContent={ () => ( + <> + { template.has_theme_file ? ( + + ) : ( + ) } - renderContent={ () => ( - <> - { template.has_theme_file ? ( - - ) : ( - - ) } - - - ) } - /> - ); - } } - + + + ) } + /> +
); } diff --git a/packages/edit-widgets/src/blocks/widget-area/edit/index.js b/packages/edit-widgets/src/blocks/widget-area/edit/index.js index 9f2388265b0b2..1957361586e5a 100644 --- a/packages/edit-widgets/src/blocks/widget-area/edit/index.js +++ b/packages/edit-widgets/src/blocks/widget-area/edit/index.js @@ -15,6 +15,7 @@ import { */ import WidgetAreaInnerBlocks from './inner-blocks'; import { store as editWidgetsStore } from '../../../store'; +import useIsDraggingWithin from './use-is-dragging-within'; /** @typedef {import('@wordpress/element').RefObject} RefObject */ @@ -68,16 +69,17 @@ export default function WidgetAreaEdit( { // unmounted when the panel is collapsed. Unmounting legacy // widgets may have unintended consequences (e.g. TinyMCE // not being properly reinitialized) - -
- - - -
+ + + + ) } @@ -117,51 +119,3 @@ const useIsDragging = ( elementRef ) => { return isDragging; }; - -/** - * A React hook to determine if it's dragging within the target element. - * - * @param {RefObject} elementRef The target elementRef object. - * - * @return {boolean} Is dragging within the target element. - */ -const useIsDraggingWithin = ( elementRef ) => { - const [ isDraggingWithin, setIsDraggingWithin ] = useState( false ); - - useEffect( () => { - const { ownerDocument } = elementRef.current; - - function handleDragStart( event ) { - // Check the first time when the dragging starts. - handleDragEnter( event ); - } - - // Set to false whenever the user cancel the drag event by either releasing the mouse or press Escape. - function handleDragEnd() { - setIsDraggingWithin( false ); - } - - function handleDragEnter( event ) { - // Check if the current target is inside the item element. - if ( elementRef.current.contains( event.target ) ) { - setIsDraggingWithin( true ); - } else { - setIsDraggingWithin( false ); - } - } - - // Bind these events to the document to catch all drag events. - // Ideally, we can also use `event.relatedTarget`, but sadly that doesn't work in Safari. - ownerDocument.addEventListener( 'dragstart', handleDragStart ); - ownerDocument.addEventListener( 'dragend', handleDragEnd ); - ownerDocument.addEventListener( 'dragenter', handleDragEnter ); - - return () => { - ownerDocument.removeEventListener( 'dragstart', handleDragStart ); - ownerDocument.removeEventListener( 'dragend', handleDragEnd ); - ownerDocument.removeEventListener( 'dragenter', handleDragEnter ); - }; - }, [] ); - - return isDraggingWithin; -}; diff --git a/packages/edit-widgets/src/blocks/widget-area/edit/inner-blocks.js b/packages/edit-widgets/src/blocks/widget-area/edit/inner-blocks.js index 15ca45640a4c1..38d17428fac5f 100644 --- a/packages/edit-widgets/src/blocks/widget-area/edit/inner-blocks.js +++ b/packages/edit-widgets/src/blocks/widget-area/edit/inner-blocks.js @@ -1,21 +1,53 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ import { useEntityBlockEditor } from '@wordpress/core-data'; -import { InnerBlocks } from '@wordpress/block-editor'; +import { + InnerBlocks, + __experimentalUseInnerBlocksProps, +} from '@wordpress/block-editor'; +import { useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useIsDraggingWithin from './use-is-dragging-within'; export default function WidgetAreaInnerBlocks() { const [ blocks, onInput, onChange ] = useEntityBlockEditor( 'root', 'postType' ); + const innerBlocksRef = useRef(); + const isDraggingWithinInnerBlocks = useIsDraggingWithin( innerBlocksRef ); + const shouldHighlightDropZone = isDraggingWithinInnerBlocks; + // Using the experimental hook so that we can control the className of the element. + const innerBlocksProps = __experimentalUseInnerBlocksProps( + { ref: innerBlocksRef }, + { + value: blocks, + onInput, + onChange, + templateLock: false, + renderAppender: InnerBlocks.ButtonBlockAppender, + } + ); + return ( - +
+
+
); } diff --git a/packages/edit-widgets/src/blocks/widget-area/edit/use-is-dragging-within.js b/packages/edit-widgets/src/blocks/widget-area/edit/use-is-dragging-within.js new file mode 100644 index 0000000000000..4588c7bda34a9 --- /dev/null +++ b/packages/edit-widgets/src/blocks/widget-area/edit/use-is-dragging-within.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** @typedef {import('@wordpress/element').RefObject} RefObject */ + +/** + * A React hook to determine if it's dragging within the target element. + * + * @param {RefObject} elementRef The target elementRef object. + * + * @return {boolean} Is dragging within the target element. + */ +const useIsDraggingWithin = ( elementRef ) => { + const [ isDraggingWithin, setIsDraggingWithin ] = useState( false ); + + useEffect( () => { + const { ownerDocument } = elementRef.current; + + function handleDragStart( event ) { + // Check the first time when the dragging starts. + handleDragEnter( event ); + } + + // Set to false whenever the user cancel the drag event by either releasing the mouse or press Escape. + function handleDragEnd() { + setIsDraggingWithin( false ); + } + + function handleDragEnter( event ) { + // Check if the current target is inside the item element. + if ( elementRef.current.contains( event.target ) ) { + setIsDraggingWithin( true ); + } else { + setIsDraggingWithin( false ); + } + } + + // Bind these events to the document to catch all drag events. + // Ideally, we can also use `event.relatedTarget`, but sadly that doesn't work in Safari. + ownerDocument.addEventListener( 'dragstart', handleDragStart ); + ownerDocument.addEventListener( 'dragend', handleDragEnd ); + ownerDocument.addEventListener( 'dragenter', handleDragEnter ); + + return () => { + ownerDocument.removeEventListener( 'dragstart', handleDragStart ); + ownerDocument.removeEventListener( 'dragend', handleDragEnd ); + ownerDocument.removeEventListener( 'dragenter', handleDragEnter ); + }; + }, [] ); + + return isDraggingWithin; +}; + +export default useIsDraggingWithin; diff --git a/packages/edit-widgets/src/blocks/widget-area/editor.scss b/packages/edit-widgets/src/blocks/widget-area/editor.scss index e1c1fcbc69bc0..94c637fd08d6b 100644 --- a/packages/edit-widgets/src/blocks/widget-area/editor.scss +++ b/packages/edit-widgets/src/blocks/widget-area/editor.scss @@ -1,3 +1,5 @@ +$panel-title-height: 48px; + .wp-block[data-type="core/widget-area"] { max-width: $widget-area-width; margin-left: auto; @@ -5,37 +7,66 @@ .components-panel__body > .components-panel__body-title { font-family: $default-font; - margin-bottom: 0; + margin: 0; + height: $panel-title-height; + // Create a stacking context and make sure it's higher is than the content. + // Since the inner blocks will be stretched to cover the whole panel, + // we still want the title to be interactive. + position: relative; + z-index: 1; + background: $white; + // z-index should be enough to create a new stacking context, + // unfortunately, Safari needs this to stop it from flickering while dragging. + // The reason behind that is still unknown, probably a bug in the browser. + transform: translateZ(0); // Remove default hover background in panel title. See #25752. &:hover { - background: inherit; + background: $white; } } - .components-panel__body .editor-styles-wrapper { - margin: 0 (-$grid-unit-20) (-$grid-unit-20) (-$grid-unit-20); - padding: $grid-unit-20; - } - .block-list-appender.wp-block { width: initial; } + // Override theme custom widths for blocks. .editor-styles-wrapper .wp-block.wp-block.wp-block.wp-block.wp-block { max-width: 100%; } + + .components-panel__body.is-opened { + padding: 0; + } } -// Add some spacing above the inner blocks so that the block toolbar doesn't -// overlap the panel header. -.wp-block-widget-area > .components-panel__body > div > .editor-styles-wrapper > .block-editor-inner-blocks { - padding-top: $grid-unit-30; +.blocks-widgets-container .wp-block-widget-area__inner-blocks.editor-styles-wrapper { + margin: 0; + padding: 0; - // Ensure the widget area block lists have a minimum height so that it doesn't - // collapse to zero height when it has no blocks. When that happens the block - // can't be used as a drop target. > .block-editor-block-list__layout { + // Stretch the inner-blocks container to cover the entire panel, + // so that dragging onto anywhere in it works. + margin-top: -$panel-title-height; + // Add some spacing above the inner blocks so that the block toolbar doesn't + // overlap the panel header. + padding: ($panel-title-height + $grid-unit-30) $grid-unit-20 $grid-unit-20; + + // Ensure the widget area block lists have a minimum height so that it doesn't + // collapse to zero height when it has no blocks. When that happens the block + // can't be used as a drop target. min-height: $grid-unit-40; } } + +.wp-block-widget-area__highlight-drop-zone { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); +} + +// Prevent "dragenter" event from firing when dragging onto the title component. +body.is-dragging-components-draggable .wp-block[data-type="core/widget-area"] .components-panel__body > .components-panel__body-title { + &, + * { + pointer-events: none; + } +} diff --git a/packages/edit-widgets/src/store/actions.js b/packages/edit-widgets/src/store/actions.js index 1ba1b03d8abf9..8b9c5df3676cd 100644 --- a/packages/edit-widgets/src/store/actions.js +++ b/packages/edit-widgets/src/store/actions.js @@ -108,10 +108,15 @@ export function* saveWidgetArea( widgetAreaId ) { ( { sidebar } ) => sidebar === widgetAreaId ); - // Remove all duplicate reference widget instances + // Remove all duplicate reference widget instances for legacy widgets. + // Why? We filter out the widgets with duplicate IDs to prevent adding more than one instance of a widget + // implemented using a function. WordPress doesn't support having more than one instance of these, if you try to + // save multiple instances of these in different sidebars you will run into undefined behaviors. const usedReferenceWidgets = []; - const widgetsBlocks = post.blocks.filter( ( { attributes: { id } } ) => { - if ( id ) { + const widgetsBlocks = post.blocks.filter( ( block ) => { + const { id } = block.attributes; + + if ( block.name === 'core/legacy-widget' && id ) { if ( usedReferenceWidgets.includes( id ) ) { return false; } diff --git a/packages/widgets/src/blocks/legacy-widget/editor.scss b/packages/widgets/src/blocks/legacy-widget/editor.scss index 8ad7a3faa5c33..72c15ff32a6b1 100644 --- a/packages/widgets/src/blocks/legacy-widget/editor.scss +++ b/packages/widgets/src/blocks/legacy-widget/editor.scss @@ -1,4 +1,5 @@ .wp-block-legacy-widget__edit-form { + display: flow-root; background: $white; border-radius: $radius-block-ui; border: 1px solid $gray-900; @@ -22,36 +23,32 @@ font-size: $default-font-size; } - label + .widefat { - margin-top: $grid-unit-15; - } - // Override theme style bleed. label, input, a { + font-family: system-ui; + font-weight: normal; color: $black; } + input[type="text"], select { font-family: system-ui; - -webkit-appearance: revert; - color: revert; - border: revert; - border-radius: revert; - background: revert; - box-shadow: revert; - text-shadow: revert; - outline: revert; - cursor: revert; - transform: revert; - font-size: revert; - line-height: revert; - padding: revert; - margin: revert; - min-height: revert; - max-width: revert; - vertical-align: revert; - font-weight: revert; + background: transparent; + box-sizing: border-box; + border: 1px solid $gray-700; + border-radius: 3px; + box-shadow: none; + color: $black; + display: block; + margin: 0; + width: 100%; + font-size: $default-font-size; + font-weight: normal; + height: 30px; + line-height: 1; + min-height: 30px; + padding-left: $grid-unit-05; } } @@ -130,6 +127,11 @@ } } +// Wide widgets opening in popovers in the Customizer should have a min-width +.components-popover__content .wp-block-legacy-widget__edit-form { + min-width: 400px; +} + .wp-block-legacy-widget { .components-select-control__input { padding: 0;