diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2de6c549969961..b4001b7eda58c7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,12 +13,12 @@ /packages/block-library @youknowriad @gziolo @Soean @ajitbohra @jorgefilipecosta @talldan @noisysocks @notnownikki # Editor -/packages/annotations @youknowriad @aduth @atimmer +/packages/annotations @youknowriad @aduth @atimmer @ellatrix /packages/autop @youknowriad @aduth -/packages/block-editor @youknowriad @gziolo @talldan @noisysocks +/packages/block-editor @youknowriad @gziolo @talldan @noisysocks @ellatrix /packages/block-serialization-spec-parser @youknowriad @gziolo @aduth @dmsnell /packages/block-serialization-default-parser @youknowriad @gziolo @aduth @dmsnell -/packages/blocks @youknowriad @gziolo @aduth @noisysocks +/packages/blocks @youknowriad @gziolo @aduth @noisysocks @ellatrix /packages/edit-post @youknowriad @talldan @noisysocks /packages/editor @youknowriad @talldan @noisysocks /packages/list-reusable-blocks @youknowriad @aduth @noisysocks @@ -29,7 +29,7 @@ # Tooling /bin @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra -/docs/tool @youknowriad @gziolo @chrisvanpatten @mkaz @ajitbohra @nosolosw @notnownikki +/docs/tool @youknowriad @gziolo @chrisvanpatten @mkaz @ajitbohra @nosolosw @notnownikki /packages/babel-plugin-import-jsx-pragma @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra @nosolosw /packages/babel-plugin-makepot @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra /packages/babel-preset-default @youknowriad @gziolo @aduth @ntwb @nerrad @ajitbohra @nosolosw @@ -60,13 +60,13 @@ /packages/blob @youknowriad @gziolo @aduth /packages/date @youknowriad @gziolo @aduth /packages/deprecated @youknowriad @gziolo @aduth -/packages/dom @youknowriad @gziolo @aduth @nosolosw +/packages/dom @youknowriad @gziolo @aduth @nosolosw @ellatrix /packages/dom-ready @youknowriad @gziolo @aduth /packages/escape-html @youknowriad @gziolo @aduth /packages/html-entities @youknowriad @gziolo @aduth /packages/i18n @youknowriad @aduth @swissspidy /packages/is-shallow-equal @youknowriad @gziolo @aduth -/packages/keycodes @youknowriad @gziolo @aduth @talldan +/packages/keycodes @youknowriad @gziolo @aduth @talldan @ellatrix /packages/priority-queue @youknowriad @gziolo @aduth /packages/token-list @youknowriad @gziolo @aduth /packages/url @youknowriad @gziolo @aduth @talldan @@ -77,9 +77,9 @@ /packages/plugins @youknowriad @gziolo @aduth @adamsilverstein # Rich Text -/packages/format-library @youknowriad @aduth @iseulde @jorgefilipecosta -/packages/rich-text @youknowriad @aduth @iseulde @jorgefilipecosta -/packages/block-editor/src/components/rich-text @youknowriad @aduth @iseulde @jorgefilipecosta +/packages/format-library @youknowriad @aduth @ellatrix @jorgefilipecosta +/packages/rich-text @youknowriad @aduth @ellatrix @jorgefilipecosta +/packages/block-editor/src/components/rich-text @youknowriad @aduth @ellatrix @jorgefilipecosta # PHP /lib @youknowriad @gziolo @aduth diff --git a/.travis.yml b/.travis.yml index 26b72a571f723d..e1a21e3f72cc5c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -99,3 +99,15 @@ jobs: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - npm run build - npm run test-e2e -- --ci --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 2 == 1' < ~/.jest-e2e-tests ) + allow_failures: + - name: PHP unit tests (PHP 5.3) + env: WP_VERSION=latest SWITCH_TO_PHP=5.3 + script: + - ./bin/run-wp-unit-tests.sh + if: branch = master and type != "pull_request" + + - name: PHP unit tests (PHP 5.2) + env: WP_VERSION=latest SWITCH_TO_PHP=5.2 + script: + - ./bin/run-wp-unit-tests.sh + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2f9e9ec7fe6f7..f3c371c089f38a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,8 +21,8 @@ For another version of Windows, or if you prefer to set things up manually, be s If you have an incompatible version of Node in your development environment, you can use [nvm](https://github.com/creationix/nvm) to change node versions on the command line: ``` -npx nvm install -npx nvm use +nvm install +nvm use ``` You also should have the latest release of [npm installed][npm]. npm is a separate project from Node.js and is updated frequently. If you've just installed Node.js which includes a version of npm within the installation you most likely will need also to update your npm installation. To update npm, type this into your terminal: `npm install npm@latest -g` diff --git a/assets/stylesheets/_mixins.scss b/assets/stylesheets/_mixins.scss index 89297e6335bc92..0d602338bd8727 100644 --- a/assets/stylesheets/_mixins.scss +++ b/assets/stylesheets/_mixins.scss @@ -484,7 +484,7 @@ background-color: $white; @include break-medium() { - margin: -3px 0 0 -3px; + margin: 3px 0 0 3px; } } } diff --git a/assets/stylesheets/_z-index.scss b/assets/stylesheets/_z-index.scss index 86ab07f3e28ab1..3c8b921b1fce0c 100644 --- a/assets/stylesheets/_z-index.scss +++ b/assets/stylesheets/_z-index.scss @@ -99,7 +99,11 @@ $z-layers: ( ".nux-dot-tip": 1000001, // Show tooltips above NUX tips, wp-admin menus, submenus, and sidebar: - ".components-tooltip": 1000002 + ".components-tooltip": 1000002, + + // Make sure corner handles are above side handles for ResizableBox component + ".components-resizable-box__side-handle": 1, + ".components-resizable-box__corner-handle": 2 ); @function z-index( $key ) { diff --git a/babel.config.js b/babel.config.js index b56ad5b149478b..7679cc1e837041 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,5 +3,6 @@ module.exports = function( api ) { return { presets: [ '@wordpress/babel-preset-default' ], + plugins: [ 'babel-plugin-inline-json-import' ], }; }; diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md b/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md index 47d2f70876143e..dff770151e8e9c 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md @@ -1,48 +1,56 @@ # Applying Styles From a Stylesheet -In the previous section, the block had applied its own styles by an inline `style` attribute. While this might be adequate for very simple components, you will quickly find that it becomes easier to write your styles by extracting them to a separate stylesheet file. +In the previous step, the block had applied its own styles by an inline `style` attribute. While this might be adequate for very simple components, you will quickly find that it becomes easier to write your styles by extracting them to a separate stylesheet file. -The editor will automatically generate a class name for each block type to simplify styling. It can be accessed from the object argument passed to the edit and save functions: +The editor will automatically generate a class name for each block type to simplify styling. It can be accessed from the object argument passed to the edit and save functions. In step 2, we will create a stylesheet to use that class name. {% codetabs %} {% ES5 %} ```js -var el = wp.element.createElement, - registerBlockType = wp.blocks.registerBlockType; - -registerBlockType( 'gutenberg-boilerplate-es5/hello-world-step-02', { - title: 'Hello World (Step 2)', - - icon: 'universal-access-alt', - - category: 'layout', - - edit: function( props ) { - return el( 'p', { className: props.className }, 'Hello editor.' ); - }, - - save: function() { - return el( 'p', {}, 'Hello saved content.' ); - } -} ); +( function( blocks, element ) { + var el = element.createElement; + + blocks.registerBlockType( 'gutenberg-examples/example-02-stylesheets', { + title: 'Example: Stylesheets', + icon: 'universal-access-alt', + category: 'layout', + edit: function( props ) { + return el( + 'p', + { className: props.className }, + 'Hello World, step 2 (from the editor, in green).' + ); + }, + save: function() { + return el( + 'p', + {}, + 'Hello World, step 2 (from the frontend, in red).' + ); + }, + } ); +}( + window.wp.blocks, + window.wp.element +) ); ``` {% ESNext %} ```js const { registerBlockType } = wp.blocks; -registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-02', { - title: 'Hello World (Step 2)', +registerBlockType( 'gutenberg-examples/example-02-stylesheets', { + title: 'Example: Stylesheets', icon: 'universal-access-alt', category: 'layout', edit( { className } ) { - return

Hello editor.

; + return

Hello World, step 2 (from the editor, in green).

; }, save() { - return

Hello saved content.

; + return

Hello World, step 2 (from the frontend, in red)./p>; } } ); ``` @@ -50,8 +58,16 @@ registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-02', { The class name is generated using the block's name prefixed with `wp-block-`, replacing the `/` namespace separator with a single `-`. +## Enqueueing Editor and Front end Assets + +Like scripts, you need to enqueue your block's styles. As explained in the section before, you use the `editor_style` handle for styles only relevant in the editor, and the `style` handle for common styles applied both in the editor and the front of your site. + +The stylesheets enqueued by `style` are the base styles and are loaded first. The `editor` stylesheet will be loaded after it. + +Let's move on into code. Create a file called `editor.css`: + ```css -.wp-block-gutenberg-boilerplate-es5-hello-world-step-02 { +.wp-block-gutenberg-examples-example-02-stylesheets { color: green; background: #cfc; border: 2px solid #9c9; @@ -59,56 +75,52 @@ The class name is generated using the block's name prefixed with `wp-block-`, re } ``` -## Enqueueing Editor-only Block Assets +And a new `style.css` file containing: -Like scripts, your block's editor-specific styles should be enqueued by assigning the `editor_styles` setting of the registered block type: +```css +.wp-block-gutenberg-examples-example-02-stylesheets { + color: darkred; + background: #fcc; + border: 2px solid #c99; + padding: 20px; +} +``` + +Configure your plugin to use these new styles: ```php 'gutenberg-boilerplate-es5-step02-editor', - 'editor_style' => 'gutenberg-boilerplate-es5-step02-editor', - ) ); -} -add_action( 'init', 'gutenberg_boilerplate_block' ); -``` - -## Enqueueing Editor and Front end Assets - -While a block's scripts are usually only necessary to load in the editor, you'll want to load styles both on the front of your site and in the editor. You may even want distinct styles in each context. - -When registering a block, you can assign one or both of `style` and `editor_style` to respectively assign styles always loaded for a block or styles only loaded in the editor. - -```php - 'gutenberg-boilerplate-es5-step02', + register_block_type( 'gutenberg-examples/example-02-stylesheets', array( + 'style' => 'gutenberg-examples-02', + 'editor_style' => 'gutenberg-examples-02-editor', + 'editor_script' => 'gutenberg-examples-02', ) ); } -add_action( 'init', 'gutenberg_boilerplate_block' ); +add_action( 'init', 'gutenberg_examples_02_register_block' ); ``` - -Since your block is likely to share some styles in both contexts, you can consider `style.css` as the base stylesheet, placing editor-specific styles in `editor.css`. diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md b/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md index b2efe54ef4d1ac..bfabb7379b40a6 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md @@ -13,50 +13,45 @@ You can also customize the toolbar to include controls specific to your block ty {% codetabs %} {% ES5 %} ```js -var el = wp.element.createElement, - Fragment = wp.element.Fragment - registerBlockType = wp.blocks.registerBlockType, - RichText = wp.editor.RichText, - BlockControls = wp.editor.BlockControls, - AlignmentToolbar = wp.editor.AlignmentToolbar; - -registerBlockType( 'gutenberg-boilerplate-es5/hello-world-step-04', { - title: 'Hello World (Step 4)', - - icon: 'universal-access-alt', - - category: 'layout', - - attributes: { - content: { - type: 'string', - source: 'html', - selector: 'p', +( function( blocks, editor, element ) { + var el = element.createElement; + var RichText = editor.RichText; + var AlignmentToolbar = editor.AlignmentToolbar; + var BlockControls = editor.BlockControls; + + blocks.registerBlockType( 'gutenberg-examples/example-04-controls', { + title: 'Example: Controls', + icon: 'universal-access-alt', + category: 'layout', + + attributes: { + content: { + type: 'array', + source: 'children', + selector: 'p', + }, + alignment: { + type: 'string', + default: 'none', + }, }, - alignment: { - type: 'string', - }, - }, - edit: function( props ) { - var content = props.attributes.content, - alignment = props.attributes.alignment; + edit: function( props ) { + var content = props.attributes.content; + var alignment = props.attributes.alignment; - function onChangeContent( newContent ) { - props.setAttributes( { content: newContent } ); - } + function onChangeContent( newContent ) { + props.setAttributes( { content: newContent } ); + } - function onChangeAlignment( newAlignment ) { - props.setAttributes( { alignment: newAlignment } ); - } + function onChangeAlignment( newAlignment ) { + props.setAttributes( { alignment: newAlignment === undefined ? 'none' : newAlignment } ); + } - return ( - el( - Fragment, - null, + return [ el( BlockControls, - null, + { key: 'controls' }, el( AlignmentToolbar, { @@ -68,98 +63,99 @@ registerBlockType( 'gutenberg-boilerplate-es5/hello-world-step-04', { el( RichText, { - key: 'editable', + key: 'richtext', tagName: 'p', - className: props.className, style: { textAlign: alignment }, + className: props.className, onChange: onChangeContent, value: content, } - ) - ) - ); - }, - - save: function( props ) { - var content = props.attributes.content, - alignment = props.attributes.alignment; + ), + ]; + }, - return el( RichText.Content, { - tagName: 'p', - className: props.className, - style: { textAlign: alignment }, - value: content - } ); - }, -} ); + save: function( props ) { + return el( RichText.Content, { + tagName: 'p', + className: 'gutenberg-examples-align-' + props.attributes.alignment, + value: props.attributes.content, + } ); + }, + } ); +}( + window.wp.blocks, + window.wp.editor, + window.wp.element +) ); ``` {% ESNext %} ```js const { registerBlockType } = wp.blocks; -const { Fragment } = wp.element; + const { RichText, - BlockControls, AlignmentToolbar, + BlockControls, } = wp.editor; -registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-04', { - title: 'Hello World (Step 4)', - +registerBlockType( 'gutenberg-examples/example-04-controls-esnext', { + title: 'Example: Controls (esnext)', icon: 'universal-access-alt', - category: 'layout', - attributes: { content: { - type: 'string', - source: 'html', + type: 'array', + source: 'children', selector: 'p', }, alignment: { type: 'string', + default: 'none', }, }, + edit: ( props ) => { + const { + attributes: { + content, + alignment, + }, + className, + } = props; + + const onChangeContent = ( newContent ) => { + props.setAttributes( { content: newContent } ); + }; - edit( { attributes, className, setAttributes } ) { - const { content, alignment } = attributes; - - function onChangeContent( newContent ) { - setAttributes( { content: newContent } ); - } - - function onChangeAlignment( newAlignment ) { - setAttributes( { alignment: newAlignment } ); - } + const onChangeAlignment = ( newAlignment ) => { + props.setAttributes( { alignment: newAlignment === undefined ? 'none' : newAlignment } ); + }; return ( - - - - +

+ { + + + + } - +
); }, - - save( { attributes } ) { - const { content, alignment } = attributes; - + save: ( props ) => { return ( ); }, diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md b/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md index ab7543c392c9cb..8c92d48349b7e8 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md @@ -1,56 +1,59 @@ # Creating dynamic blocks -It is possible to create dynamic blocks. These are blocks that can change their content even if the post is not saved. One example from WordPress itself is the latest posts block. This block will update everywhere it is used when a new post is published. +Dynamic blocks are blocks that can change their content even if the post is not saved. One example from WordPress itself is the latest posts block. This block will update everywhere it is used when a new post is published. -The following code example shows how to create the latest post block dynamic block. +The following code example shows how to create a dynamic block that shows only the last post as a link. {% codetabs %} {% ES5 %} ```js -// myblock.js +( function( blocks, element, data ) { -var el = wp.element.createElement, - registerBlockType = wp.blocks.registerBlockType, - withSelect = wp.data.withSelect; + var el = element.createElement, + registerBlockType = blocks.registerBlockType, + withSelect = data.withSelect; -registerBlockType( 'my-plugin/latest-post', { - title: 'Latest Post', - icon: 'megaphone', - category: 'widgets', - - edit: withSelect( function( select ) { - return { - posts: select( 'core' ).getEntityRecords( 'postType', 'post' ) - }; - } )( function( props ) { + registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', + icon: 'megaphone', + category: 'widgets', - if ( ! props.posts ) { - return "Loading..."; - } - - if ( props.posts.length === 0 ) { - return "No posts"; - } - var className = props.className; - var post = props.posts[ 0 ]; - - return el( - 'a', - { className: className, href: post.link }, - post.title.rendered - ); - } ), -} ); + edit: withSelect( function( select ) { + return { + posts: select( 'core' ).getEntityRecords( 'postType', 'post' ) + }; + } )( function( props ) { + + if ( ! props.posts ) { + return "Loading..."; + } + + if ( props.posts.length === 0 ) { + return "No posts"; + } + var className = props.className; + var post = props.posts[ 0 ]; + + return el( + 'a', + { className: className, href: post.link }, + post.title.rendered + ); + } ), + } ); +}( + window.wp.blocks, + window.wp.element, + window.wp.data, +) ); ``` {% ESNext %} ```js -// myblock.js - const { registerBlockType } = wp.blocks; const { withSelect } = wp.data; -registerBlockType( 'my-plugin/latest-post', { - title: 'Latest Post', +registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', icon: 'megaphone', category: 'widgets', @@ -78,13 +81,16 @@ registerBlockType( 'my-plugin/latest-post', { ``` {% end %} -Because it is a dynamic block it doesn't need to override the default `save` implementation on the client. Instead, it needs a server component. The rendering can be added using the `render_callback` property when using the `register_block_type` function. +Because it is a dynamic block it doesn't need to override the default `save` implementation on the client. Instead, it needs a server component. The contents in the front of your site depend on the function called by the `render_callback` property of `register_block_type`. ```php 1, 'post_status' => 'publish', @@ -101,9 +107,21 @@ function my_plugin_render_block_latest_post( $attributes, $content ) { ); } -register_block_type( 'my-plugin/latest-post', array( - 'render_callback' => 'my_plugin_render_block_latest_post', -) ); +function gutenberg_examples_05_dynamic() { + wp_register_script( + 'gutenberg-examples-05', + plugins_url( 'block.js', __FILE__ ), + array( 'wp-blocks', 'wp-element', 'wp-data' ) + ); + + register_block_type( 'gutenberg-examples/example-05-dynamic', array( + 'editor_script' => 'gutenberg-examples-05', + 'render_callback' => 'gutenberg_examples_05_dynamic_render_callback' + ) ); + +} +add_action( 'init', 'gutenberg_examples_05_dynamic' ); + ``` There are a few things to notice: @@ -121,45 +139,47 @@ Gutenberg 2.8 added the [``](/packages/components/src/server-s {% codetabs %} {% ES5 %} ```js -// myblock.js - -var el = wp.element.createElement, - registerBlockType = wp.blocks.registerBlockType, - ServerSideRender = wp.components.ServerSideRender; - -registerBlockType( 'my-plugin/latest-post', { - title: 'Latest Post', - icon: 'megaphone', - category: 'widgets', - - edit: function( props ) { - // ensure the block attributes matches this plugin's name - return ( - el(ServerSideRender, { - block: "my-plugin/latest-post", - attributes: props.attributes - }) - ); - }, -} ); +( function( blocks, element, components ) { + + var el = element.createElement, + registerBlockType = blocks.registerBlockType, + ServerSideRender = components.ServerSideRender; + + registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', + icon: 'megaphone', + category: 'widgets', + + edit: function( props ) { + + return ( + el(ServerSideRender, { + block: "gutenberg-examples/example-05-dynamic", + attributes: props.attributes + } ) + ); + }, + } ); +}( + window.wp.blocks, + window.wp.element, + window.wp.components, +) ); ``` {% ESNext %} ```js -// myblock.js - const { registerBlockType } = wp.blocks; const { ServerSideRender } = wp.components; -registerBlockType( 'my-plugin/latest-post', { - title: 'Latest Post', +registerBlockType( 'gutenberg-examples/example-05-dynamic', { + title: 'Example: last post', icon: 'megaphone', category: 'widgets', edit: function( props ) { - // ensure the block attributes matches this plugin's name return ( ); @@ -168,4 +188,4 @@ registerBlockType( 'my-plugin/latest-post', { ``` {% end %} -The PHP code is the same as above and is automatically handled through the WP REST API. +Note that this code uses the `wp.components` utility but not `wp.data`. Make sure to update the `wp-data` dependency to `wp-compononents` in the PHP code. diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md b/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md index c51bd505bdd2e2..738ed6640ce55f 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md @@ -1,6 +1,6 @@ # Introducing Attributes and Editable Fields -Our example block is still not very interesting because it lacks options to customize the appearance of the message. In this section, we will implement a RichText field allowing the user to specify their own message. Before doing so, it's important to understand how the state of a block (its "attributes") is maintained and changed over time. +The example blocks so far are still not very interesting because they lack options to customize the appearance of the message. In this section, we will implement a RichText field allowing the user to specify their own message. Before doing so, it's important to understand how the state of a block (its "attributes") is maintained and changed over time. ## Attributes @@ -8,84 +8,121 @@ Until now, the `edit` and `save` functions have returned a simple representation One challenge of maintaining the representation of a block as a JavaScript object is that we must be able to extract this object again from the saved content of a post. This is achieved with the block type's `attributes` property: -{% codetabs %} -{% ES5 %} ```js -var el = wp.element.createElement, - registerBlockType = wp.blocks.registerBlockType, - RichText = wp.editor.RichText; - -registerBlockType( 'gutenberg-boilerplate-es5/hello-world-step-03', { - title: 'Hello World (Step 3)', - - icon: 'universal-access-alt', - - category: 'layout', - attributes: { content: { - type: 'string', - source: 'html', + type: 'array', + source: 'children', selector: 'p', - } + }, }, +``` - edit: function( props ) { - var content = props.attributes.content; +When registering a new block type, the `attributes` property describes the shape of the attributes object you'd like to receive in the `edit` and `save` functions. Each value is a [source function](/docs/designers-developers/developers/block-api/block-attributes.md) to find the desired value from the markup of the block. + +In the code snippet above, when loading the editor, the `content` value will be extracted from the HTML of the paragraph element in the saved post's markup. + +## Components and the `RichText` Component + +Earlier examples used the `createElement` function to create DOM nodes, but it's also possible to encapsulate this behavior into "components". This abstraction helps you share common behaviors and hide complexity in self-contained units. - function onChangeContent( newContent ) { - props.setAttributes( { content: newContent } ); - } +There are a number of [components available](/docs/designers-developers/developers/packages/packages-editor.md#components) to use in implementing your blocks. You can see one such component in the code below: the [`RichText` component](/docs/designers-developers/developers/packages/packages-editor.md#richtext). - return el( - RichText, - { - tagName: 'p', - className: props.className, - onChange: onChangeContent, - value: content, +The `RichText` component can be considered as a super-powered `textarea` element, enabling rich content editing including bold, italics, hyperlinks, etc. + +To use the `RichText` component, add `wp-editor` to the dependency array of registered script handles when calling `wp_register_script`. + +```php +wp_register_script( + 'gutenberg-examples-03', + plugins_url( 'block.js', __FILE__ ), + array( + 'wp-blocks', + 'wp-element', + 'wp-editor' // Note the addition of wp-editor to the dependencies + ), + filemtime( plugin_dir_path( __FILE__ ) . 'block.js' ) +); +``` + +Do not forget to also update the `editor_script` handle in `register_block_type` to `gutenberg-examples-03`. + +Implementing this behavior as a component enables you as the block implementer to be much more granular about editable fields. Your block may not need `RichText` at all, or it may need many independent `RichText` elements, each operating on a subset of the overall block state. + +Because `RichText` allows for nested nodes, you'll most often use it in conjunction with the `html` attribute source when extracting the value from saved content. You'll also use `RichText.Content` in the `save` function to output RichText values. + +Here is the complete block definition for Example 03. + +{% codetabs %} +{% ES5 %} +```js +( function( blocks, editor, element ) { + var el = element.createElement; + var RichText = editor.RichText; + + blocks.registerBlockType( 'gutenberg-examples/example-03-editable', { + title: 'Example: Editable', + icon: 'universal-access-alt', + category: 'layout', + + attributes: { + content: { + type: 'array', + source: 'children', + selector: 'p', + }, + }, + + edit: function( props ) { + var content = props.attributes.content; + function onChangeContent( newContent ) { + props.setAttributes( { content: newContent } ); } - ); - }, - save: function( props ) { - var content = props.attributes.content; + return el( + RichText, + { + tagName: 'p', + className: props.className, + onChange: onChangeContent, + value: content, + } + ); + }, - return el( RichText.Content, { - tagName: 'p', - className: props.className, - value: content - } ); - }, -} ); + save: function( props ) { + return el( RichText.Content, { + tagName: 'p', value: props.attributes.content, + } ); + }, + } ); +}( + window.wp.blocks, + window.wp.editor, + window.wp.element +) ); ``` {% ESNext %} ```js const { registerBlockType } = wp.blocks; const { RichText } = wp.editor; -registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-03', { - title: 'Hello World (Step 3)', - +registerBlockType( 'gutenberg-examples/example-03-editable-esnext', { + title: 'Example: Editable (esnext)', icon: 'universal-access-alt', - category: 'layout', - attributes: { content: { - type: 'string', - source: 'html', + type: 'array', + source: 'children', selector: 'p', }, }, - - edit( { attributes, className, setAttributes } ) { - const { content } = attributes; - - function onChangeContent( newContent ) { + edit: ( props ) => { + const { attributes: { content }, setAttributes, className } = props; + const onChangeContent = ( newContent ) => { setAttributes( { content: newContent } ); - } - + }; return ( ); }, - - save( { attributes } ) { - const { content } = attributes; - - return ( - - ); + save: ( props ) => { + return ; }, } ); ``` {% end %} - -When registering a new block type, the `attributes` property describes the shape of the attributes object you'd like to receive in the `edit` and `save` functions. Each value is a [source function](/docs/designers-developers/developers/block-api/block-attributes.md) to find the desired value from the markup of the block. - -In the code snippet above, when loading the editor, we will extract the `content` value as the HTML of the paragraph element in the saved post's markup. - -## Components and the `RichText` Component - -Earlier examples used the `createElement` function to create DOM nodes, but it's also possible to encapsulate this behavior into ["components"](). This abstraction helps as a pattern to share common behaviors and to hide complexity into self-contained units. There are a number of components available to use in implementing your blocks. You can see one such component in the snippet above: the [`RichText` component](). - -The `RichText` component can be considered as a super-powered `textarea` element, enabling rich content editing including bold, italics, hyperlinks, etc. - -To use the `RichText` component, add `wp-editor` to the array of registered script handles when calling `wp_register_script`. - -```php -wp_register_script( - 'gutenberg-boilerplate-es5-step03', - plugins_url( 'step-03/block.js', __FILE__ ), - array( - 'wp-blocks', - 'wp-element', - 'wp-editor', // Note the addition of wp-editor to the dependencies - ) -); -``` - -Implementing this behavior as a component enables you as the block implementer to be much more granular about editable fields. Your block may not need `RichText` at all, or it may need many independent `RichText` elements, each operating on a subset of the overall block state. - -Because `RichText` allows for nested nodes, you'll most often use it in conjunction with the `html` attribute source when extracting the value from saved content. You'll also use `RichText.Content` in the `save` function to output RichText values. diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/readme.md b/docs/designers-developers/developers/tutorials/block-tutorial/readme.md index 0ff9418d01743a..a3ee5191dbca79 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/readme.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/readme.md @@ -4,4 +4,6 @@ The purpose of this tutorial is to step through the fundamentals of creating a n To follow along with this tutorial, you can [download the accompanying WordPress plugin](https://github.com/WordPress/gutenberg-examples) which includes all of the examples for you to try on your own site. At each step along the way, you should feel free to experiment by modifying the examples with your own ideas and observing the effects they have on the block's behavior. -Code snippets are provided both for "classic" JavaScript (ECMAScript 5, or "ES5"), as well as newer versions of the language standard (ES2015 and newer, or "ESNext"). You can change between them using tabs found above each code example. When choosing to author your blocks with ESNext, you will need a build step in order to support older browsers. Note that it is not required to use ESNext to create a new block, and you are welcome to use classic JavaScript if you so choose. +Code snippets are provided both for "classic" JavaScript (ECMAScript 5, or "ES5"), as well as newer versions of the language standard (ES2015 and newer, or "ESNext"). You can change between them using tabs found above each code example. When choosing to author your blocks with ESNext, you need to run [the JavaScript build step](/docs/designers-developers/developers/tutorials/javascript/js-build-setup/) in order to support older browsers. + +Note that it is not required to use ESNext to create a new block, and you are welcome to use classic JavaScript if you so choose. diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md b/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md index 3dfb4645fcc3d6..c959b4cd5aec58 100644 --- a/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md @@ -10,19 +10,22 @@ While the block's editor behaviors are implemented in JavaScript, you'll need to ```php 'gutenberg-boilerplate-es5-step01', + register_block_type( 'gutenberg-examples/example-01-basic', array( + 'editor_script' => 'gutenberg-examples-01', ) ); + } -add_action( 'init', 'gutenberg_boilerplate_block' ); +add_action( 'init', 'gutenberg_examples_01_register_block' ); ``` Note the two script dependencies: @@ -39,44 +42,58 @@ With the script enqueued, let's look at the implementation of the block itself: {% codetabs %} {% ES5 %} ```js -var el = wp.element.createElement, - registerBlockType = wp.blocks.registerBlockType, - blockStyle = { backgroundColor: '#900', color: '#fff', padding: '20px' }; - -registerBlockType( 'gutenberg-boilerplate-es5/hello-world-step-01', { - title: 'Hello World (Step 1)', - - icon: 'universal-access-alt', - - category: 'layout', - - edit: function() { - return el( 'p', { style: blockStyle }, 'Hello editor.' ); - }, - - save: function() { - return el( 'p', { style: blockStyle }, 'Hello saved content.' ); - }, -} ); +( function( blocks, element ) { + var el = element.createElement; + + var blockStyle = { + backgroundColor: '#900', + color: '#fff', + padding: '20px', + }; + + blocks.registerBlockType( 'gutenberg-examples/example-01-basic', { + title: 'Example: Basic', + icon: 'universal-access-alt', + category: 'layout', + edit: function() { + return el( + 'p', + { style: blockStyle }, + 'Hello World, step 1 (from the editor).' + ); + }, + save: function() { + return el( + 'p', + { style: blockStyle }, + 'Hello World, step 1 (from the frontend).' + ); + }, + } ); +}( + window.wp.blocks, + window.wp.element +) ); ``` {% ESNext %} ```js const { registerBlockType } = wp.blocks; -const blockStyle = { backgroundColor: '#900', color: '#fff', padding: '20px' }; -registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-01', { - title: 'Hello World (Step 1)', +const blockStyle = { + backgroundColor: '#900', + color: '#fff', + padding: '20px', +}; +registerBlockType( 'gutenberg-examples/example-01-basic-esnext', { + title: 'Example: Basic (esnext)', icon: 'universal-access-alt', - category: 'layout', - edit() { - return

Hello editor.

; + return
Basic example with JSX! (editor)
; }, - save() { - return

Hello saved content.

; + return
Basic example with JSX! (front)
; }, } ); ``` @@ -86,6 +103,6 @@ _By now you should be able to see `Hello editor` in the admin side and `Hello sa Once a block is registered, you should immediately see that it becomes available as an option in the editor inserter dialog, using values from `title`, `icon`, and `category` to organize its display. You can choose an icon from any included in the built-in [Dashicons icon set](https://developer.wordpress.org/resource/dashicons/), or provide a [custom svg element](/docs/designers-developers/developers/block-api/block-registration.md#icon-optional). -A block name must be prefixed with a namespace specific to your plugin. This helps prevent conflicts when more than one plugin registers a block with the same name. +A block name must be prefixed with a namespace specific to your plugin. This helps prevent conflicts when more than one plugin registers a block with the same name. In this example, the namespace is `gutenberg-examples`. The `edit` and `save` functions describe the structure of your block in the context of the editor and the saved content respectively. While the difference is not obvious in this simple example, in the following sections we'll explore how these are used to enable customization of the block in the editor preview. diff --git a/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md b/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md index 64f513d605cf99..134c3046ab3149 100644 --- a/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md +++ b/docs/designers-developers/developers/tutorials/javascript/js-build-setup.md @@ -86,7 +86,7 @@ After installing, a `node_modules` directory is created with the modules and the Also, if you look at package.json file it will include a new section: ```json -"dependencies": { +"devDependencies": { "@wordpress/scripts": "3.1.0" } ``` diff --git a/gutenberg.php b/gutenberg.php index 549d2251b17a3f..90b1961840fd19 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -3,7 +3,7 @@ * Plugin Name: Gutenberg * Plugin URI: https://github.com/WordPress/gutenberg * Description: Printing since 1440. This is the development plugin for the new block editor in core. - * Version: 5.3.0 + * Version: 5.4.0-rc.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/client-assets.php b/lib/client-assets.php index 2a97319dd9f1c3..f3ad7c7ee92dfa 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -245,7 +245,9 @@ function gutenberg_register_scripts_and_styles() { ); // TEMPORARY: Core does not (yet) provide persistence migration from the - // introduction of the block editor. + // introduction of the block editor and still calls the data plugins. + // We unset the existing inline scripts first. + $wp_scripts->registered['wp-data']->extra['after'] = array(); wp_add_inline_script( 'wp-data', implode( @@ -254,8 +256,10 @@ function gutenberg_register_scripts_and_styles() { '( function() {', ' var userId = ' . get_current_user_ID() . ';', ' var storageKey = "WP_DATA_USER_" + userId;', + ' wp.data', + ' .use( wp.data.plugins.persistence, { storageKey: storageKey } );', ' wp.data.plugins.persistence.__unstableMigrate( { storageKey: storageKey } );', - '} )()', + '} )();', ) ) ); diff --git a/lib/packages-dependencies.php b/lib/packages-dependencies.php index fe839ee056704c..3bfbecc382c490 100644 --- a/lib/packages-dependencies.php +++ b/lib/packages-dependencies.php @@ -111,6 +111,7 @@ 'wp-data' => array( 'lodash', 'wp-compose', + 'wp-deprecated', 'wp-element', 'wp-is-shallow-equal', 'wp-priority-queue', diff --git a/package-lock.json b/package-lock.json index 5f6113ab8415f8..3d13faa76d4059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "5.3.0", + "version": "5.4.0-rc.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2767,6 +2767,7 @@ "requires": { "@babel/runtime": "^7.3.1", "@wordpress/compose": "file:packages/compose", + "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/element": "file:packages/element", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/priority-queue": "file:packages/priority-queue", @@ -4064,6 +4065,15 @@ } } }, + "babel-plugin-inline-json-import": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/babel-plugin-inline-json-import/-/babel-plugin-inline-json-import-0.3.2.tgz", + "integrity": "sha512-QNNJx08KjmMT25Cw7rAPQ6dlREDPiZGDyApHL8KQ9vrQHbrr4PTi7W8g1tMMZPz0jEMd39nx/eH7xjnDNxq5sA==", + "dev": true, + "requires": { + "decache": "^4.5.1" + } + }, "babel-runtime": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", @@ -4698,6 +4708,12 @@ "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", "dev": true }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=", + "dev": true + }, "callsites": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.0.0.tgz", @@ -7210,6 +7226,15 @@ "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", "dev": true }, + "decache": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/decache/-/decache-4.5.1.tgz", + "integrity": "sha512-5J37nATc6FmOTLbcsr9qx7Nm28qQyg1SK4xyEHqM0IBkNhWFp0Sm+vKoWYHD8wq+OUEb9jLyaKFfzzd1A9hcoA==", + "dev": true, + "requires": { + "callsite": "^1.0.0" + } + }, "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", diff --git a/package.json b/package.json index f63075eaa1525d..ee92bafcfda43d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "5.3.0", + "version": "5.4.0-rc.1", "private": true, "description": "A new WordPress editor experience", "repository": "git+https://github.com/WordPress/gutenberg.git", @@ -77,6 +77,7 @@ "@wordpress/npm-package-json-lint-config": "file:packages/npm-package-json-lint-config", "@wordpress/postcss-themes": "file:packages/postcss-themes", "@wordpress/scripts": "file:packages/scripts", + "babel-plugin-inline-json-import": "0.3.2", "benchmark": "2.1.4", "browserslist": "4.4.1", "chalk": "2.4.1", diff --git a/packages/api-fetch/src/middlewares/preloading.js b/packages/api-fetch/src/middlewares/preloading.js index 020cb913fced15..4de356243b7366 100644 --- a/packages/api-fetch/src/middlewares/preloading.js +++ b/packages/api-fetch/src/middlewares/preloading.js @@ -34,7 +34,11 @@ const createPreloadingMiddleware = ( preloadedData ) => ( options, next ) => { if ( parse && 'GET' === method && preloadedData[ path ] ) { return Promise.resolve( preloadedData[ path ].body ); - } else if ( 'OPTIONS' === method && preloadedData[ method ][ path ] ) { + } else if ( + 'OPTIONS' === method && + preloadedData[ method ] && + preloadedData[ method ][ path ] + ) { return Promise.resolve( preloadedData[ method ][ path ] ); } } diff --git a/packages/api-fetch/src/middlewares/test/preloading.js b/packages/api-fetch/src/middlewares/test/preloading.js index d697af3b429494..e2d8a1f4d0c68d 100644 --- a/packages/api-fetch/src/middlewares/test/preloading.js +++ b/packages/api-fetch/src/middlewares/test/preloading.js @@ -25,20 +25,29 @@ describe( 'Preloading Middleware', () => { } ); } ); - it( 'should move to the next middleware if no preloaded data', () => { - const preloadedData = {}; - const prelooadingMiddleware = createPreloadingMiddleware( preloadedData ); - const requestOptions = { - method: 'GET', - path: 'wp/v2/posts', - }; + describe.each( [ + [ 'GET' ], + [ 'OPTIONS' ], + ] )( '%s', ( method ) => { + describe.each( [ + [ 'all empty', {} ], + [ 'method empty', { [ method ]: {} } ], + ] )( '%s', ( label, preloadedData ) => { + it( 'should move to the next middleware if no preloaded data', () => { + const prelooadingMiddleware = createPreloadingMiddleware( preloadedData ); + const requestOptions = { + method, + path: 'wp/v2/posts', + }; - const callback = ( options ) => { - expect( options ).toBe( requestOptions ); - return true; - }; + const callback = ( options ) => { + expect( options ).toBe( requestOptions ); + return true; + }; - const ret = prelooadingMiddleware( requestOptions, callback ); - expect( ret ).toBe( true ); + const ret = prelooadingMiddleware( requestOptions, callback ); + expect( ret ).toBe( true ); + } ); + } ); } ); } ); diff --git a/packages/block-editor/src/components/block-list/style.scss b/packages/block-editor/src/components/block-list/style.scss index b1566d2ae03caa..03ed0f07bd5de3 100644 --- a/packages/block-editor/src/components/block-list/style.scss +++ b/packages/block-editor/src/components/block-list/style.scss @@ -904,6 +904,12 @@ .block-editor-block-contextual-toolbar > * { pointer-events: auto; } + + // Full-aligned blocks have negative margins on the parent of the toolbar, so additional position adjustment is not required. + &[data-align="full"] .block-editor-block-contextual-toolbar { + left: 0; + right: 0; + } } .block-editor-block-list__block.is-focus-mode:not(.is-multi-selected) > .block-editor-block-contextual-toolbar { diff --git a/packages/block-editor/src/components/media-placeholder/README.md b/packages/block-editor/src/components/media-placeholder/README.md index 2890d2f1f162a1..9dc983ab7a8d8c 100644 --- a/packages/block-editor/src/components/media-placeholder/README.md +++ b/packages/block-editor/src/components/media-placeholder/README.md @@ -27,6 +27,92 @@ const { MediaPlaceholder } = wp.editor; } ``` +## Props + +### accept + +A string passed to `FormFileUpload` that tells the browser which file types can be upload to the upload window the browser use e.g: `image/*,video/*`. +More information about this string is available in https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers. +This property is similar to the `allowedTypes` property. The difference is the format and the fact that this property affects the behavior of `FormFileUpload` while `allowedTypes` affects the behavior `MediaUpload`. + +- Type: `String` +- Required: No + +### addToGallery + +If true, and if `gallery === true` the gallery media modal opens directly in the media library where the user can add additional images. When uploading/selecting files on the placeholder, the placeholder appends the files to the existing files list. +If false the gallery media modal opens in the edit mode where the user can edit existing images, by reordering them, remove them, or change their attributes. When uploading/selecting files on the placeholder the files replace the existing files list. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### allowedTypes + +Array with the types of the media to upload/select from the media library. +Each type is a string that can contain the general mime type e.g: `image`, `audio`, `text`, +or the complete mime type e.g: `audio/mpeg`, `image/gif`. +If allowedTypes is unset all mime types should be allowed. +This property is similar to the `accept` property. The difference is the format and the fact that this property affects the behavior of `MediaUpload` while `accept` affects the behavior `FormFileUpload`. + +- Type: `Array` +- Required: No + +### className + +Class name added to the placeholder. + +- Type: `String` +- Required: No + +### isAppender + +If true, the property changes the look of the placeholder to be adequate to scenarios where new files are added to an already existing set of files, e.g., adding files to a gallery. +If false the default placeholder style is used. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### labels + +An object that can contain a `title` and `instructions` properties. These properties are passed to the placeholder component as `label` and `instructions` respectively. + +- Type: `Object` +- Required: No + + +### multiple + +Whether to allow multiple selection of files or not. + +- Type: `Boolean` +- Required: No +- Default: `false` + +### onError + +Callback called when an upload error happens. + +- Type: `Function` +- Required: No + +### onSelect + +Callback called when the files are selected/uploaded. +The call back receives an array with the new files. Each element of the collection is an object containing the media properties of the file e.g.: `url`, `id`,... + +- Type: `Function` +- Required: Yes + +### value + +Media ID (or media IDs if multiple is true) to be selected by default when opening the media library. + +- Type: `Number|Array` +- Required: No + + ## Extend It includes a `wp.hooks` filter `editor.MediaPlaceholder` that enables developers to replace or extend it. diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index fd788477a0d6d1..f321b19dc1de8d 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -3,9 +3,14 @@ */ import { Component } from '@wordpress/element'; import { DropZoneProvider, SlotFillProvider } from '@wordpress/components'; -import { withDispatch, withRegistry } from '@wordpress/data'; +import { withDispatch } from '@wordpress/data'; import { compose } from '@wordpress/compose'; +/** + * Internal dependencies + */ +import withRegistryProvider from './with-registry-provider'; + class BlockEditorProvider extends Component { componentDidMount() { this.props.updateSettings( this.props.settings ); @@ -115,6 +120,7 @@ class BlockEditorProvider extends Component { } export default compose( [ + withRegistryProvider, withDispatch( ( dispatch ) => { const { updateSettings, @@ -126,5 +132,4 @@ export default compose( [ resetBlocks, }; } ), - withRegistry, ] )( BlockEditorProvider ); diff --git a/packages/block-editor/src/components/provider/with-registry-provider.js b/packages/block-editor/src/components/provider/with-registry-provider.js new file mode 100644 index 00000000000000..cdb3b7fe7b2ded --- /dev/null +++ b/packages/block-editor/src/components/provider/with-registry-provider.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import { withRegistry, createRegistry, RegistryProvider } from '@wordpress/data'; +import { createHigherOrderComponent } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { storeConfig } from '../../store'; +import applyMiddlewares from '../../store/middlewares'; + +const withRegistryProvider = createHigherOrderComponent( ( WrappedComponent ) => { + return withRegistry( ( { useSubRegistry = true, registry, ...props } ) => { + if ( ! useSubRegistry ) { + return ; + } + + const [ subRegistry, setSubRegistry ] = useState( null ); + useEffect( () => { + const newRegistry = createRegistry( {}, registry ); + const store = newRegistry.registerStore( 'core/block-editor', storeConfig ); + // This should be removed after the refactoring of the effects to controls. + applyMiddlewares( store ); + setSubRegistry( newRegistry ); + }, [ registry ] ); + + if ( ! subRegistry ) { + return null; + } + + return ( + + + + ); + } ); +}, 'withRegistryProvider' ); + +export default withRegistryProvider; diff --git a/packages/block-editor/src/store/index.js b/packages/block-editor/src/store/index.js index 0119e63d7a3d16..485238f46f606d 100644 --- a/packages/block-editor/src/store/index.js +++ b/packages/block-editor/src/store/index.js @@ -17,11 +17,15 @@ import controls from './controls'; */ const MODULE_KEY = 'core/block-editor'; -const store = registerStore( MODULE_KEY, { +export const storeConfig = { reducer, selectors, actions, controls, +}; + +const store = registerStore( MODULE_KEY, { + ...storeConfig, persist: [ 'preferences' ], } ); applyMiddlewares( store ); diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 03f02965457099..fb431d1a48d0f6 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -1115,7 +1115,7 @@ export const canInsertBlockType = createSelector( * the number of inserts that have occurred. */ function getInsertUsage( state, id ) { - return state.preferences.insertUsage[ id ] || null; + return get( state.preferences.insertUsage, [ id ], null ); } /** diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index fde8a74fefa91f..3bbe1abd7f3a85 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -1927,9 +1927,11 @@ describe( 'selectors', () => { { id: 1, isTemporary: false, clientId: 'block1', title: 'Reusable Block 1' }, ], }, - preferences: { - insertUsage: {}, - }, + // Intentionally include a test case which considers + // `insertUsage` as not present within preferences. + // + // See: https://github.com/WordPress/gutenberg/issues/14580 + preferences: {}, blockListSettings: {}, }; const items = getInserterItems( state ); diff --git a/packages/block-library/src/columns/style.scss b/packages/block-library/src/columns/style.scss index e377480792af02..2cc0cf0ec4999e 100644 --- a/packages/block-library/src/columns/style.scss +++ b/packages/block-library/src/columns/style.scss @@ -10,6 +10,7 @@ } .wp-block-column { + margin-bottom: 1em; flex-grow: 1; // Responsiveness: Show at most one columns on mobile. @@ -44,14 +45,6 @@ } } -// Specificity overide to ensure margin is applied -// and preserved on last child to ensure that when columns -// are aligned to bottom they are are flush with each other -.wp-block-column, -.entry-content > .wp-block-columns .wp-block-column:last-child { - margin-bottom: 1em; -} - /** * All Columns Alignment */ diff --git a/packages/block-library/src/embed/edit.js b/packages/block-library/src/embed/edit.js index 0e251a8f0aafd0..fcc305d9e8a7c3 100644 --- a/packages/block-library/src/embed/edit.js +++ b/packages/block-library/src/embed/edit.js @@ -61,13 +61,24 @@ export function getEmbedEditComponent( title, icon, responsive = true ) { if ( switchedPreview || switchedURL ) { if ( this.props.cannotEmbed ) { - // Can't embed this URL, and we've just received or switched the preview. + // We either have a new preview or a new URL, but we can't embed it. + if ( ! this.props.fetching ) { + // If we're not fetching the preview, then we know it can't be embedded, so try + // removing any trailing slash, and resubmit. + this.resubmitWithoutTrailingSlash(); + } return; } this.handleIncomingPreview(); } } + resubmitWithoutTrailingSlash() { + this.setState( ( prevState ) => ( { + url: prevState.url.replace( /\/$/, '' ), + } ), this.setUrl ); + } + setUrl( event ) { if ( event ) { event.preventDefault(); diff --git a/packages/block-library/src/embed/util.js b/packages/block-library/src/embed/util.js index 085ea4b22ff8c8..9cba9f50364745 100644 --- a/packages/block-library/src/embed/util.js +++ b/packages/block-library/src/embed/util.js @@ -46,7 +46,7 @@ export const findBlock = ( url ) => { }; export const isFromWordPress = ( html ) => { - return includes( html, 'class="wp-embedded-content" data-secret' ); + return includes( html, 'class="wp-embedded-content"' ); }; export const getPhotoHtml = ( photo ) => { diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index 51d2587591eda3..c6336b03508364 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -28,6 +28,7 @@ import { ToggleControl, Toolbar, withNotices, + ExternalLink, } from '@wordpress/components'; import { compose } from '@wordpress/compose'; import { withSelect } from '@wordpress/data'; @@ -456,7 +457,14 @@ class ImageEdit extends Component { label={ __( 'Alt Text (Alternative Text)' ) } value={ alt } onChange={ this.updateAlt } - help={ __( 'Alternative text describes your image to people who can’t see it. Add a short description with its key details.' ) } + help={ + + + { __( 'Describe the purpose of the image' ) } + + { __( 'Leave empty if the image is purely decorative.' ) } + + } /> { ! isEmpty( imageSizeOptions ) && ( { if ( ! block ) { return; } - const { name, settings } = block; + const { metadata, settings, name } = block; + if ( metadata ) { + unstable__bootstrapServerSideBlockDefinitions( { [ name ]: metadata } ); // eslint-disable-line camelcase + } registerBlockType( name, settings ); } ); diff --git a/packages/block-library/src/media-text/edit.js b/packages/block-library/src/media-text/edit.js index 061d36ccf6c697..ccf77324a8ce88 100644 --- a/packages/block-library/src/media-text/edit.js +++ b/packages/block-library/src/media-text/edit.js @@ -21,6 +21,7 @@ import { TextareaControl, ToggleControl, Toolbar, + ExternalLink, } from '@wordpress/components'; /** * Internal dependencies @@ -170,7 +171,14 @@ class MediaTextEdit extends Component { label={ __( 'Alt Text (Alternative Text)' ) } value={ mediaAlt } onChange={ onMediaAltChange } - help={ __( 'Alternative text describes your image to people who can’t see it. Add a short description with its key details.' ) } + help={ + + + { __( 'Describe the purpose of the image' ) } + + { __( 'Leave empty if the image is purely decorative.' ) } + + } /> ) } ); diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index 5599dafd7c463f..285c139b3b0fa4 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -150,5 +150,4 @@ // By providing a minimum of margin styles, we ensure it doesn't look broken or unstyled in those themes. figcaption { margin-top: 0.5em; - margin-bottom: 1em; } diff --git a/packages/block-library/src/text-columns/block.json b/packages/block-library/src/text-columns/block.json new file mode 100644 index 00000000000000..cf86b62ec572c0 --- /dev/null +++ b/packages/block-library/src/text-columns/block.json @@ -0,0 +1,26 @@ +{ + "name": "core/text-columns", + "icon": "columns", + "category": "layout", + "attributes": { + "content": { + "type": "array", + "source": "query", + "selector": "p", + "query": { + "children": { + "type": "string", + "source": "html" + } + }, + "default": [ {}, {} ] + }, + "columns": { + "type": "number", + "default": 2 + }, + "width": { + "type": "string" + } + } +} diff --git a/packages/block-library/src/text-columns/index.js b/packages/block-library/src/text-columns/index.js index f14b3b8153baa8..6ec2e8b977109a 100644 --- a/packages/block-library/src/text-columns/index.js +++ b/packages/block-library/src/text-columns/index.js @@ -18,7 +18,14 @@ import { } from '@wordpress/block-editor'; import deprecated from '@wordpress/deprecated'; -export const name = 'core/text-columns'; +/** + * Internal dependencies + */ +import metadata from './block.json'; + +const { name } = metadata; + +export { metadata, name }; export const settings = { // Disable insertion as this block is deprecated and ultimately replaced by the Columns block. @@ -30,32 +37,6 @@ export const settings = { description: __( 'This block is deprecated. Please use the Columns block instead.' ), - icon: 'columns', - - category: 'layout', - - attributes: { - content: { - type: 'array', - source: 'query', - selector: 'p', - query: { - children: { - type: 'string', - source: 'html', - }, - }, - default: [ {}, {} ], - }, - columns: { - type: 'number', - default: 2, - }, - width: { - type: 'string', - }, - }, - transforms: { to: [ { diff --git a/packages/block-library/src/verse/index.js b/packages/block-library/src/verse/index.js index 4016915ad6689c..ab5434192877e8 100644 --- a/packages/block-library/src/verse/index.js +++ b/packages/block-library/src/verse/index.js @@ -18,7 +18,7 @@ export const settings = { description: __( 'Insert poetry. Use special spacing formats. Or quote song lyrics.' ), - icon: , + icon: , category: 'formatting', diff --git a/packages/block-library/src/video/edit.js b/packages/block-library/src/video/edit.js index 5b54e8ae0f0a4e..175d95871ecbb4 100644 --- a/packages/block-library/src/video/edit.js +++ b/packages/block-library/src/video/edit.js @@ -219,8 +219,10 @@ class VideoEdit extends Component { + + { __( 'Poster Image' ) } + { expect( block ).toBeUndefined(); } ); + it( 'should reject blocks with invalid save function', () => { + const block = registerBlockType( 'my-plugin/fancy-block-5', { + ...defaultBlockSettings, + save: 'invalid', + } ); + expect( console ).toHaveErroredWith( 'The "save" property must be a valid function.' ); + expect( block ).toBeUndefined(); + } ); + it( 'should reject blocks with an invalid edit function', () => { const blockType = { save: noop, edit: 'not-a-function', category: 'common', title: 'block title' }, block = registerBlockType( 'my-plugin/fancy-block-6', blockType ); @@ -318,12 +327,12 @@ describe( 'blocks', () => { expect( block ).toBeUndefined(); } ); - it( 'should reject valid blocks when they become invalid after executing filter which removes save property', () => { + it( 'should reject blocks which become invalid after executing filter which does not return a plain object', () => { addFilter( 'blocks.registerBlockType', 'core/blocks/without-save', ( settings ) => { - return omit( settings, 'save' ); + return [ settings ]; } ); const block = registerBlockType( 'my-plugin/fancy-block-13', defaultBlockSettings ); - expect( console ).toHaveErroredWith( 'The "save" property must be specified and must be a valid function.' ); + expect( console ).toHaveErroredWith( 'Block settings must be a valid object.' ); expect( block ).toBeUndefined(); } ); } ); diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index c76f5a75ac8d7d..1be0255a96866f 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,8 +1,9 @@ -## 7.2.1 (Unreleased) +## 7.3.0 (Unreleased) ### New Features - Added a new `render` property to `FormFileUpload` component. Allowing users of the component to custom the UI for their needs. +- Added a new `BaseControl.VisualLabel` component. ### Bug fixes diff --git a/packages/components/src/base-control/README.md b/packages/components/src/base-control/README.md index b79ee413e21905..f451a75b6ee4fd 100644 --- a/packages/components/src/base-control/README.md +++ b/packages/components/src/base-control/README.md @@ -45,7 +45,7 @@ If this property is added, a label will be generated using label property as the If this property is added, a help text will be generated using help property as the content. -- Type: `String` +- Type: `String|WPElement` - Required: No ### className @@ -62,3 +62,44 @@ The content to be displayed within the BaseControl. - Type: `Element` - Required: Yes + +## BaseControl.VisualLabel + +`BaseControl.VisualLabel` component is used to render a purely visual label inside a `BaseControl` component. +It should only be used in cases where the children being rendered inside BaseControl are already properly labeled, e.g., a button, but we want an additional visual label for that section equivalent to the labels BaseControl would otherwise use if the label prop was passed. + + +## Usage +```jsx +import { BaseControl } from '@wordpress/components'; + +const MyBaseControl = () => ( + + + Author + + + +); +``` + +### Props + +#### className + +The class that will be added with `components-base-control__label` to the classes of the wrapper div. +If no className is passed only `components-base-control__label` is used. + +- Type: `String` +- Required: No + +#### children + +The content to be displayed within the `BaseControl.VisualLabel`. + +- Type: `Element` +- Required: Yes diff --git a/packages/components/src/base-control/index.js b/packages/components/src/base-control/index.js index 925914b7824154..37a5939e10775c 100644 --- a/packages/components/src/base-control/index.js +++ b/packages/components/src/base-control/index.js @@ -8,7 +8,7 @@ function BaseControl( { id, label, help, className, children } ) {
{ label && id && } - { label && ! id && { label } } + { label && ! id && { label } } { children }
{ !! help &&

{ help }

} @@ -16,4 +16,13 @@ function BaseControl( { id, label, help, className, children } ) { ); } +BaseControl.VisualLabel = ( { className, children } ) => { + className = classnames( 'components-base-control__label', className ); + return ( + + { children } + + ); +}; + export default BaseControl; diff --git a/packages/components/src/checkbox-control/README.md b/packages/components/src/checkbox-control/README.md index 91b50a09b2d970..c011fd2c56fe1f 100644 --- a/packages/components/src/checkbox-control/README.md +++ b/packages/components/src/checkbox-control/README.md @@ -60,7 +60,7 @@ import { withState } from '@wordpress/compose'; const MyCheckboxControl = withState( { isChecked: true, -} )( ( { isChecked, setState } ) => ( +} )( ( { isChecked, setState } ) => ( ( ); ``` -### Props +#### Props The following props are used to control the display of the component. @@ -32,3 +103,7 @@ The following props are used to control the display of the component. * `onRemove`: function called when dismissing the notice * `isDismissible`: (boolean) defaults to true, whether the notice should be dismissible or not * `actions`: (array) an array of action objects. Each member object should contain a `label` and either a `url` link string or `onClick` callback function. A `className` property can be used to add custom classes to the button styles. By default, some classes are used (e.g: is-link or is-default) the default classes can be removed by setting property `noDefaultClasses` to `false`. + +## Related components + +- To create a more prominent message that requires action, use a Modal. diff --git a/packages/components/src/radio-control/README.md b/packages/components/src/radio-control/README.md index 25ccbcafaa88e9..2a55a81718fb15 100644 --- a/packages/components/src/radio-control/README.md +++ b/packages/components/src/radio-control/README.md @@ -62,7 +62,7 @@ import { withState } from '@wordpress/compose'; const MyRadioControl = withState( { option: 'a', -} )( ( { option, setState } ) => ( +} )( ( { option, setState } ) => ( ( +} )( ( { columns, setState } ) => ( diff --git a/packages/components/src/resizable-box/style.scss b/packages/components/src/resizable-box/style.scss index 5032d78dc1efbd..433e169ea9dfdc 100644 --- a/packages/components/src/resizable-box/style.scss +++ b/packages/components/src/resizable-box/style.scss @@ -1,16 +1,14 @@ +// This is a wrapper of the actual visible handle (pseudo element). It is styled +// to be much bigger than the visual part so it's easier to click and use. .components-resizable-box__handle { display: none; + width: $resize-handler-container-size; + height: $resize-handler-container-size; // Show the resize handle when selected. .components-resizable-box__container.is-selected & { display: block; } - - // The handle is a pseudo-element and will sit inside this larger - // container size. - width: $resize-handler-container-size; - height: $resize-handler-container-size; - padding: $grid-size-small; } // This is the "visible" resize handle. @@ -23,21 +21,48 @@ border-radius: 50%; background: theme(primary); cursor: inherit; + position: absolute; + top: calc(50% - #{$resize-handler-size / 2}); + right: calc(50% - #{$resize-handler-size / 2}); +} + +// Show corner handles on top of side handles so they can be used +.components-resizable-box__side-handle { + z-index: z-index(".components-resizable-box__side-handle"); +} + +.components-resizable-box__corner-handle { + z-index: z-index(".components-resizable-box__corner-handle"); +} + +// Make horizontal side-handles full width +.components-resizable-box__side-handle.components-resizable-box__handle-top, +.components-resizable-box__side-handle.components-resizable-box__handle-bottom { + width: 100%; + left: 0; +} + +// Make vertical side-handles full height +.components-resizable-box__side-handle.components-resizable-box__handle-left, +.components-resizable-box__side-handle.components-resizable-box__handle-right { + height: 100%; + top: 0; } /*!rtl:begin:ignore*/ .components-resizable-box__handle-right { - top: calc(50% - #{$resize-handler-container-size / 2}); right: calc(#{$resize-handler-container-size / 2} * -1); } -.components-resizable-box__handle-bottom { - bottom: calc(#{$resize-handler-container-size / 2} * -1); - left: calc(50% - #{$resize-handler-container-size / 2}); -} - .components-resizable-box__handle-left { - top: calc(50% - #{$resize-handler-container-size / 2}); left: calc(#{$resize-handler-container-size / 2} * -1); } + +.components-resizable-box__handle-top { + top: calc(#{$resize-handler-container-size / 2} * -1); +} + +.components-resizable-box__handle-bottom { + bottom: calc(#{$resize-handler-container-size / 2} * -1); +} /*!rtl:end:ignore*/ diff --git a/packages/components/src/select-control/README.md b/packages/components/src/select-control/README.md index 31c8dc5132a481..c7a1008be7c0c9 100644 --- a/packages/components/src/select-control/README.md +++ b/packages/components/src/select-control/README.md @@ -16,7 +16,7 @@ SelectControl allow users to select from a single-option menu. It functions as a #### When to use a select control -Use a select control when: +Use a select control when: - You want users to select a single option from a list. - There is a strong default option. @@ -83,10 +83,10 @@ Render a user interface to select the size of an image. import { SelectControl } from '@wordpress/components'; import { withState } from '@wordpress/compose'; - + const MySelectControl = withState( { size: '50%', - } )( ( { size, setState } ) => ( + } )( ( { size, setState } ) => ( ( - setState( { className } ) } - /> -) ); -``` +![A TextControl without a clear visual marker to indicate the input area](https://make.wordpress.org/design/files/2019/03/TextControl-Dont.png) -## Props +**Don’t** +Don’t use unclear visual markers to indicate a text field. + +## Development guidelines + +### Usage + +Render a user interface to input the name of an additional css class. + + import { TextControl } from '@wordpress/components'; + import { withState } from '@wordpress/compose'; + + const MyTextControl = withState( { + className: '', + } )( ( { className, setState } ) => ( + setState( { className } ) } + /> + ) ); + +### Props The set of props accepted by the component will be specified below. Props not included in this set will be applied to the input element. -### label - +#### label If this property is added, a label will be generated using label property as the content. - Type: `String` - Required: No -### help - +#### help If this property is added, a help text will be generated using help property as the content. - Type: `String` - Required: No -### type - +#### type Type of the input element to render. Defaults to "text". - Type: `String` - Required: No - Default: "text" -### value - +#### value The current value of the input. - Type: `Number` - Required: Yes -### className - +#### className The class that will be added with "components-base-control" to the classes of the wrapper div. If no className is passed only components-base-control is used. - Type: `String` - Required: No -### onChange - +#### onChange A function that receives the value of the input. - Type: `function` - Required: Yes + +## Related components +- To offer users more constrained options for input, use SelectControl, RadioControl, CheckboxControl, or RangeControl. diff --git a/packages/components/src/textarea-control/README.md b/packages/components/src/textarea-control/README.md index 672d13c5b6b1c9..f5cd78d3ad5082 100644 --- a/packages/components/src/textarea-control/README.md +++ b/packages/components/src/textarea-control/README.md @@ -33,7 +33,7 @@ Do not use TextareaControl if you need to let users enter shorter answers (no lo **Do** -Use TextareaControl to let users to enter text longer than a single line. +Use TextareaControl to let users to enter text longer than a single line. ![](https://wordpress.org/gutenberg/files/2019/01/TextareaControl-Answers-Dont.png) @@ -55,11 +55,11 @@ Containers improve the discoverability of text fields by creating contrast betwe ![](https://wordpress.org/gutenberg/files/2019/01/TextareaControl-Stroke-Do.png) **Do** -Use a stroke around the container, which clearly indicates that users can input information. +Use a stroke around the container, which clearly indicates that users can input information. ![](https://wordpress.org/gutenberg/files/2019/01/TextareaControl-Stroke-Dont.png) -**Don’t** +**Don’t** Use unclear visual markers to indicate a text field. ### Label text @@ -78,10 +78,10 @@ When text input isn’t accepted, an error message can display instructions on h import { TextareaControl } from '@wordpress/components'; import { withState } from '@wordpress/compose'; - + const MyTextareaControl = withState( { text: '', - } )( ( { text, setState } ) => ( + } )( ( { text, setState } ) => ( ( +} )( ( { hasFixedBackground, setState } ) => ( setState( ( state ) => ( { hasFixedBackground: ! state.hasFixedBackground } ) ) } /> @@ -37,7 +37,7 @@ If this property is added, a label will be generated using label property as the If this property is added, a help text will be generated using help property as the content. -- Type: `String` | `Function` +- Type: `String|WPElement` - Required: No ### checked @@ -61,4 +61,3 @@ The class that will be added with `components-base-control` and `components-togg Type: String Required: No - diff --git a/packages/data/README.md b/packages/data/README.md index 66bdc2faa5b420..c496d0ef6dbf0a 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -297,6 +297,7 @@ configurations. _Parameters_ - _storeConfigs_ `Object`: Initial store configurations. +- _parent_ `?Object`: Parent registry. _Returns_ diff --git a/packages/data/package.json b/packages/data/package.json index d8fde6251944fb..2f05a8cbb499dc 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -24,6 +24,7 @@ "dependencies": { "@babel/runtime": "^7.3.1", "@wordpress/compose": "file:../compose", + "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/priority-queue": "file:../priority-queue", diff --git a/packages/data/src/namespace-store.js b/packages/data/src/namespace-store/index.js similarity index 64% rename from packages/data/src/namespace-store.js rename to packages/data/src/namespace-store/index.js index 97f1e2eae1b388..8a53d28b300c3c 100644 --- a/packages/data/src/namespace-store.js +++ b/packages/data/src/namespace-store/index.js @@ -7,12 +7,21 @@ import { get, mapValues, } from 'lodash'; +import combineReducers from 'turbo-combine-reducers'; + +/** + * WordPress dependencies + */ +import createReduxRoutineMiddleware from '@wordpress/redux-routine'; /** * Internal dependencies */ -import promise from './promise-middleware'; -import createResolversCacheMiddleware from './resolvers-cache-middleware'; +import promise from '../promise-middleware'; +import createResolversCacheMiddleware from '../resolvers-cache-middleware'; +import metadataReducer from './metadata/reducer'; +import * as metadataSelectors from './metadata/selectors'; +import * as metadataActions from './metadata/actions'; /** * Creates a namespace object with a store derived from the reducer given. @@ -27,16 +36,27 @@ export default function createNamespace( key, options, registry ) { const reducer = options.reducer; const store = createReduxStore( key, options, registry ); - let selectors, actions, resolvers; - if ( options.actions ) { - actions = mapActions( options.actions, store ); - } - if ( options.selectors ) { - selectors = mapSelectors( options.selectors, store, registry ); - } + let resolvers; + const actions = mapActions( { + ...metadataActions, + ...options.actions, + }, store ); + let selectors = mapSelectors( { + ...mapValues( metadataSelectors, ( selector ) => ( state, ...args ) => selector( state.metadata, ...args ) ), + ...mapValues( options.selectors, ( selector ) => { + if ( selector.isRegistrySelector ) { + const mappedSelector = ( reg ) => ( state, ...args ) => { + return selector( reg )( state.root, ...args ); + }; + mappedSelector.isRegistrySelector = selector.isRegistrySelector; + return mappedSelector; + } + + return ( state, ...args ) => selector( state.root, ...args ); + } ), + }, store, registry ); if ( options.resolvers ) { - const fulfillment = getCoreDataFulfillment( registry, key ); - const result = mapResolvers( options.resolvers, selectors, fulfillment, store ); + const result = mapResolvers( options.resolvers, selectors, store ); resolvers = result.resolvers; selectors = result.selectors; } @@ -44,12 +64,18 @@ export default function createNamespace( key, options, registry ) { const getSelectors = () => selectors; const getActions = () => actions; + // We have some modules monkey-patching the store object + // It's wrong to do so but until we refactor all of our effects to controls + // We need to keep the same "store" instance here. + store.__unstableOriginalGetState = store.getState; + store.getState = () => store.__unstableOriginalGetState().root; + // Customize subscribe behavior to call listeners only on effective change, // not on every dispatch. const subscribe = store && function( listener ) { - let lastState = store.getState(); + let lastState = store.__unstableOriginalGetState(); store.subscribe( () => { - const state = store.getState(); + const state = store.__unstableOriginalGetState(); const hasChanged = state !== lastState; lastState = state; @@ -84,15 +110,36 @@ export default function createNamespace( key, options, registry ) { * @return {Object} Newly created redux store. */ function createReduxStore( key, options, registry ) { + const middlewares = [ + createResolversCacheMiddleware( registry, key ), + promise, + ]; + + if ( options.controls ) { + const normalizedControls = mapValues( options.controls, ( control ) => { + return control.isRegistryControl ? control( registry ) : control; + } ); + middlewares.push( createReduxRoutineMiddleware( normalizedControls ) ); + } + const enhancers = [ - applyMiddleware( createResolversCacheMiddleware( registry, key ), promise ), + applyMiddleware( ...middlewares ), ]; if ( typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ ) { enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__( { name: key, instanceId: key } ) ); } const { reducer, initialState } = options; - return createStore( reducer, initialState, flowRight( enhancers ) ); + const enhancedReducer = combineReducers( { + metadata: metadataReducer, + root: reducer, + } ); + + return createStore( + enhancedReducer, + { root: initialState }, + flowRight( enhancers ) + ); } /** @@ -120,7 +167,7 @@ function mapSelectors( selectors, store, registry ) { // direct assignment. const argsLength = arguments.length; const args = new Array( argsLength + 1 ); - args[ 0 ] = store.getState(); + args[ 0 ] = store.__unstableOriginalGetState(); for ( let i = 0; i < argsLength; i++ ) { args[ i + 1 ] = arguments[ i ]; } @@ -151,11 +198,15 @@ function mapActions( actions, store ) { * * @param {Object} resolvers Resolvers to register. * @param {Object} selectors The current selectors to be modified. - * @param {Object} fulfillment Fulfillment implementation functions. * @param {Object} store The redux store to which the resolvers should be mapped. * @return {Object} An object containing updated selectors and resolvers. */ -function mapResolvers( resolvers, selectors, fulfillment, store ) { +function mapResolvers( resolvers, selectors, store ) { + const mappedResolvers = mapValues( resolvers, ( resolver ) => { + const { fulfill: resolverFulfill = resolver } = resolver; + return { ...resolver, fulfill: resolverFulfill }; + } ); + const mapSelector = ( selector, selectorName ) => { const resolver = resolvers[ selectorName ]; if ( ! resolver ) { @@ -169,13 +220,14 @@ function mapResolvers( resolvers, selectors, fulfillment, store ) { return; } - if ( fulfillment.hasStarted( selectorName, args ) ) { + const { metadata } = store.__unstableOriginalGetState(); + if ( metadataSelectors.hasStartedResolution( metadata, selectorName, args ) ) { return; } - fulfillment.start( selectorName, args ); - await fulfillment.fulfill( selectorName, ...args ); - fulfillment.finish( selectorName, args ); + store.dispatch( metadataActions.startResolution( selectorName, args ) ); + await fulfillResolver( store, mappedResolvers, selectorName, ...args ); + store.dispatch( metadataActions.finishResolution( selectorName, args ) ); } fulfillSelector( ...args ); @@ -183,54 +235,28 @@ function mapResolvers( resolvers, selectors, fulfillment, store ) { }; }; - const mappedResolvers = mapValues( resolvers, ( resolver ) => { - const { fulfill: resolverFulfill = resolver } = resolver; - return { ...resolver, fulfill: resolverFulfill }; - } ); - return { resolvers: mappedResolvers, selectors: mapValues( selectors, mapSelector ), }; } -/** - * Bundles up fulfillment functions for resolvers. - * @param {Object} registry Registry reference, for fulfilling via resolvers - * @param {string} key Part of the state shape to register the - * selectors for. - * @return {Object} An object providing fulfillment functions. - */ -function getCoreDataFulfillment( registry, key ) { - const { hasStartedResolution } = registry.select( 'core/data' ); - const { startResolution, finishResolution } = registry.dispatch( 'core/data' ); - - return { - hasStarted: ( ...args ) => hasStartedResolution( key, ...args ), - start: ( ...args ) => startResolution( key, ...args ), - finish: ( ...args ) => finishResolution( key, ...args ), - fulfill: ( ...args ) => fulfillWithRegistry( registry, key, ...args ), - }; -} - /** * Calls a resolver given arguments * - * @param {Object} registry Registry reference, for fulfilling via resolvers - * @param {string} key Part of the state shape to register the - * selectors for. + * @param {Object} store Store reference, for fulfilling via resolvers + * @param {Object} resolvers Store Resolvers * @param {string} selectorName Selector name to fulfill. - * @param {Array} args Selector Arguments. + * @param {Array} args Selector Arguments. */ -async function fulfillWithRegistry( registry, key, selectorName, ...args ) { - const namespace = registry.stores[ key ]; - const resolver = get( namespace, [ 'resolvers', selectorName ] ); +async function fulfillResolver( store, resolvers, selectorName, ...args ) { + const resolver = get( resolvers, [ selectorName ] ); if ( ! resolver ) { return; } const action = resolver.fulfill( ...args ); if ( action ) { - await namespace.store.dispatch( action ); + await store.dispatch( action ); } } diff --git a/packages/data/src/store/actions.js b/packages/data/src/namespace-store/metadata/actions.js similarity index 65% rename from packages/data/src/store/actions.js rename to packages/data/src/namespace-store/metadata/actions.js index b7bd9aa805738f..528cce18cd39f1 100644 --- a/packages/data/src/store/actions.js +++ b/packages/data/src/namespace-store/metadata/actions.js @@ -2,16 +2,14 @@ * Returns an action object used in signalling that selector resolution has * started. * - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Name of selector for which resolver triggered. * @param {...*} args Arguments to associate for uniqueness. * * @return {Object} Action object. */ -export function startResolution( reducerKey, selectorName, args ) { +export function startResolution( selectorName, args ) { return { type: 'START_RESOLUTION', - reducerKey, selectorName, args, }; @@ -21,16 +19,14 @@ export function startResolution( reducerKey, selectorName, args ) { * Returns an action object used in signalling that selector resolution has * completed. * - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Name of selector for which resolver triggered. * @param {...*} args Arguments to associate for uniqueness. * * @return {Object} Action object. */ -export function finishResolution( reducerKey, selectorName, args ) { +export function finishResolution( selectorName, args ) { return { type: 'FINISH_RESOLUTION', - reducerKey, selectorName, args, }; @@ -39,53 +35,43 @@ export function finishResolution( reducerKey, selectorName, args ) { /** * Returns an action object used in signalling that we should invalidate the resolution cache. * - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Name of selector for which resolver should be invalidated. * @param {Array} args Arguments to associate for uniqueness. * * @return {Object} Action object. */ -export function invalidateResolution( reducerKey, selectorName, args ) { +export function invalidateResolution( selectorName, args ) { return { type: 'INVALIDATE_RESOLUTION', - reducerKey, selectorName, args, }; } /** - * Returns an action object used in signalling that the resolution cache for a - * given reducerKey should be invalidated. - * - * @param {string} reducerKey Registered store reducer key. + * Returns an action object used in signalling that the resolution + * should be invalidated. * * @return {Object} Action object. */ -export function invalidateResolutionForStore( reducerKey ) { +export function invalidateResolutionForStore() { return { type: 'INVALIDATE_RESOLUTION_FOR_STORE', - reducerKey, }; } /** * Returns an action object used in signalling that the resolution cache for a - * given reducerKey and selectorName should be invalidated. + * given selectorName should be invalidated. * - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Name of selector for which all resolvers should * be invalidated. * * @return {Object} Action object. */ -export function invalidateResolutionForStoreSelector( - reducerKey, - selectorName -) { +export function invalidateResolutionForStoreSelector( selectorName ) { return { type: 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR', - reducerKey, selectorName, }; } diff --git a/packages/data/src/store/reducer.js b/packages/data/src/namespace-store/metadata/reducer.js similarity index 77% rename from packages/data/src/store/reducer.js rename to packages/data/src/namespace-store/metadata/reducer.js index cd043f753f833b..46ddc183563ae0 100644 --- a/packages/data/src/store/reducer.js +++ b/packages/data/src/namespace-store/metadata/reducer.js @@ -13,7 +13,7 @@ import { onSubKey } from './utils'; * Reducer function returning next state for selector resolution of * subkeys, object form: * - * reducerKey -> selectorName -> EquivalentKeyMap + * selectorName -> EquivalentKeyMap * * @param {Object} state Current state. * @param {Object} action Dispatched action. @@ -21,7 +21,6 @@ import { onSubKey } from './utils'; * @returns {Object} Next state. */ const subKeysIsResolved = flowRight( [ - onSubKey( 'reducerKey' ), onSubKey( 'selectorName' ), ] )( ( state = new EquivalentKeyMap(), action ) => { switch ( action.type ) { @@ -44,7 +43,7 @@ const subKeysIsResolved = flowRight( [ /** * Reducer function returning next state for selector resolution, object form: * - * reducerKey -> selectorName -> EquivalentKeyMap + * selectorName -> EquivalentKeyMap * * @param {Object} state Current state. * @param {Object} action Dispatched action. @@ -54,18 +53,10 @@ const subKeysIsResolved = flowRight( [ const isResolved = ( state = {}, action ) => { switch ( action.type ) { case 'INVALIDATE_RESOLUTION_FOR_STORE': - return has( state, action.reducerKey ) ? - omit( state, [ action.reducerKey ] ) : - state; + return {}; case 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR': - return has( state, [ action.reducerKey, action.selectorName ] ) ? - { - ...state, - [ action.reducerKey ]: omit( - state[ action.reducerKey ], - [ action.selectorName ] - ), - } : + return has( state, [ action.selectorName ] ) ? + omit( state, [ action.selectorName ] ) : state; case 'START_RESOLUTION': case 'FINISH_RESOLUTION': diff --git a/packages/data/src/store/selectors.js b/packages/data/src/namespace-store/metadata/selectors.js similarity index 54% rename from packages/data/src/store/selectors.js rename to packages/data/src/namespace-store/metadata/selectors.js index b17594e6705a5d..34217edcedd10f 100644 --- a/packages/data/src/store/selectors.js +++ b/packages/data/src/namespace-store/metadata/selectors.js @@ -4,20 +4,19 @@ import { get } from 'lodash'; /** - * Returns the raw `isResolving` value for a given reducer key, selector name, + * Returns the raw `isResolving` value for a given selector name, * and arguments set. May be undefined if the selector has never been resolved * or not resolved for the given set of arguments, otherwise true or false for * resolution started and completed respectively. * * @param {Object} state Data state. - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Selector name. * @param {Array} args Arguments passed to selector. * * @return {?boolean} isResolving value. */ -export function getIsResolving( state, reducerKey, selectorName, args ) { - const map = get( state, [ reducerKey, selectorName ] ); +export function getIsResolving( state, selectorName, args ) { + const map = get( state, [ selectorName ] ); if ( ! map ) { return; } @@ -26,58 +25,54 @@ export function getIsResolving( state, reducerKey, selectorName, args ) { } /** - * Returns true if resolution has already been triggered for a given reducer - * key, selector name, and arguments set. + * Returns true if resolution has already been triggered for a given + * selector name, and arguments set. * * @param {Object} state Data state. - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Selector name. * @param {?Array} args Arguments passed to selector (default `[]`). * * @return {boolean} Whether resolution has been triggered. */ -export function hasStartedResolution( state, reducerKey, selectorName, args = [] ) { - return getIsResolving( state, reducerKey, selectorName, args ) !== undefined; +export function hasStartedResolution( state, selectorName, args = [] ) { + return getIsResolving( state, selectorName, args ) !== undefined; } /** - * Returns true if resolution has completed for a given reducer key, selector + * Returns true if resolution has completed for a given selector * name, and arguments set. * * @param {Object} state Data state. - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Selector name. * @param {?Array} args Arguments passed to selector. * * @return {boolean} Whether resolution has completed. */ -export function hasFinishedResolution( state, reducerKey, selectorName, args = [] ) { - return getIsResolving( state, reducerKey, selectorName, args ) === false; +export function hasFinishedResolution( state, selectorName, args = [] ) { + return getIsResolving( state, selectorName, args ) === false; } /** * Returns true if resolution has been triggered but has not yet completed for - * a given reducer key, selector name, and arguments set. + * a given selector name, and arguments set. * * @param {Object} state Data state. - * @param {string} reducerKey Registered store reducer key. * @param {string} selectorName Selector name. * @param {?Array} args Arguments passed to selector. * * @return {boolean} Whether resolution is in progress. */ -export function isResolving( state, reducerKey, selectorName, args = [] ) { - return getIsResolving( state, reducerKey, selectorName, args ) === true; +export function isResolving( state, selectorName, args = [] ) { + return getIsResolving( state, selectorName, args ) === true; } /** * Returns the list of the cached resolvers. * * @param {Object} state Data state. - * @param {string} reducerKey Registered store reducer key. * * @return {Object} Resolvers mapped by args and selectorName. */ -export function getCachedResolvers( state, reducerKey ) { - return state.hasOwnProperty( reducerKey ) ? state[ reducerKey ] : {}; +export function getCachedResolvers( state ) { + return state; } diff --git a/packages/data/src/store/test/reducer.js b/packages/data/src/namespace-store/metadata/test/reducer.js similarity index 66% rename from packages/data/src/store/test/reducer.js rename to packages/data/src/namespace-store/metadata/test/reducer.js index eab156e89a71e4..d1af3f7804ea9c 100644 --- a/packages/data/src/store/test/reducer.js +++ b/packages/data/src/namespace-store/metadata/test/reducer.js @@ -18,128 +18,106 @@ describe( 'reducer', () => { it( 'should return with started resolution', () => { const state = reducer( undefined, { type: 'START_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [], } ); // { test: { getFoo: EquivalentKeyMap( [] => true ) } } - expect( state.test.getFoo.get( [] ) ).toBe( true ); + expect( state.getFoo.get( [] ) ).toBe( true ); } ); it( 'should return with finished resolution', () => { const original = reducer( undefined, { type: 'START_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [], } ); const state = reducer( deepFreeze( original ), { type: 'FINISH_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [], } ); // { test: { getFoo: EquivalentKeyMap( [] => false ) } } - expect( state.test.getFoo.get( [] ) ).toBe( false ); + expect( state.getFoo.get( [] ) ).toBe( false ); } ); it( 'should remove invalidations', () => { let state = reducer( undefined, { type: 'START_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [], } ); state = reducer( deepFreeze( state ), { type: 'FINISH_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [], } ); state = reducer( deepFreeze( state ), { type: 'INVALIDATE_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [], } ); - // { test: { getFoo: EquivalentKeyMap( [] => undefined ) } } - expect( state.test.getFoo.get( [] ) ).toBe( undefined ); + // { getFoo: EquivalentKeyMap( [] => undefined ) } + expect( state.getFoo.get( [] ) ).toBe( undefined ); } ); it( 'different arguments should not conflict', () => { const original = reducer( undefined, { type: 'START_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [ 'post' ], } ); let state = reducer( deepFreeze( original ), { type: 'FINISH_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [ 'post' ], } ); state = reducer( deepFreeze( state ), { type: 'START_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [ 'block' ], } ); - // { test: { getFoo: EquivalentKeyMap( [] => false ) } } - expect( state.test.getFoo.get( [ 'post' ] ) ).toBe( false ); - expect( state.test.getFoo.get( [ 'block' ] ) ).toBe( true ); + // { getFoo: EquivalentKeyMap( [] => false ) } + expect( state.getFoo.get( [ 'post' ] ) ).toBe( false ); + expect( state.getFoo.get( [ 'block' ] ) ).toBe( true ); } ); it( 'should remove invalidation for store level and leave others ' + 'intact', () => { const original = reducer( undefined, { type: 'FINISH_RESOLUTION', - reducerKey: 'testA', selectorName: 'getFoo', args: [ 'post' ], } ); - let state = reducer( deepFreeze( original ), { - type: 'FINISH_RESOLUTION', - reducerKey: 'testB', - selectorName: 'getBar', - args: [ 'postBar' ], - } ); - state = reducer( deepFreeze( state ), { + const state = reducer( deepFreeze( original ), { type: 'INVALIDATE_RESOLUTION_FOR_STORE', - reducerKey: 'testA', } ); - expect( state.testA ).toBeUndefined(); - // { testB: { getBar: EquivalentKeyMap( [] => false ) } } - expect( state.testB.getBar.get( [ 'postBar' ] ) ).toBe( false ); + expect( state ).toEqual( {} ); } ); it( 'should remove invalidation for store and selector name level and ' + 'leave other selectors at store level intact', () => { const original = reducer( undefined, { type: 'FINISH_RESOLUTION', - reducerKey: 'test', selectorName: 'getFoo', args: [ 'post' ], } ); let state = reducer( deepFreeze( original ), { type: 'FINISH_RESOLUTION', - reducerKey: 'test', selectorName: 'getBar', args: [ 'postBar' ], } ); state = reducer( deepFreeze( state ), { type: 'INVALIDATE_RESOLUTION_FOR_STORE_SELECTOR', - reducerKey: 'test', selectorName: 'getBar', } ); - expect( state.test.getBar ).toBeUndefined(); - // { test: { getFoo: EquivalentKeyMap( [] => false ) } } - expect( state.test.getFoo.get( [ 'post' ] ) ).toBe( false ); + expect( state.getBar ).toBeUndefined(); + // { getFoo: EquivalentKeyMap( [] => false ) } + expect( state.getFoo.get( [ 'post' ] ) ).toBe( false ); } ); } ); diff --git a/packages/data/src/store/test/selectors.js b/packages/data/src/namespace-store/metadata/test/selectors.js similarity index 58% rename from packages/data/src/store/test/selectors.js rename to packages/data/src/namespace-store/metadata/test/selectors.js index 1af4bf9b8adf19..916d3beb436312 100644 --- a/packages/data/src/store/test/selectors.js +++ b/packages/data/src/namespace-store/metadata/test/selectors.js @@ -16,29 +16,25 @@ import { describe( 'getIsResolving', () => { it( 'should return undefined if no state by reducerKey, selectorName', () => { const state = {}; - const result = getIsResolving( state, 'test', 'getFoo', [] ); + const result = getIsResolving( state, 'getFoo', [] ); expect( result ).toBe( undefined ); } ); it( 'should return undefined if state by reducerKey, selectorName, but not args', () => { const state = { - test: { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }, + getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), }; - const result = getIsResolving( state, 'test', 'getFoo', [ 'bar' ] ); + const result = getIsResolving( state, 'getFoo', [ 'bar' ] ); expect( result ).toBe( undefined ); } ); it( 'should return value by reducerKey, selectorName', () => { const state = { - test: { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }, + getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), }; - const result = getIsResolving( state, 'test', 'getFoo', [] ); + const result = getIsResolving( state, 'getFoo', [] ); expect( result ).toBe( true ); } ); @@ -47,18 +43,16 @@ describe( 'getIsResolving', () => { describe( 'hasStartedResolution', () => { it( 'returns false if not has started', () => { const state = {}; - const result = hasStartedResolution( state, 'test', 'getFoo', [] ); + const result = hasStartedResolution( state, 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns true if has started', () => { const state = { - test: { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }, + getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), }; - const result = hasStartedResolution( state, 'test', 'getFoo', [] ); + const result = hasStartedResolution( state, 'getFoo', [] ); expect( result ).toBe( true ); } ); @@ -67,22 +61,18 @@ describe( 'hasStartedResolution', () => { describe( 'hasFinishedResolution', () => { it( 'returns false if not has finished', () => { const state = { - test: { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }, + getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), }; - const result = hasFinishedResolution( state, 'test', 'getFoo', [] ); + const result = hasFinishedResolution( state, 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns true if has finished', () => { const state = { - test: { - getFoo: new EquivalentKeyMap( [ [ [], false ] ] ), - }, + getFoo: new EquivalentKeyMap( [ [ [], false ] ] ), }; - const result = hasFinishedResolution( state, 'test', 'getFoo', [] ); + const result = hasFinishedResolution( state, 'getFoo', [] ); expect( result ).toBe( true ); } ); @@ -91,29 +81,25 @@ describe( 'hasFinishedResolution', () => { describe( 'isResolving', () => { it( 'returns false if not has started', () => { const state = {}; - const result = isResolving( state, 'test', 'getFoo', [] ); + const result = isResolving( state, 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns false if has finished', () => { const state = { - test: { - getFoo: new EquivalentKeyMap( [ [ [], false ] ] ), - }, + getFoo: new EquivalentKeyMap( [ [ [], false ] ] ), }; - const result = isResolving( state, 'test', 'getFoo', [] ); + const result = isResolving( state, 'getFoo', [] ); expect( result ).toBe( false ); } ); it( 'returns true if has started but not finished', () => { const state = { - test: { - getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), - }, + getFoo: new EquivalentKeyMap( [ [ [], true ] ] ), }; - const result = isResolving( state, 'test', 'getFoo', [] ); + const result = isResolving( state, 'getFoo', [] ); expect( result ).toBe( true ); } ); diff --git a/packages/data/src/store/test/utils.js b/packages/data/src/namespace-store/metadata/test/utils.js similarity index 100% rename from packages/data/src/store/test/utils.js rename to packages/data/src/namespace-store/metadata/test/utils.js diff --git a/packages/data/src/store/utils.js b/packages/data/src/namespace-store/metadata/utils.js similarity index 100% rename from packages/data/src/store/utils.js rename to packages/data/src/namespace-store/metadata/utils.js diff --git a/packages/data/src/plugins/controls/test/index.js b/packages/data/src/namespace-store/test/index.js similarity index 83% rename from packages/data/src/plugins/controls/test/index.js rename to packages/data/src/namespace-store/test/index.js index d60cb9053211d9..eba9e7e6697fc5 100644 --- a/packages/data/src/plugins/controls/test/index.js +++ b/packages/data/src/namespace-store/test/index.js @@ -1,16 +1,14 @@ /** * Internal dependencies */ -import { createRegistry } from '../../../registry'; -import { createRegistryControl } from '../../../factory'; -import controlsPlugin from '../'; +import { createRegistry } from '../../registry'; +import { createRegistryControl } from '../../factory'; describe( 'controls', () => { let registry; beforeEach( () => { registry = createRegistry(); - registry.use( controlsPlugin ); } ); describe( 'should call registry-aware controls', () => { diff --git a/packages/data/src/plugins/controls/index.js b/packages/data/src/plugins/controls/index.js index a023dd6916b107..f5dd906dde60e7 100644 --- a/packages/data/src/plugins/controls/index.js +++ b/packages/data/src/plugins/controls/index.js @@ -1,36 +1,11 @@ -/** - * External dependencies - */ -import { applyMiddleware } from 'redux'; -import { mapValues } from 'lodash'; - /** * WordPress dependencies */ -import createMiddleware from '@wordpress/redux-routine'; +import deprecated from '@wordpress/deprecated'; export default function( registry ) { - return { - registerStore( reducerKey, options ) { - const store = registry.registerStore( reducerKey, options ); - - if ( options.controls ) { - const normalizedControls = mapValues( options.controls, ( control ) => { - return control.isRegistryControl ? control( registry ) : control; - } ); - const middleware = createMiddleware( normalizedControls ); - const enhancer = applyMiddleware( middleware ); - const createStore = () => store; - - Object.assign( - store, - enhancer( createStore )( options.reducer ) - ); - - registry.namespaces[ reducerKey ].supportControls = true; - } - - return store; - }, - }; + deprecated( 'wp.data.plugins.controls', { + hint: 'The controls plugins is now baked-in.', + } ); + return registry; } diff --git a/packages/data/src/plugins/persistence/index.js b/packages/data/src/plugins/persistence/index.js index 5ce7f41a37c2fc..a21fcb9b442fe2 100644 --- a/packages/data/src/plugins/persistence/index.js +++ b/packages/data/src/plugins/persistence/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { flow, merge, isPlainObject, omit } from 'lodash'; +import { merge, isPlainObject, get } from 'lodash'; /** * Internal dependencies @@ -72,7 +72,7 @@ export function createPersistenceInterface( options ) { * * @return {Object} Persisted data. */ - function get() { + function getData() { if ( data === undefined ) { // If unset, getItem is expected to return null. Fall back to // empty object. @@ -99,12 +99,15 @@ export function createPersistenceInterface( options ) { * @param {string} key Key to update. * @param {*} value Updated value. */ - function set( key, value ) { + function setData( key, value ) { data = { ...data, [ key ]: value }; storage.setItem( storageKey, JSON.stringify( data ) ); } - return { get, set }; + return { + get: getData, + set: setData, + }; } /** @@ -147,14 +150,12 @@ const persistencePlugin = function( registry, pluginOptions ) { let lastState = getPersistedState( undefined, { nextState: getState() } ); - return ( result ) => { + return () => { const state = getPersistedState( lastState, { nextState: getState() } ); if ( state !== lastState ) { persistence.set( reducerKey, state ); lastState = state; } - - return result; }; } @@ -184,19 +185,19 @@ const persistencePlugin = function( registry, pluginOptions ) { initialState = persistedState; } - options = { ...options, initialState }; + options = { + ...options, + initialState, + }; } const store = registry.registerStore( reducerKey, options ); - store.dispatch = flow( [ - store.dispatch, - createPersistOnChange( - store.getState, - reducerKey, - options.persist - ), - ] ); + store.subscribe( createPersistOnChange( + store.getState, + reducerKey, + options.persist + ) ); return store; }, @@ -211,20 +212,18 @@ persistencePlugin.__unstableMigrate = ( pluginOptions ) => { const persistence = createPersistenceInterface( pluginOptions ); // Preferences migration to introduce the block editor module - const persistedState = persistence.get(); - const coreEditorState = persistedState[ 'core/editor' ]; - if ( coreEditorState && coreEditorState.preferences && coreEditorState.preferences.insertUsage ) { - const blockEditorState = { + const insertUsage = get( persistence.get(), [ + 'core/editor', + 'preferences', + 'insertUsage', + ] ); + + if ( insertUsage ) { + persistence.set( 'core/block-editor', { preferences: { - insertUsage: coreEditorState.preferences.insertUsage, + insertUsage, }, - }; - - persistence.set( 'core/editor', { - ...coreEditorState, - preferences: omit( coreEditorState.preferences, [ 'insertUsage' ] ), } ); - persistence.set( 'core/block-editor', blockEditorState ); } }; diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index acca73b93129a3..15f02180c7b859 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -9,8 +9,8 @@ import { /** * Internal dependencies */ -import createNamespace from './namespace-store.js'; -import dataStore from './store'; +import createNamespace from './namespace-store'; +import createCoreDataStore from './store'; /** * An isolated orchestrator of store registrations. @@ -34,11 +34,12 @@ import dataStore from './store'; * Creates a new store registry, given an optional object of initial store * configurations. * - * @param {Object} storeConfigs Initial store configurations. + * @param {Object} storeConfigs Initial store configurations. + * @param {Object?} parent Parent registry. * * @return {WPDataRegistry} Data registry. */ -export function createRegistry( storeConfigs = {} ) { +export function createRegistry( storeConfigs = {}, parent = null ) { const stores = {}; let listeners = []; @@ -74,7 +75,11 @@ export function createRegistry( storeConfigs = {} ) { */ function select( reducerKey ) { const store = stores[ reducerKey ]; - return store && store.getSelectors(); + if ( store ) { + return store.getSelectors(); + } + + return parent && parent.select( reducerKey ); } /** @@ -87,7 +92,11 @@ export function createRegistry( storeConfigs = {} ) { */ function dispatch( reducerKey ) { const store = stores[ reducerKey ]; - return store && store.getActions(); + if ( store ) { + return store.getActions(); + } + + return parent && parent.dispatch( reducerKey ); } // @@ -166,10 +175,15 @@ export function createRegistry( storeConfigs = {} ) { return registry; } - Object.entries( { - 'core/data': dataStore, - ...storeConfigs, - } ).map( ( [ name, config ] ) => registry.registerStore( name, config ) ); + registerGenericStore( 'core/data', createCoreDataStore( registry ) ); + + Object.entries( storeConfigs ).forEach( + ( [ name, config ] ) => registry.registerStore( name, config ) + ); + + if ( parent ) { + parent.subscribe( globalListener ); + } return withPlugins( registry ); } diff --git a/packages/data/src/resolvers-cache-middleware.js b/packages/data/src/resolvers-cache-middleware.js index 0bc57390795669..7477b36d805b59 100644 --- a/packages/data/src/resolvers-cache-middleware.js +++ b/packages/data/src/resolvers-cache-middleware.js @@ -14,7 +14,7 @@ import { get } from 'lodash'; const createResolversCacheMiddleware = ( registry, reducerKey ) => () => ( next ) => ( action ) => { const resolvers = registry.select( 'core/data' ).getCachedResolvers( reducerKey ); Object.entries( resolvers ).forEach( ( [ selectorName, resolversByArgs ] ) => { - const resolver = get( registry.namespaces, [ reducerKey, 'resolvers', selectorName ] ); + const resolver = get( registry.stores, [ reducerKey, 'resolvers', selectorName ] ); if ( ! resolver || ! resolver.shouldInvalidate ) { return; } diff --git a/packages/data/src/store/index.js b/packages/data/src/store/index.js index 67ff3b67220856..9a14aac264508e 100644 --- a/packages/data/src/store/index.js +++ b/packages/data/src/store/index.js @@ -1,12 +1,48 @@ -/** - * Internal dependencies - */ -import reducer from './reducer'; -import * as selectors from './selectors'; -import * as actions from './actions'; - -export default { - reducer, - actions, - selectors, -}; + +function createCoreDataStore( registry ) { + const getCoreDataSelector = ( selectorName ) => ( reducerKey, ...args ) => { + return registry.select( reducerKey )[ selectorName ]( ...args ); + }; + + const getCoreDataAction = ( actionName ) => ( reducerKey, ...args ) => { + return registry.dispatch( reducerKey )[ actionName ]( ...args ); + }; + + return { + getSelectors() { + return [ + 'getIsResolving', + 'hasStartedResolution', + 'hasFinishedResolution', + 'isResolving', + 'getCachedResolvers', + ].reduce( ( memo, selectorName ) => ( { + ...memo, + [ selectorName ]: getCoreDataSelector( selectorName ), + } ), {} ); + }, + + getActions() { + return [ + 'startResolution', + 'finishResolution', + 'invalidateResolution', + 'invalidateResolutionForStore', + 'invalidateResolutionForStoreSelector', + ].reduce( ( memo, actionName ) => ( { + ...memo, + [ actionName ]: getCoreDataAction( actionName ), + } ), {} ); + }, + + subscribe() { + // There's no reasons to trigger any listener when we subscribe to this store + // because there's no state stored in this store that need to retrigger selectors + // if a change happens, the corresponding store where the tracking stated live + // would have already triggered a "subscribe" call. + return () => {}; + }, + }; +} + +export default createCoreDataStore; diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js index cd18ac2d892854..b828cd01991596 100644 --- a/packages/data/src/test/registry.js +++ b/packages/data/src/test/registry.js @@ -604,4 +604,52 @@ describe( 'createRegistry', () => { expect( registry.select() ).toBe( 10 ); } ); } ); + + describe( 'parent registry', () => { + it( 'should call parent registry selectors/actions if defined', () => { + const mySelector = jest.fn(); + const myAction = jest.fn(); + const getSelectors = () => ( { mySelector } ); + const getActions = () => ( { myAction } ); + const subscribe = () => {}; + registry.registerGenericStore( 'store', { getSelectors, getActions, subscribe } ); + const subRegistry = createRegistry( {}, registry ); + + subRegistry.select( 'store' ).mySelector(); + subRegistry.dispatch( 'store' ).myAction(); + + expect( mySelector ).toHaveBeenCalled(); + expect( myAction ).toHaveBeenCalled(); + } ); + + it( 'should override existing store in parent registry', () => { + const mySelector = jest.fn(); + const myAction = jest.fn(); + const getSelectors = () => ( { mySelector } ); + const getActions = () => ( { myAction } ); + const subscribe = () => {}; + registry.registerGenericStore( 'store', { getSelectors, getActions, subscribe } ); + + const subRegistry = createRegistry( {}, registry ); + const mySelector2 = jest.fn(); + const myAction2 = jest.fn(); + const getSelectors2 = () => ( { mySelector: mySelector2 } ); + const getActions2 = () => ( { myAction: myAction2 } ); + const subscribe2 = () => {}; + subRegistry.registerGenericStore( 'store', { + getSelectors: getSelectors2, + getActions: getActions2, + subscribe: subscribe2, + } ); + + subRegistry.select( 'store' ).mySelector(); + subRegistry.dispatch( 'store' ).myAction(); + + expect( mySelector ).not.toHaveBeenCalled(); + expect( myAction ).not.toHaveBeenCalled(); + + expect( mySelector2 ).toHaveBeenCalled(); + expect( myAction2 ).toHaveBeenCalled(); + } ); + } ); } ); diff --git a/packages/dom/README.md b/packages/dom/README.md index 8a2186890d5e49..1212dea1f4f5d5 100644 --- a/packages/dom/README.md +++ b/packages/dom/README.md @@ -148,7 +148,7 @@ _Parameters_ _Returns_ -- `boolean`: True if at the edge, false if not. +- `boolean`: True if at the vertical edge, false if not. # **placeCaretAtHorizontalEdge** diff --git a/packages/dom/src/dom.js b/packages/dom/src/dom.js index b3b0f7b6df89da..87be698a3be8d0 100644 --- a/packages/dom/src/dom.js +++ b/packages/dom/src/dom.js @@ -60,14 +60,17 @@ function isSelectionForward( selection ) { } /** - * Check whether the selection is horizontally at the edge of the container. + * Check whether the selection is at the edge of the container. Checks for + * horizontal position by default. Set `onlyVertical` to true to check only + * vertically. * - * @param {Element} container Focusable element. - * @param {boolean} isReverse Set to true to check left, false for right. + * @param {Element} container Focusable element. + * @param {boolean} isReverse Set to true to check left, false to check right. + * @param {boolean} onlyVertical Set to true to check only vertical position. * - * @return {boolean} True if at the horizontal edge, false if not. + * @return {boolean} True if at the edge, false if not. */ -export function isHorizontalEdge( container, isReverse ) { +function isEdge( container, isReverse, onlyVertical ) { if ( includes( [ 'INPUT', 'TEXTAREA' ], container.tagName ) ) { if ( container.selectionStart !== container.selectionEnd ) { return false; @@ -86,105 +89,16 @@ export function isHorizontalEdge( container, isReverse ) { const selection = window.getSelection(); - // Create copy of range for setting selection to find effective offset. - const range = selection.getRangeAt( 0 ).cloneRange(); - - // Collapse in direction of selection. - if ( ! selection.isCollapsed ) { - range.collapse( ! isSelectionForward( selection ) ); - } - - let node = range.startContainer; - - let extentOffset; - if ( isReverse ) { - // When in reverse, range node should be first. - extentOffset = 0; - } else if ( node.nodeValue ) { - // Otherwise, vary by node type. A text node has no children. Its range - // offset reflects its position in nodeValue. - // - // "If the startContainer is a Node of type Text, Comment, or - // CDATASection, then the offset is the number of characters from the - // start of the startContainer to the boundary point of the Range." - // - // See: https://developer.mozilla.org/en-US/docs/Web/API/Range/startOffset - // See: https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeValue - extentOffset = node.nodeValue.length; - } else { - // "For other Node types, the startOffset is the number of child nodes - // between the start of the startContainer and the boundary point of - // the Range." - // - // See: https://developer.mozilla.org/en-US/docs/Web/API/Range/startOffset - extentOffset = node.childNodes.length; - } - - // Offset of range should be at expected extent. - const position = isReverse ? 'start' : 'end'; - const offset = range[ `${ position }Offset` ]; - if ( offset !== extentOffset ) { + if ( ! selection.rangeCount ) { return false; } - // If confirmed to be at extent, traverse up through DOM, verifying that - // the node is at first or last child for reverse or forward respectively - // (ignoring empty text nodes). Continue until container is reached. - const order = isReverse ? 'previous' : 'next'; - - while ( node !== container ) { - let next = node[ `${ order }Sibling` ]; - - // Skip over empty text nodes. - while ( next && next.nodeType === TEXT_NODE && next.data === '' ) { - next = next[ `${ order }Sibling` ]; - } - - if ( next ) { - return false; - } - - node = node.parentNode; - } - - // If reached, range is assumed to be at edge. - return true; -} - -/** - * Check whether the selection is vertically at the edge of the container. - * - * @param {Element} container Focusable element. - * @param {boolean} isReverse Set to true to check top, false for bottom. - * - * @return {boolean} True if at the edge, false if not. - */ -export function isVerticalEdge( container, isReverse ) { - if ( includes( [ 'INPUT', 'TEXTAREA' ], container.tagName ) ) { - return isHorizontalEdge( container, isReverse ); - } - - if ( ! container.isContentEditable ) { - return true; - } - - const selection = window.getSelection(); - const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; - - if ( ! range ) { - return false; - } - - const rangeRect = getRectangleFromRange( range ); + const rangeRect = getRectangleFromRange( selection.getRangeAt( 0 ) ); if ( ! rangeRect ) { return false; } - // Calculate a buffer that is half the line height. In some browsers, the - // selection rectangle may not fill the entire height of the line, so we add - // half the line height to the selection rectangle to ensure that it is well - // over its line boundary. const computedStyle = window.getComputedStyle( container ); const lineHeight = parseInt( computedStyle.lineHeight, 10 ); @@ -198,20 +112,65 @@ export function isVerticalEdge( container, isReverse ) { return false; } - const editableRect = container.getBoundingClientRect(); - const buffer = lineHeight / 2; + // Calculate a buffer that is half the line height. In some browsers, the + // selection rectangle may not fill the entire height of the line, so we add + // 3/4 the line height to the selection rectangle to ensure that it is well + // over its line boundary. + const buffer = 3 * parseInt( lineHeight, 10 ) / 4; + const containerRect = container.getBoundingClientRect(); + const verticalEdge = isReverse ? + containerRect.top > rangeRect.top - buffer : + containerRect.bottom < rangeRect.bottom + buffer; - // Too low. - if ( isReverse && rangeRect.top - buffer > editableRect.top ) { + if ( ! verticalEdge ) { return false; } - // Too high. - if ( ! isReverse && rangeRect.bottom + buffer < editableRect.bottom ) { + if ( onlyVertical ) { + return true; + } + + // To calculate the horizontal position, we insert a test range and see if + // this test range has the same horizontal position. This method proves to + // be better than a DOM-based calculation, because it ignores empty text + // nodes and a trailing line break element. In other words, we need to check + // visual positioning, not DOM positioning. + const x = isReverse ? containerRect.left + 1 : containerRect.right - 1; + const y = isReverse ? containerRect.top + buffer : containerRect.bottom - buffer; + const testRange = hiddenCaretRangeFromPoint( document, x, y, container ); + + if ( ! testRange ) { return false; } - return true; + const side = isReverse ? 'left' : 'right'; + const testRect = getRectangleFromRange( testRange ); + + return Math.round( testRect[ side ] ) === Math.round( rangeRect[ side ] ); +} + +/** + * Check whether the selection is horizontally at the edge of the container. + * + * @param {Element} container Focusable element. + * @param {boolean} isReverse Set to true to check left, false for right. + * + * @return {boolean} True if at the horizontal edge, false if not. + */ +export function isHorizontalEdge( container, isReverse ) { + return isEdge( container, isReverse ); +} + +/** + * Check whether the selection is vertically at the edge of the container. + * + * @param {Element} container Focusable element. + * @param {boolean} isReverse Set to true to check top, false for bottom. + * + * @return {boolean} True if at the vertical edge, false if not. + */ +export function isVerticalEdge( container, isReverse ) { + return isEdge( container, isReverse, true ); } /** @@ -229,6 +188,18 @@ export function getRectangleFromRange( range ) { return range.getBoundingClientRect(); } + const { startContainer } = range; + + // Correct invalid "BR" ranges. The cannot contain any children. + if ( startContainer.nodeName === 'BR' ) { + const { parentNode } = startContainer; + const index = Array.from( parentNode.childNodes ).indexOf( startContainer ); + + range = document.createRange(); + range.setStart( parentNode, index ); + range.setEnd( parentNode, index ); + } + let rect = range.getClientRects()[ 0 ]; // If the collapsed range starts (and therefore ends) at an element node, diff --git a/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap b/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap index 41f6fe04a2b531..2a80f20633a953 100644 --- a/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/writing-flow.test.js.snap @@ -126,6 +126,16 @@ exports[`adding blocks should navigate around nested inline boundaries 2`] = ` " `; +exports[`adding blocks should navigate empty paragraph 1`] = ` +" +

1

+ + + +

2

+" +`; + exports[`adding blocks should not create extra line breaks in multiline value 1`] = ` "

diff --git a/packages/e2e-tests/specs/block-transforms.test.js b/packages/e2e-tests/specs/block-transforms.test.js index 7eae4155ab22fb..d76f546ebc9849 100644 --- a/packages/e2e-tests/specs/block-transforms.test.js +++ b/packages/e2e-tests/specs/block-transforms.test.js @@ -95,7 +95,13 @@ const getTransformResult = async ( blockContent, transformName ) => { return getEditedPostContent(); }; -describe( 'Block transforms', () => { +// Skipping all the tests when plugins are enabled +// makes sure the tests are not executed, and no unused snapshots errors are thrown. +const maybeDescribe = process.env.POPULAR_PLUGINS ? + describe : + describe.skip; + +maybeDescribe( 'Block transforms', () => { // Todo: Remove the filter as soon as all fixtures are corrected, // and its direct usage on the editor does not trigger errors. // Currently some fixtures trigger errors (mainly media related) @@ -166,10 +172,10 @@ describe( 'Block transforms', () => { ( { originalBlock, availableTransforms }, fixture ) => ( map( availableTransforms, - ( distinationBlock ) => ( [ + ( destinationBlock ) => ( [ originalBlock, fixture, - distinationBlock, + destinationBlock, ] ) ) ) @@ -177,10 +183,10 @@ describe( 'Block transforms', () => { it.each( testTable )( 'block %s in fixture %s into the %s block', - async ( originalBlock, fixture, distinationBlock ) => { + async ( originalBlock, fixture, destinationBlock ) => { const { content } = transformStructure[ fixture ]; expect( - await getTransformResult( content, distinationBlock ) + await getTransformResult( content, destinationBlock ) ).toMatchSnapshot(); } ); diff --git a/packages/e2e-tests/specs/embedding.test.js b/packages/e2e-tests/specs/embedding.test.js index 3ea51358b14b21..6fb26c02281c70 100644 --- a/packages/e2e-tests/specs/embedding.test.js +++ b/packages/e2e-tests/specs/embedding.test.js @@ -9,6 +9,8 @@ import { createJSONResponse, getEditedPostContent, clickButton, + insertBlock, + publishPost, } from '@wordpress/e2e-test-utils'; const MOCK_EMBED_WORDPRESS_SUCCESS_RESPONSE = { @@ -60,6 +62,10 @@ const MOCK_BAD_WORDPRESS_RESPONSE = { }; const MOCK_RESPONSES = [ + { + match: createEmbeddingMatcher( 'https://wordpress.org/gutenberg/handbook' ), + onRequestMatch: createJSONResponse( MOCK_BAD_WORDPRESS_RESPONSE ), + }, { match: createEmbeddingMatcher( 'https://wordpress.org/gutenberg/handbook/' ), onRequestMatch: createJSONResponse( MOCK_BAD_WORDPRESS_RESPONSE ), @@ -80,6 +86,10 @@ const MOCK_RESPONSES = [ match: createEmbeddingMatcher( 'https://twitter.com/notnownikki' ), onRequestMatch: createJSONResponse( MOCK_EMBED_RICH_SUCCESS_RESPONSE ), }, + { + match: createEmbeddingMatcher( 'https://twitter.com/notnownikki/' ), + onRequestMatch: createJSONResponse( MOCK_CANT_EMBED_RESPONSE ), + }, { match: createEmbeddingMatcher( 'https://twitter.com/thatbunty' ), onRequestMatch: createJSONResponse( MOCK_BAD_EMBED_PROVIDER_RESPONSE ), @@ -173,6 +183,17 @@ describe( 'Embedding content', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'should retry embeds that could not be embedded with trailing slashes, without the trailing slashes', async () => { + await clickBlockAppender(); + await page.keyboard.type( '/embed' ); + await page.keyboard.press( 'Enter' ); + // This URL can't be embedded, but without the trailing slash, it can. + await page.keyboard.type( 'https://twitter.com/notnownikki/' ); + await page.keyboard.press( 'Enter' ); + // The twitter block should appear correctly. + await page.waitForSelector( 'figure.wp-block-embed-twitter' ); + } ); + it( 'should allow the user to try embedding a failed URL again', async () => { // URL that can't be embedded. await clickBlockAppender(); @@ -192,4 +213,27 @@ describe( 'Embedding content', () => { await clickButton( 'Try again' ); await page.waitForSelector( 'figure.wp-block-embed-twitter' ); } ); + + it( 'should switch to the WordPress block correctly', async () => { + // This test is to make sure that WordPress embeds are detected correctly, + // because the HTML can vary, and the block is detected by looking for + // classes in the HTML, so we need to flag up if the HTML changes. + + // Publish a post to embed. + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Hello there!' ); + await publishPost(); + const postUrl = await page.$eval( '#inspector-text-control-0', ( el ) => el.value ); + + // Start a new post, embed the previous post. + await createNewPost(); + await clickBlockAppender(); + await page.keyboard.type( '/embed' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( postUrl ); + await page.keyboard.press( 'Enter' ); + + // Check the block has become a WordPress block. + await page.waitForSelector( '.wp-block-embed-wordpress' ); + } ); } ); diff --git a/packages/e2e-tests/specs/writing-flow.test.js b/packages/e2e-tests/specs/writing-flow.test.js index 62ece158016405..c186507a7cdef3 100644 --- a/packages/e2e-tests/specs/writing-flow.test.js +++ b/packages/e2e-tests/specs/writing-flow.test.js @@ -298,4 +298,16 @@ describe( 'adding blocks', () => { // Check that none of the paragraph blocks have
in them. expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should navigate empty paragraph', async () => { + await clickBlockAppender(); + await page.keyboard.press( 'Enter' ); + await page.waitForFunction( () => document.activeElement.isContentEditable ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( '2' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/edit-post/src/components/sidebar/style.scss b/packages/edit-post/src/components/sidebar/style.scss index 793088a0390911..62936b0f0a7ff6 100644 --- a/packages/edit-post/src/components/sidebar/style.scss +++ b/packages/edit-post/src/components/sidebar/style.scss @@ -40,17 +40,10 @@ z-index: z-index(".edit-post-sidebar .components-panel"); @include break-small() { - overflow: inherit; + overflow: hidden; height: auto; max-height: none; } - - @include break-medium() { - - body.is-fullscreen-mode & { - max-height: calc(100vh - #{ $panel-header-height }); - } - } } > .components-panel .components-panel__header { diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index c77a3add99e4b7..2430ee41087232 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -139,7 +139,10 @@ export function isEditorPanelEnabled( state, panelName ) { */ export function isEditorPanelOpened( state, panelName ) { const panels = getPreference( state, 'panels' ); - return panels[ panelName ] === true || get( panels, [ panelName, 'opened' ], false ); + return ( + get( panels, [ panelName ] ) === true || + get( panels, [ panelName, 'opened' ] ) === true + ); } /** @@ -163,7 +166,7 @@ export function isModalActive( state, modalName ) { * @return {boolean} Is active. */ export function isFeatureActive( state, feature ) { - return !! state.preferences.features[ feature ]; + return get( state.preferences.features, [ feature ], false ); } /** diff --git a/packages/edit-post/src/store/test/selectors.js b/packages/edit-post/src/store/test/selectors.js index b035169b3044d8..16131bf8eaf8d9 100644 --- a/packages/edit-post/src/store/test/selectors.js +++ b/packages/edit-post/src/store/test/selectors.js @@ -273,6 +273,15 @@ describe( 'selectors', () => { } ); describe( 'isEditorPanelOpened', () => { + it( 'is tolerant to an undefined panels preference', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + preferences: {}, + }; + + expect( isEditorPanelOpened( state, 'post-status' ) ).toBe( false ); + } ); + it( 'should return false by default', () => { const state = { preferences: { @@ -333,6 +342,15 @@ describe( 'selectors', () => { } ); describe( 'isFeatureActive', () => { + it( 'is tolerant to an undefined features preference', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + preferences: {}, + }; + + expect( isFeatureActive( state, 'chicken' ) ).toBe( false ); + } ); + it( 'should return true if feature is active', () => { const state = { preferences: { diff --git a/packages/editor/src/components/post-taxonomies/flat-term-selector.js b/packages/editor/src/components/post-taxonomies/flat-term-selector.js index 233f07cc3e2aa0..3b4c1c8a0c2926 100644 --- a/packages/editor/src/components/post-taxonomies/flat-term-selector.js +++ b/packages/editor/src/components/post-taxonomies/flat-term-selector.js @@ -38,7 +38,7 @@ const isSameTermName = ( termA, termB ) => termA.toLowerCase() === termB.toLower /** * Returns a term object with name unescaped. - * The unescape of the name propery is done using lodash unescape function. + * The unescape of the name property is done using lodash unescape function. * * @param {Object} term The term object to unescape. * @@ -57,7 +57,7 @@ const unescapeTerm = ( term ) => { * * @param {Object[]} terms Array of term objects to unescape. * - * @return {Object[]} Array of therm objects unscaped. + * @return {Object[]} Array of term objects unescaped. */ const unescapeTerms = ( terms ) => { return map( terms, unescapeTerm ); diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index ca1cbe02626865..54405f61247b8f 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -154,6 +154,7 @@ class EditorProvider extends Component { onInput={ resetEditorBlocksWithoutUndoLevel } onChange={ resetEditorBlocks } settings={ editorSettings } + useSubRegistry={ false } > { children } diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js index d22d6e7f67de8c..fb49621e8e5a64 100644 --- a/packages/editor/src/store/defaults.js +++ b/packages/editor/src/store/defaults.js @@ -4,6 +4,7 @@ import { SETTINGS_DEFAULTS } from '@wordpress/block-editor'; export const PREFERENCES_DEFAULTS = { + insertUsage: {}, // Should be kept for backward compatibility, see: https://github.com/WordPress/gutenberg/issues/14580. isPublishSidebarEnabled: true, }; diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js index f7be763e12170f..751563e77ecc3c 100644 --- a/packages/editor/src/store/test/reducer.js +++ b/packages/editor/src/store/test/reducer.js @@ -481,6 +481,7 @@ describe( 'state', () => { it( 'should apply all defaults', () => { const state = preferences( undefined, {} ); expect( state ).toEqual( { + insertUsage: {}, isPublishSidebarEnabled: true, } ); } ); diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index cca141484575cf..4516d123428d68 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -53,7 +53,7 @@ Rule|Description|Recommended [gutenberg-phase](docs/rules/gutenberg-phase.md)|Governs the use of the `process.env.GUTENBERG_PHASE` constant|✓ [no-unused-vars-before-return](/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md)|Disallow assigning variable values if unused before a return|✓ [react-no-unsafe-timeout](/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md)|Disallow unsafe `setTimeout` in component| -[valid-sprintf](/packages/eslint-plugin/docs/rules/valid-sprintf.md)|Disallow assigning variable values if unused before a return|✓ +[valid-sprintf](/packages/eslint-plugin/docs/rules/valid-sprintf.md)|Enforce valid sprintf usage|✓ ### Legacy diff --git a/packages/nux/src/store/selectors.js b/packages/nux/src/store/selectors.js index 71669709d42cb2..9225bd97077eee 100644 --- a/packages/nux/src/store/selectors.js +++ b/packages/nux/src/store/selectors.js @@ -2,7 +2,7 @@ * External dependencies */ import createSelector from 'rememo'; -import { includes, difference, keys } from 'lodash'; +import { includes, difference, keys, has } from 'lodash'; /** * An object containing information about a guide. @@ -55,7 +55,7 @@ export function isTipVisible( state, tipId ) { return false; } - if ( state.preferences.dismissedTips[ tipId ] ) { + if ( has( state.preferences.dismissedTips, [ tipId ] ) ) { return false; } diff --git a/packages/nux/src/store/test/selectors.js b/packages/nux/src/store/test/selectors.js index ed63a7fa828de5..546d052958dd5e 100644 --- a/packages/nux/src/store/test/selectors.js +++ b/packages/nux/src/store/test/selectors.js @@ -53,6 +53,26 @@ describe( 'selectors', () => { } ); describe( 'isTipVisible', () => { + it( 'is tolerant to individual preferences being undefined', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + guides: [], + preferences: {}, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'is tolerant to undefined dismissedTips', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( true ); + } ); + it( 'should return true by default', () => { const state = { guides: [], diff --git a/packages/scripts/README.md b/packages/scripts/README.md index 16b22d3d7bfee9..e6bc450c54ba00 100644 --- a/packages/scripts/README.md +++ b/packages/scripts/README.md @@ -23,7 +23,7 @@ _Example:_ ```json { "scripts": { - "build": "wp-scripts run build", + "build": "wp-scripts build", "check-engines": "wp-scripts check-engines", "check-licenses": "wp-scripts check-licenses --production", "lint:css": "wp-scripts lint-style '**/*.css'", @@ -216,6 +216,9 @@ This is how you execute those scripts using the presented setup: * `npm run test:e2e` - runs all unit tests. * `npm run test:e2e:help` - prints all available options to configure unit tests runner. +* `npm run test-e2e -- --puppeteer-interactive` - runs all unit tests interactively. +* `npm run test-e2e FILE_NAME -- --puppeteer-interactive ` - runs one test file interactively. +* `npm run test-e2e:watch -- --puppeteer-interactive` - runs all tests interactively and watch for changes. This script automatically detects the best config to start Puppeteer but sometimes you may need to specify custom options: - You can add a `jest-puppeteer.config.js` at the root of the project or define a custom path using `JEST_PUPPETEER_CONFIG` environment variable. Check [jest-puppeteer](https://github.com/smooth-code/jest-puppeteer#jest-puppeteerconfigjs) for more details. diff --git a/packages/scripts/scripts/check-licenses.js b/packages/scripts/scripts/check-licenses.js index 54b906f30e37b7..7670ae3b0d2140 100644 --- a/packages/scripts/scripts/check-licenses.js +++ b/packages/scripts/scripts/check-licenses.js @@ -79,6 +79,7 @@ const otherOssLicenses = [ 'Apache License, Version 2.0', 'Apache version 2.0', 'CC-BY-3.0', + 'LGPL', ]; const licenses = [ diff --git a/packages/scripts/scripts/test-e2e.js b/packages/scripts/scripts/test-e2e.js index 8105891c8d838b..374ebc59f6fbe5 100644 --- a/packages/scripts/scripts/test-e2e.js +++ b/packages/scripts/scripts/test-e2e.js @@ -43,11 +43,23 @@ const runInBand = ! hasRunInBand ? [ '--runInBand' ] : []; -const cleanUpPrefixes = [ '--puppeteer-' ]; - if ( hasCliArg( '--puppeteer-interactive' ) ) { process.env.PUPPETEER_HEADLESS = 'false'; process.env.PUPPETEER_SLOWMO = getCliArg( '--puppeteer-slowmo' ) || 80; } +const configsMapping = { + WP_BASE_URL: '--wordpress-base-url', + WP_USERNAME: '--wordpress-username', + WP_PASSWORD: '--wordpress-password', +}; + +Object.entries( configsMapping ).forEach( ( [ envKey, argName ] ) => { + if ( hasCliArg( argName ) ) { + process.env[ envKey ] = getCliArg( argName ); + } +} ); + +const cleanUpPrefixes = [ '--puppeteer-', '--wordpress-' ]; + jest.run( [ ...config, ...runInBand, ...getCliArgs( cleanUpPrefixes ) ] ); diff --git a/playground/.babelrc b/playground/.babelrc index 4e7ed80cab13cd..5b8ba9724e13af 100644 --- a/playground/.babelrc +++ b/playground/.babelrc @@ -3,6 +3,7 @@ "plugins": [ [ "@babel/plugin-transform-react-jsx", { "pragma": "createElement" - } ] + } ], + "babel-plugin-inline-json-import" ] -} \ No newline at end of file +} diff --git a/test/unit/__mocks__/@wordpress/data.js b/test/unit/__mocks__/@wordpress/data.js deleted file mode 100644 index 729e53648b211e..00000000000000 --- a/test/unit/__mocks__/@wordpress/data.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Internal dependencies - */ -import { use, plugins } from '../../../../packages/data/src'; - -use( plugins.controls ); - -export * from '../../../../packages/data/src';