From 03e034fea925afd1cd359de4e2713ebde92ce2dc Mon Sep 17 00:00:00 2001 From: Enrique Piqueras Date: Mon, 23 Sep 2019 07:34:07 -0700 Subject: [PATCH] Editor: Implement `EntityProvider` and use it to refactor custom sources with a backwards compatibility hook for meta sources. (#17153) * Editor: Implement `EntityProvider` and use it to refactor custom sources with a backwards compatibility hook for meta sources. * Core Data: Handle dynamic entity contexts using a proxy. * Block Editor: Replace dependency on Core Data with an Editor filter. * Core Data: Fix linting error from rebase. * Core Data: Simplify proxy usage into a context specific proxy. * Core Data: Clean up variable names. --- package-lock.json | 1 + packages/core-data/package.json | 1 + packages/core-data/src/entity-provider.js | 90 ++++++++ packages/core-data/src/index.js | 2 + .../editor/src/components/provider/index.js | 28 ++- .../custom-sources-backwards-compatibility.js | 73 +++++++ packages/editor/src/hooks/index.js | 1 + packages/editor/src/store/actions.js | 206 +----------------- .../editor/src/store/block-sources/README.md | 22 -- .../store/block-sources/__mocks__/index.js | 1 - .../editor/src/store/block-sources/index.js | 6 - .../editor/src/store/block-sources/meta.js | 55 ----- packages/editor/src/store/test/actions.js | 1 - 13 files changed, 185 insertions(+), 302 deletions(-) create mode 100644 packages/core-data/src/entity-provider.js create mode 100644 packages/editor/src/hooks/custom-sources-backwards-compatibility.js delete mode 100644 packages/editor/src/store/block-sources/README.md delete mode 100644 packages/editor/src/store/block-sources/__mocks__/index.js delete mode 100644 packages/editor/src/store/block-sources/index.js delete mode 100644 packages/editor/src/store/block-sources/meta.js diff --git a/package-lock.json b/package-lock.json index 99755f651ca11..d7586bbb61573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4885,6 +4885,7 @@ "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/data": "file:packages/data", "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/element": "file:packages/element", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/url": "file:packages/url", "equivalent-key-map": "^0.2.2", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 9b2dbb8ba7fd3..fabeae3fee70f 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -26,6 +26,7 @@ "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", + "@wordpress/element": "file:../element", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/url": "file:../url", "equivalent-key-map": "^0.2.2", diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js new file mode 100644 index 0000000000000..7870e0217cc97 --- /dev/null +++ b/packages/core-data/src/entity-provider.js @@ -0,0 +1,90 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext, useCallback } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { defaultEntities, kinds } from './entities'; + +const entities = { + ...defaultEntities.reduce( ( acc, entity ) => { + if ( ! acc[ entity.kind ] ) { + acc[ entity.kind ] = {}; + } + acc[ entity.kind ][ entity.name ] = { context: createContext() }; + return acc; + }, {} ), + ...kinds.reduce( ( acc, kind ) => { + acc[ kind.name ] = {}; + return acc; + }, {} ), +}; +const getEntity = ( kind, type ) => { + if ( ! entities[ kind ] ) { + throw new Error( `Missing entity config for kind: ${ kind }.` ); + } + + if ( ! entities[ kind ][ type ] ) { + entities[ kind ][ type ] = { context: createContext() }; + } + + return entities[ kind ][ type ]; +}; + +/** + * Context provider component for providing + * an entity for a specific entity type. + * + * @param {Object} props The component's props. + * @param {string} props.kind The entity kind. + * @param {string} props.type The entity type. + * @param {number} props.id The entity ID. + * @param {*} props.children The children to wrap. + * + * @return {Object} The provided children, wrapped with + * the entity's context provider. + */ +export default function EntityProvider( { kind, type, id, children } ) { + const Provider = getEntity( kind, type ).context.Provider; + return { children }; +} + +/** + * Hook that returns the value and a setter for the + * specified property of the nearest provided + * entity of the specified type. + * + * @param {string} kind The entity kind. + * @param {string} type The entity type. + * @param {string} prop The property name. + * + * @return {[*, Function]} A tuple where the first item is the + * property value and the second is the + * setter. + */ +export function useEntityProp( kind, type, prop ) { + const id = useContext( getEntity( kind, type ).context ); + + const value = useSelect( + ( select ) => { + const entity = select( 'core' ).getEditedEntityRecord( kind, type, id ); + return entity && entity[ prop ]; + }, + [ kind, type, id, prop ] + ); + + const { editEntityRecord } = useDispatch( 'core' ); + const setValue = useCallback( + ( newValue ) => { + editEntityRecord( kind, type, id, { + [ prop ]: newValue, + } ); + }, + [ kind, type, id, prop ] + ); + + return [ value, setValue ]; +} diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index b0c5718ab9b88..2cdddb960e448 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -48,3 +48,5 @@ registerStore( REDUCER_KEY, { selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, } ); + +export { default as EntityProvider, useEntityProp } from './entity-provider'; diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 6cd07c572211a..c386f02097167 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -11,6 +11,7 @@ import { compose } from '@wordpress/compose'; import { Component } from '@wordpress/element'; import { withDispatch, withSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { EntityProvider } from '@wordpress/core-data'; import { BlockEditorProvider, transformStyles } from '@wordpress/block-editor'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; @@ -164,6 +165,7 @@ class EditorProvider extends Component { const { canUserUseUnfilteredHTML, children, + post, blocks, resetEditorBlocks, isReady, @@ -185,18 +187,20 @@ class EditorProvider extends Component { ); return ( - - { children } - - - { editorSettings.__experimentalBlockDirectory && } - + + + { children } + + + { editorSettings.__experimentalBlockDirectory && } + + ); } } diff --git a/packages/editor/src/hooks/custom-sources-backwards-compatibility.js b/packages/editor/src/hooks/custom-sources-backwards-compatibility.js new file mode 100644 index 0000000000000..0e6173b99382d --- /dev/null +++ b/packages/editor/src/hooks/custom-sources-backwards-compatibility.js @@ -0,0 +1,73 @@ +/** + * WordPress dependencies + */ +import { getBlockType } from '@wordpress/blocks'; +import { useEntityProp } from '@wordpress/core-data'; +import { useMemo, useCallback } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; + +const EMPTY_OBJECT = {}; +function useMetaAttributeSource( name, _attributes, _setAttributes ) { + const { attributes: attributeTypes = EMPTY_OBJECT } = + getBlockType( name ) || EMPTY_OBJECT; + let [ attributes, setAttributes ] = [ _attributes, _setAttributes ]; + + if ( Object.values( attributeTypes ).some( ( type ) => type.source === 'meta' ) ) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [ meta, setMeta ] = useEntityProp( 'postType', 'post', 'meta' ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + attributes = useMemo( + () => ( { + ..._attributes, + ...Object.keys( attributeTypes ).reduce( ( acc, key ) => { + if ( attributeTypes[ key ].source === 'meta' ) { + acc[ key ] = meta[ attributeTypes[ key ].meta ]; + } + return acc; + }, {} ), + } ), + [ attributeTypes, meta, _attributes ] + ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + setAttributes = useCallback( + ( ...args ) => { + Object.keys( args[ 0 ] ).forEach( ( key ) => { + if ( attributeTypes[ key ].source === 'meta' ) { + setMeta( { [ attributeTypes[ key ].meta ]: args[ 0 ][ key ] } ); + } + } ); + return _setAttributes( ...args ); + }, + [ attributeTypes, setMeta, _setAttributes ] + ); + } + + return [ attributes, setAttributes ]; +} +const withMetaAttributeSource = createHigherOrderComponent( + ( BlockListBlock ) => ( { attributes, setAttributes, name, ...props } ) => { + [ attributes, setAttributes ] = useMetaAttributeSource( + name, + attributes, + setAttributes + ); + return ( + + ); + }, + 'withMetaAttributeSource' +); + +addFilter( + 'editor.BlockListBlock', + 'core/editor/custom-sources-backwards-compatibility/with-meta-attribute-source', + withMetaAttributeSource +); diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 2c8a61d980252..6e0934d63c0cf 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -1,4 +1,5 @@ /** * Internal dependencies */ +import './custom-sources-backwards-compatibility'; import './default-autocompleters'; diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 78b218f83de70..0e2ef1dc9da46 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -9,7 +9,6 @@ import { has, castArray } from 'lodash'; import deprecated from '@wordpress/deprecated'; import { dispatch, select, apiFetch } from '@wordpress/data-controls'; import { parse, synchronizeBlocksWithTemplate } from '@wordpress/blocks'; -import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -25,120 +24,6 @@ import { getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; import serializeBlocks from './utils/serialize-blocks'; -import { awaitNextStateChange, getRegistry } from './controls'; -import * as sources from './block-sources'; - -/** - * Map of Registry instance to WeakMap of dependencies by custom source. - * - * @type WeakMap> - */ -const lastBlockSourceDependenciesByRegistry = new WeakMap; - -/** - * Given a blocks array, returns a blocks array with sourced attribute values - * applied. The reference will remain consistent with the original argument if - * no attribute values must be overridden. If sourced values are applied, the - * return value will be a modified copy of the original array. - * - * @param {WPBlock[]} blocks Original blocks array. - * - * @return {WPBlock[]} Blocks array with sourced values applied. - */ -function* getBlocksWithSourcedAttributes( blocks ) { - const registry = yield getRegistry(); - if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { - return blocks; - } - - const blockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); - - let workingBlocks = blocks; - for ( let i = 0; i < blocks.length; i++ ) { - let block = blocks[ i ]; - const blockType = yield select( 'core/blocks', 'getBlockType', block.name ); - - for ( const [ attributeName, schema ] of Object.entries( blockType.attributes ) ) { - if ( ! sources[ schema.source ] || ! sources[ schema.source ].apply ) { - continue; - } - - if ( ! blockSourceDependencies.has( sources[ schema.source ] ) ) { - continue; - } - - const dependencies = blockSourceDependencies.get( sources[ schema.source ] ); - const sourcedAttributeValue = sources[ schema.source ].apply( schema, dependencies ); - - // It's only necessary to apply the value if it differs from the - // block's locally-assigned value, to avoid needlessly resetting - // the block editor. - if ( sourcedAttributeValue === block.attributes[ attributeName ] ) { - continue; - } - - // Create a shallow clone to mutate, leaving the original intact. - if ( workingBlocks === blocks ) { - workingBlocks = [ ...workingBlocks ]; - } - - block = { - ...block, - attributes: { - ...block.attributes, - [ attributeName ]: sourcedAttributeValue, - }, - }; - - workingBlocks.splice( i, 1, block ); - } - - // Recurse to apply source attributes to inner blocks. - if ( block.innerBlocks.length ) { - const appliedInnerBlocks = yield* getBlocksWithSourcedAttributes( block.innerBlocks ); - if ( appliedInnerBlocks !== block.innerBlocks ) { - if ( workingBlocks === blocks ) { - workingBlocks = [ ...workingBlocks ]; - } - - block = { - ...block, - innerBlocks: appliedInnerBlocks, - }; - - workingBlocks.splice( i, 1, block ); - } - } - } - - return workingBlocks; -} - -/** - * Refreshes the last block source dependencies, optionally for a given subset - * of sources (defaults to the full set of sources). - * - * @param {?Array} sourcesToUpdate Optional subset of sources to reset. - * - * @yield {Object} Yielded actions or control descriptors. - */ -function* resetLastBlockSourceDependencies( sourcesToUpdate = Object.values( sources ) ) { - if ( ! sourcesToUpdate.length ) { - return; - } - - const registry = yield getRegistry(); - if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { - lastBlockSourceDependenciesByRegistry.set( registry, new WeakMap ); - } - - const lastBlockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); - - for ( const source of sourcesToUpdate ) { - const dependencies = yield* source.getDependencies(); - lastBlockSourceDependencies.set( source, dependencies ); - } -} /** * Returns an action generator used in signalling that editor has initialized with @@ -168,7 +53,6 @@ export function* setupEditor( post, edits, template ) { } yield resetPost( post ); - yield* resetLastBlockSourceDependencies(); yield { type: 'SETUP_EDITOR', post, @@ -186,7 +70,6 @@ export function* setupEditor( post, edits, template ) { ) { yield editPost( edits ); } - yield* __experimentalSubscribeSources(); } /** @@ -199,55 +82,6 @@ export function __experimentalTearDownEditor() { return { type: 'TEAR_DOWN_EDITOR' }; } -/** - * Returns an action generator which loops to await the next state change, - * calling to reset blocks when a block source dependencies change. - * - * @yield {Object} Action object. - */ -export function* __experimentalSubscribeSources() { - while ( true ) { - yield awaitNextStateChange(); - - // The bailout case: If the editor becomes unmounted, it will flag - // itself as non-ready. Effectively unsubscribes from the registry. - const isStillReady = yield select( STORE_KEY, '__unstableIsEditorReady' ); - if ( ! isStillReady ) { - break; - } - - const registry = yield getRegistry(); - - let reset = false; - for ( const source of Object.values( sources ) ) { - if ( ! source.getDependencies ) { - continue; - } - - const dependencies = yield* source.getDependencies(); - - if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { - lastBlockSourceDependenciesByRegistry.set( registry, new WeakMap ); - } - - const lastBlockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); - const lastDependencies = lastBlockSourceDependencies.get( source ); - - if ( ! isShallowEqual( dependencies, lastDependencies ) ) { - lastBlockSourceDependencies.set( source, dependencies ); - - // Allow the loop to continue in order to assign latest - // dependencies values, but mark for reset. - reset = true; - } - } - - if ( reset ) { - yield resetEditorBlocks( yield select( STORE_KEY, 'getEditorBlocks' ), { __unstableShouldCreateUndoLevel: false } ); - } - } -} - /** * Returns an action object used in signalling that the latest version of the * post has been received, either by initialization or save. @@ -793,44 +627,7 @@ export function unlockPostSaving( lockName ) { * @yield {Object} Action object */ export function* resetEditorBlocks( blocks, options = {} ) { - const lastBlockAttributesChange = yield select( 'core/block-editor', '__experimentalGetLastBlockAttributeChanges' ); - - // Sync to sources from block attributes updates. - if ( lastBlockAttributesChange ) { - const updatedSources = new Set; - const updatedBlockTypes = new Set; - for ( const [ clientId, attributes ] of Object.entries( lastBlockAttributesChange ) ) { - const blockName = yield select( 'core/block-editor', 'getBlockName', clientId ); - if ( updatedBlockTypes.has( blockName ) ) { - continue; - } - - updatedBlockTypes.add( blockName ); - const blockType = yield select( 'core/blocks', 'getBlockType', blockName ); - - for ( const [ attributeName, newAttributeValue ] of Object.entries( attributes ) ) { - if ( ! blockType.attributes.hasOwnProperty( attributeName ) ) { - continue; - } - - const schema = blockType.attributes[ attributeName ]; - const source = sources[ schema.source ]; - - if ( source && source.update ) { - yield* source.update( schema, newAttributeValue ); - updatedSources.add( source ); - } - } - } - - // Dependencies are reset so that source dependencies subscription - // skips a reset which would otherwise occur by dependencies change. - // This assures that at most one reset occurs per block change. - yield* resetLastBlockSourceDependencies( Array.from( updatedSources ) ); - } - - const edits = { blocks: yield* getBlocksWithSourcedAttributes( blocks ) }; - + const edits = { blocks }; if ( options.__unstableShouldCreateUndoLevel !== false ) { const { id, type } = yield select( STORE_KEY, 'getCurrentPost' ); const noChange = @@ -852,7 +649,6 @@ export function* resetEditorBlocks( blocks, options = {} ) { edits.content = ( { blocks: blocksForSerialization = [] } ) => serializeBlocks( blocksForSerialization ); } - yield* editPost( edits ); } diff --git a/packages/editor/src/store/block-sources/README.md b/packages/editor/src/store/block-sources/README.md deleted file mode 100644 index 0c16d12b3159d..0000000000000 --- a/packages/editor/src/store/block-sources/README.md +++ /dev/null @@ -1,22 +0,0 @@ -Block Sources -============= - -By default, the blocks module supports only attributes serialized into a block's comment demarcations, or those sourced from a [standard set of sources](https://developer.wordpress.org/block-editor/developers/block-api/block-attributes/). Since the blocks module is intended to be used in a number of contexts outside the post editor, the implementation of additional context-specific sources must be implemented as an external process. - -The post editor supports such additional sources for attributes (e.g. `meta` source). - -These sources are implemented here using a uniform interface for applying and responding to block updates to sourced attributes. In the future, this interface may be generalized to allow third-party extensions to either extend the post editor sources or implement their own in custom renderings of a block editor. - -## Source API - -### `getDependencies` - -Store control called on every store change, expected to return an object whose values represent the data blocks assigned this source depend on. When these values change, all blocks assigned this source are automatically updated. The value returned from this function is passed as the second argument of the source's `apply` function, where it is expected to be used as shared data relevant for sourcing the attribute value. - -### `apply` - -Function called to retrieve an attribute value for a block. Given the attribute schema and the dependencies defined by the source's `getDependencies`, the function should return the expected attribute value. - -### `update` - -Store control called when a single block's attributes have been updated, before the new block value has taken effect (i.e. before `apply` and `applyAll` are once again called). Given the attribute schema and updated value, the control should reflect the update on the source. diff --git a/packages/editor/src/store/block-sources/__mocks__/index.js b/packages/editor/src/store/block-sources/__mocks__/index.js deleted file mode 100644 index cb0ff5c3b541f..0000000000000 --- a/packages/editor/src/store/block-sources/__mocks__/index.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/editor/src/store/block-sources/index.js b/packages/editor/src/store/block-sources/index.js deleted file mode 100644 index 542d774c313ce..0000000000000 --- a/packages/editor/src/store/block-sources/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Internal dependencies - */ -import * as meta from './meta'; - -export { meta }; diff --git a/packages/editor/src/store/block-sources/meta.js b/packages/editor/src/store/block-sources/meta.js deleted file mode 100644 index 3910395c4a740..0000000000000 --- a/packages/editor/src/store/block-sources/meta.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * WordPress dependencies - */ -import { select } from '@wordpress/data-controls'; - -/** - * Internal dependencies - */ -import { editPost } from '../actions'; - -/** - * Store control invoked upon a state change, responsible for returning an - * object of dependencies. When a change in dependencies occurs (by shallow - * equality of the returned object), blocks are reset to apply the new sourced - * value. - * - * @yield {Object} Optional yielded controls. - * - * @return {Object} Dependencies as object. - */ -export function* getDependencies() { - return { - meta: yield select( 'core/editor', 'getEditedPostAttribute', 'meta' ), - }; -} - -/** - * Given an attribute schema and dependencies data, returns a source value. - * - * @param {Object} schema Block type attribute schema. - * @param {Object} dependencies Source dependencies. - * @param {Object} dependencies.meta Post meta. - * - * @return {Object} Block attribute value. - */ -export function apply( schema, { meta } ) { - return meta[ schema.meta ]; -} - -/** - * Store control invoked upon a block attributes update, responsible for - * reflecting an update in a meta value. - * - * @param {Object} schema Block type attribute schema. - * @param {*} value Updated block attribute value. - * - * @yield {Object} Yielded action objects or store controls. - */ -export function* update( schema, value ) { - yield editPost( { - meta: { - [ schema.meta ]: value, - }, - } ); -} diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 3c34195bb3541..255c0ea84af39 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -14,7 +14,6 @@ import { } from '../constants'; jest.mock( '@wordpress/data-controls' ); -jest.mock( '../block-sources' ); select.mockImplementation( ( ...args ) => { const { select: actualSelect } = jest