From f81d0d7dc41779a95d164098eabb219a95df5970 Mon Sep 17 00:00:00 2001 From: Marcus Kazmierczak Date: Wed, 20 Feb 2019 09:54:57 -0800 Subject: [PATCH 001/169] Add ESNext syntax to meta block tutorial (#13954) * Add ESNext syntax to meta block tutorial * Applied WordPress code styles to the examples * Apply suggestions from code review Co-Authored-By: mkaz --- .../tutorials/metabox/meta-block-3-add.md | 75 +++++++++++++++---- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md b/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md index 0ad9966b91a12..4f6e54f148ed7 100644 --- a/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md +++ b/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md @@ -10,26 +10,28 @@ By specifying the source of the attributes as `meta`, the Block Editor automatic Add this code to your JavaScript file (this tutorial will call the file `myguten.js`): +{% codetabs %} +{% ES5 %} ```js ( function( wp ) { var el = wp.element.createElement; var registerBlockType = wp.blocks.registerBlockType; - var TextField = wp.components.TextControl; + var TextControl = wp.components.TextControl; - registerBlockType("myguten/meta-block", { - title: "Meta Block", - icon: "smiley", - category: "common", + registerBlockType( 'myguten/meta-block', { + title: 'Meta Block', + icon: 'smiley', + category: 'common', attributes: { blockValue: { - type: "string", - source: "meta", - meta: "myguten_meta_block_field" + type: 'string', + source: 'meta', + meta: 'myguten_meta_block_field' } }, - edit: function(props) { + edit: function( props ) { var className = props.className; var setAttributes = props.setAttributes; @@ -37,11 +39,11 @@ Add this code to your JavaScript file (this tutorial will call the file `myguten setAttributes({ blockValue }); } - return el( - "div", + return el( + 'div', { className: className }, - el( TextField, { - label: "Meta Block Field", + el( TextControl, { + label: 'Meta Block Field', value: props.attributes.blockValue, onChange: updateBlockValue } ) @@ -53,9 +55,52 @@ Add this code to your JavaScript file (this tutorial will call the file `myguten save: function() { return null; } - }); -})( window.wp ); + } ); +} )( window.wp ); ``` +{% ESNext %} +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { TextControl } from '@wordpress/components'; + +registerBlockType( 'myguten/meta-block', { + title: 'Meta Block', + icon: 'smiley', + category: 'common', + + attributes: { + blockValue: { + type: 'string', + source: 'meta', + meta: 'myguten_meta_block_field', + }, + }, + + edit( { className, setAttributes, attributes } ) { + + function updateBlockValue( blockValue ) { + setAttributes( { blockValue } ); + } + + return ( +
+ +
+ ); + }, + + // No information saved to the block + // Data is saved to post meta via attributes + save() { + return null; + } +} ); +``` +{% end %} **Important:** Before you test, you need to enqueue your JavaScript file and its dependencies. Note the WordPress packages used above are `wp.element`, `wp.blocks`, and `wp.components`. Each of these need to be included in the array of dependencies. Update the `myguten-meta-block.php` file adding the enqueue function: From 1020543c18a7e303e652ba5b27c4e434642ec72c Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 20 Feb 2019 16:00:40 -0500 Subject: [PATCH 002/169] Editor: RichText: Check for presence of inputType (#13986) --- packages/editor/src/components/rich-text/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 1d6c353448d9e..724c9f26e30e6 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -366,7 +366,7 @@ export class RichText extends Component { return; } - if ( event ) { + if ( event && event.nativeEvent.inputType ) { const { inputType } = event.nativeEvent; // The browser formatted something or tried to insert HTML. From 4be0a36c703a45cdcac67807d30e2e4f26c92343 Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Thu, 21 Feb 2019 14:29:44 +1100 Subject: [PATCH 003/169] Bump plugin version to 5.1.1 (#13990) --- gutenberg.php | 2 +- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gutenberg.php b/gutenberg.php index 3ade4ca6d01b0..40da12a8c4b8c 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.1.0 + * Version: 5.1.1 * Author: Gutenberg Team * * @package gutenberg diff --git a/package-lock.json b/package-lock.json index 55d51e8dce8bb..ed633e96e9bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "5.1.0", + "version": "5.1.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8dd9929cfa023..ea6cd663dbca0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "5.1.0", + "version": "5.1.1", "private": true, "description": "A new WordPress editor experience", "repository": "git+https://github.com/WordPress/gutenberg.git", From c60224004300e06c9875fe78511312f4dd812b9d Mon Sep 17 00:00:00 2001 From: andrei draganescu Date: Thu, 21 Feb 2019 11:08:55 +0200 Subject: [PATCH 004/169] Added a snippet for observing the browser when running e2e tests (#13993) * Added a snippet for observing the browser when running e2e tests --- docs/contributors/testing-overview.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/contributors/testing-overview.md b/docs/contributors/testing-overview.md index 7d0d01921a6a5..6992bd664c3ec 100644 --- a/docs/contributors/testing-overview.md +++ b/docs/contributors/testing-overview.md @@ -356,6 +356,12 @@ or interactively npm run test-e2e:watch ``` +Sometimes it's useful to observe the browser while running tests. To do so you can use these environment variables: + +```bash +PUPPETEER_HEADLESS=false PUPPETEER_SLOWMO=80 npm run test-e2e:watch +``` + If you're using a different setup, you can provide the base URL, username and password like this: ```bash From 72ff590394027945b4adea0cd1d838a6060671c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20=28Greg=29=20Zi=C3=B3=C5=82kowski?= Date: Thu, 21 Feb 2019 11:00:02 +0100 Subject: [PATCH 005/169] Extract reusable part of Webpack config and put in @wordpress/scripts (#13814) * New build-config package with webpack config. Pull the Gutenberg webpack config into a package so it can be re-used for block/extension development. * Require new build-config package. * Dynamically handle WP externals with a function. Use code from WP Calypso for handling WP externals so we don't have to have the actual list of packages accessible in our webpack configuration. * Use webpack config from build-config package. * Require build-config package. * Adjust file refs for WP packages. * Move main gutenberg entry definition and webpack copy plugin out of build-config. * Add react-dev-utils for formatting webpack compiler messages. * Implement build script using webpack config from build-config. * Adjust output path so build goes to working directory. * Update package name to webpack-config * Apply more tweaks to the way webpack config package is structured * Update the way externals are handled * Add default values for entry and output * Move shared webpack config under @wordpress/scripts package * Improve the way how loaders are handled * Replace GUTENBERG with WP in webpack config env variables Co-Authored-By: gziolo * Bring back feature flag to webpack config accidentally removed during merge * Add missing dev dependencies for the packages used in webpack config * Fix the list of excluded folders for babel-loader --- package-lock.json | 90 ++++--- package.json | 5 +- .../test/index.js | 3 +- .../CHANGELOG.md | 10 +- .../block-serialization-spec-parser/index.js | 2 - .../package.json | 6 +- .../test/index.js | 3 +- packages/scripts/config/webpack.config.js | 109 +++++++++ packages/scripts/package.json | 6 +- packages/scripts/scripts/build.js | 1 - packages/scripts/utils/index.js | 4 + packages/scripts/utils/string.js | 20 ++ packages/scripts/utils/test/string.js | 23 ++ webpack.config.js | 228 ++++++------------ 14 files changed, 305 insertions(+), 205 deletions(-) delete mode 100644 packages/block-serialization-spec-parser/index.js create mode 100644 packages/scripts/config/webpack.config.js create mode 100644 packages/scripts/utils/string.js create mode 100644 packages/scripts/utils/test/string.js diff --git a/package-lock.json b/package-lock.json index ed633e96e9bd2..656ba9ae3cd0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2562,7 +2562,10 @@ } }, "@wordpress/block-serialization-spec-parser": { - "version": "file:packages/block-serialization-spec-parser" + "version": "file:packages/block-serialization-spec-parser", + "requires": { + "pegjs": "^0.10.0" + } }, "@wordpress/blocks": { "version": "file:packages/blocks", @@ -2997,6 +3000,7 @@ "@wordpress/eslint-plugin": "file:packages/eslint-plugin", "@wordpress/jest-preset-default": "file:packages/jest-preset-default", "@wordpress/npm-package-json-lint-config": "file:packages/npm-package-json-lint-config", + "babel-loader": "^8.0.5", "chalk": "^2.4.1", "check-node-version": "^3.1.1", "cross-spawn": "^5.1.0", @@ -3007,10 +3011,13 @@ "puppeteer": "1.6.1", "read-pkg-up": "^1.0.1", "resolve-bin": "^0.4.0", + "source-map-loader": "^0.2.4", "stylelint": "^9.10.1", "stylelint-config-wordpress": "^13.1.0", "webpack": "4.8.3", - "webpack-cli": "^3.1.2" + "webpack-bundle-analyzer": "^3.0.3", + "webpack-cli": "^3.1.2", + "webpack-livereload-plugin": "^2.2.0" } }, "@wordpress/shortcode": { @@ -15869,8 +15876,7 @@ "pegjs": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", - "integrity": "sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0=", - "dev": true + "integrity": "sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0=" }, "pend": { "version": "1.2.0", @@ -15956,6 +15962,34 @@ "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", "dev": true }, + "portfinder": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz", + "integrity": "sha512-Yxe4mTyDzTd59PZJY4ojZR8F+E5e97iq2ZOHPz3HDgSvYC5siNad2tLooQ5y5QHyQhc3xVqvyk/eNA3wuoa7Sw==", + "dev": true, + "requires": { + "async": "^1.5.2", + "debug": "^2.2.0", + "mkdirp": "0.5.x" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -19763,34 +19797,13 @@ "dev": true }, "source-map-loader": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-0.2.3.tgz", - "integrity": "sha512-MYbFX9DYxmTQFfy2v8FC1XZwpwHKYxg3SK8Wb7VPBKuhDjz8gi9re2819MsG4p49HDyiOSUKlmZ+nQBArW5CGw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-0.2.4.tgz", + "integrity": "sha512-OU6UJUty+i2JDpTItnizPrlpOIBLmQbWMuBg9q5bVtnHACqw1tn9nNwqJLbv0/00JjnJb/Ee5g5WS5vrRv7zIQ==", "dev": true, "requires": { "async": "^2.5.0", - "loader-utils": "~0.2.2", - "source-map": "~0.6.1" - }, - "dependencies": { - "loader-utils": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.17.tgz", - "integrity": "sha1-+G5jdNQyBabmxg6RlvF8Apm/s0g=", - "dev": true, - "requires": { - "big.js": "^3.1.3", - "emojis-list": "^2.0.0", - "json5": "^0.5.0", - "object-assign": "^4.0.1" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } + "loader-utils": "^1.1.0" } }, "source-map-resolve": { @@ -21820,9 +21833,9 @@ } }, "webpack-bundle-analyzer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.0.2.tgz", - "integrity": "sha512-cZG4wSQtKrSpk5RJ33dxiaAyo8bP0V+JvycAyIDFEiDIhw4LHhhVKhn40YT1w6TR9E4scHA00LnIoBtTA13Mow==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.0.3.tgz", + "integrity": "sha512-naLWiRfmtH4UJgtUktRTLw6FdoZJ2RvCR9ePbwM9aRMsS/KjFerkPZG9epEvXRAw5d5oPdrs9+3p+afNjxW8Xw==", "dev": true, "requires": { "acorn": "^5.7.3", @@ -21846,9 +21859,9 @@ "dev": true }, "commander": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.18.0.tgz", - "integrity": "sha512-6CYPa+JP2ftfRU2qkDK+UTVeQYosOg/2GbcjIcKPHfinyOLPVGXu/ovN86RP49Re5ndJK1N0kuiidFFuepc4ZQ==", + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==", "dev": true } } @@ -22052,11 +22065,12 @@ } }, "webpack-livereload-plugin": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/webpack-livereload-plugin/-/webpack-livereload-plugin-2.1.1.tgz", - "integrity": "sha512-W7Q55QbPvVJotpIZSjjwzmqQ22333ExYxWM3WFlHKkbPStQqVRSmJkjntUqXF9jtpdeXi8r8HLkA1RVnAP0SQA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webpack-livereload-plugin/-/webpack-livereload-plugin-2.2.0.tgz", + "integrity": "sha512-sx9xA5mHoNOUgLQI0PmXT3KV9ecsVmUaTgr+fsoL69qAOHw/7VzkL1+ZMDQ8n0dPbWounswK6cBRSgMod7Nhgg==", "dev": true, "requires": { + "portfinder": "^1.0.17", "tiny-lr": "^1.1.1" } }, diff --git a/package.json b/package.json index ea6cd663dbca0..2299994e4f385 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@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-loader": "8.0.5", "benchmark": "2.1.4", "browserslist": "4.4.1", "chalk": "2.4.1", @@ -104,6 +103,7 @@ "node-watch": "0.6.0", "pegjs": "0.10.0", "phpegjs": "1.0.0-beta7", + "postcss": "7.0.13", "react-dom": "16.6.3", "react-test-renderer": "16.6.3", "redux": "4.0.0", @@ -113,13 +113,10 @@ "shallow-equal": "1.0.0", "shallow-equals": "1.0.0", "shallowequal": "1.1.0", - "source-map-loader": "0.2.3", "sprintf-js": "1.1.1", "stylelint-config-wordpress": "13.1.0", "uuid": "3.3.2", "webpack": "4.8.3", - "webpack-bundle-analyzer": "3.0.2", - "webpack-livereload-plugin": "2.1.1", "webpack-rtl-plugin": "github:yoavf/webpack-rtl-plugin#develop" }, "npmPackageJsonLintConfig": { diff --git a/packages/block-serialization-default-parser/test/index.js b/packages/block-serialization-default-parser/test/index.js index a3c67e280ef94..72f4121ce1944 100644 --- a/packages/block-serialization-default-parser/test/index.js +++ b/packages/block-serialization-default-parser/test/index.js @@ -6,7 +6,8 @@ import path from 'path'; /** * WordPress dependencies */ -import { jsTester, phpTester } from '@wordpress/block-serialization-spec-parser'; +// eslint-disable-next-line no-restricted-syntax +import { jsTester, phpTester } from '@wordpress/block-serialization-spec-parser/shared-tests'; /** * Internal dependencies diff --git a/packages/block-serialization-spec-parser/CHANGELOG.md b/packages/block-serialization-spec-parser/CHANGELOG.md index e4f403ffd6849..7ba44f0cd9a96 100644 --- a/packages/block-serialization-spec-parser/CHANGELOG.md +++ b/packages/block-serialization-spec-parser/CHANGELOG.md @@ -1,6 +1,12 @@ -## 2.1.0 (Unreleased) +## 3.0.0 (Unreleased) -- A `parser.php` file generated from the PEGJS grammar is now included. +## Breaking Change + +- A `parser.js` file generated from the PEGJS grammar is now outputted in commonjs format. + +## New Feature + +- A `parser.php` file generated from the PEGJS grammar is now added upon installation. ## 2.0.2 (2018-12-12) diff --git a/packages/block-serialization-spec-parser/index.js b/packages/block-serialization-spec-parser/index.js deleted file mode 100644 index b217f8f5c5ad7..0000000000000 --- a/packages/block-serialization-spec-parser/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { parse } from './parser'; -export { jsTester, phpTester } from './shared-tests'; diff --git a/packages/block-serialization-spec-parser/package.json b/packages/block-serialization-spec-parser/package.json index 918c269169f09..5d40a8cfd03de 100644 --- a/packages/block-serialization-spec-parser/package.json +++ b/packages/block-serialization-spec-parser/package.json @@ -18,12 +18,16 @@ "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" }, + "main": "parser.js", + "dependencies": { + "pegjs": "^0.10.0" + }, "publishConfig": { "access": "public" }, "scripts": { "build": "concurrently \"npm run build:js\" \"npm run build:php\"", - "build:js": "pegjs --format umd -o ./parser.js ./grammar.pegjs", + "build:js": "pegjs --format commonjs -o ./parser.js ./grammar.pegjs", "build:php": "node bin/create-php-parser.js" } } diff --git a/packages/block-serialization-spec-parser/test/index.js b/packages/block-serialization-spec-parser/test/index.js index 7bdbe9f053f16..9d00c5a5434c3 100644 --- a/packages/block-serialization-spec-parser/test/index.js +++ b/packages/block-serialization-spec-parser/test/index.js @@ -6,7 +6,8 @@ import path from 'path'; /** * Internal dependencies */ -import { jsTester, phpTester, parse } from '../'; +import { parse } from '../'; +import { jsTester, phpTester } from '../shared-tests'; describe( 'block-serialization-spec-parser-js', jsTester( parse ) ); diff --git a/packages/scripts/config/webpack.config.js b/packages/scripts/config/webpack.config.js new file mode 100644 index 0000000000000..44d0ff61d9a65 --- /dev/null +++ b/packages/scripts/config/webpack.config.js @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +const { BundleAnalyzerPlugin } = require( 'webpack-bundle-analyzer' ); +const LiveReloadPlugin = require( 'webpack-livereload-plugin' ); +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { camelCaseDash } = require( '../utils' ); + +/** + * Converts @wordpress/* string request into request object. + * + * Note this isn't the same as camel case because of the + * way that numbers don't trigger the capitalized next letter. + * + * @example + * formatRequest( '@wordpress/api-fetch' ); + * // { this: [ 'wp', 'apiFetch' ] } + * formatRequest( '@wordpress/i18n' ); + * // { this: [ 'wp', 'i18n' ] } + * + * @param {string} request Request name from import statement. + * @return {Object} Request object formatted for further processing. + */ +const formatRequest = ( request ) => { + // '@wordpress/api-fetch' -> [ '@wordpress', 'api-fetch' ] + const [ , name ] = request.split( '/' ); + + // { this: [ 'wp', 'apiFetch' ] } + return { + this: [ 'wp', camelCaseDash( name ) ], + }; +}; + +const wordpressExternals = ( context, request, callback ) => { + if ( /^@wordpress\//.test( request ) ) { + callback( null, formatRequest( request ), 'this' ); + } else { + callback(); + } +}; + +const externals = [ + { + react: 'React', + 'react-dom': 'ReactDOM', + moment: 'moment', + jquery: 'jQuery', + lodash: 'lodash', + 'lodash-es': 'lodash', + }, + wordpressExternals, +]; + +const isProduction = process.env.NODE_ENV === 'production'; +const mode = isProduction ? 'production' : 'development'; + +const config = { + mode, + entry: { + index: path.resolve( process.cwd(), 'src', 'index.js' ), + }, + output: { + filename: '[name].js', + path: path.resolve( process.cwd(), 'build' ), + }, + externals, + resolve: { + alias: { + 'lodash-es': 'lodash', + }, + }, + module: { + rules: [ + { + test: /\.js$/, + use: require.resolve( 'source-map-loader' ), + enforce: 'pre', + }, + { + test: /\.js$/, + exclude: /node_modules/, + use: require.resolve( 'babel-loader' ), + }, + ], + }, + plugins: [ + // WP_BUNDLE_ANALYZER global variable enables utility that represents bundle content + // as convenient interactive zoomable treemap. + process.env.WP_BUNDLE_ANALYZER && new BundleAnalyzerPlugin(), + // WP_LIVE_RELOAD_PORT global variable changes port on which live reload works + // when running watch mode. + ! isProduction && new LiveReloadPlugin( { port: process.env.WP_LIVE_RELOAD_PORT || 35729 } ), + ].filter( Boolean ), + stats: { + children: false, + }, +}; + +if ( ! isProduction ) { + // WP_DEVTOOL global variable controls how source maps are generated. + // See: https://webpack.js.org/configuration/devtool/#devtool. + config.devtool = process.env.WP_DEVTOOL || 'source-map'; +} + +module.exports = config; diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 8aaf6e8ee7ac6..388bc0a26a3b8 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -35,6 +35,7 @@ "@wordpress/eslint-plugin": "file:../eslint-plugin", "@wordpress/jest-preset-default": "file:../jest-preset-default", "@wordpress/npm-package-json-lint-config": "file:../npm-package-json-lint-config", + "babel-loader": "^8.0.5", "chalk": "^2.4.1", "check-node-version": "^3.1.1", "cross-spawn": "^5.1.0", @@ -45,10 +46,13 @@ "puppeteer": "1.6.1", "read-pkg-up": "^1.0.1", "resolve-bin": "^0.4.0", + "source-map-loader": "^0.2.4", "stylelint": "^9.10.1", "stylelint-config-wordpress": "^13.1.0", "webpack": "4.8.3", - "webpack-cli": "^3.1.2" + "webpack-bundle-analyzer": "^3.0.3", + "webpack-cli": "^3.1.2", + "webpack-livereload-plugin": "^2.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/scripts/scripts/build.js b/packages/scripts/scripts/build.js index 9470274f76633..ea2c7d303f2fa 100644 --- a/packages/scripts/scripts/build.js +++ b/packages/scripts/scripts/build.js @@ -32,4 +32,3 @@ if ( hasWebpackConfig ) { console.log( 'Webpack config file is missing.' ); process.exit( 1 ); } - diff --git a/packages/scripts/utils/index.js b/packages/scripts/utils/index.js index 473952fcdfc17..b1a78d0d600c9 100644 --- a/packages/scripts/utils/index.js +++ b/packages/scripts/utils/index.js @@ -18,8 +18,12 @@ const { const { hasPackageProp, } = require( './package' ); +const { + camelCaseDash, +} = require( './string' ); module.exports = { + camelCaseDash, fromConfigRoot, getCliArg, getCliArgs, diff --git a/packages/scripts/utils/string.js b/packages/scripts/utils/string.js new file mode 100644 index 0000000000000..ea6ea3889c1ad --- /dev/null +++ b/packages/scripts/utils/string.js @@ -0,0 +1,20 @@ +/** + * Given a string, returns a new string with dash separators converted to + * camelCase equivalent. This is not as aggressive as `_.camelCase` in + * converting to uppercase, where Lodash will also capitalize letters + * following numbers. + * + * @param {string} string Input dash-delimited string. + * + * @return {string} Camel-cased string. + */ +function camelCaseDash( string ) { + return string.replace( + /-([a-z])/g, + ( match, letter ) => letter.toUpperCase() + ); +} + +module.exports = { + camelCaseDash, +}; diff --git a/packages/scripts/utils/test/string.js b/packages/scripts/utils/test/string.js new file mode 100644 index 0000000000000..ca81bdda5df54 --- /dev/null +++ b/packages/scripts/utils/test/string.js @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { camelCaseDash } from '../string'; + +describe( 'string', () => { + describe( 'camelCaseDash', () => { + test( 'does not change a single word', () => { + expect( camelCaseDash( 'blocks' ) ).toBe( 'blocks' ); + expect( camelCaseDash( 'dom' ) ).toBe( 'dom' ); + } ); + + test( 'does not capitalize letters following numbers', () => { + expect( camelCaseDash( 'a11y' ) ).toBe( 'a11y' ); + expect( camelCaseDash( 'i18n' ) ).toBe( 'i18n' ); + } ); + + test( 'converts dashes into camel case', () => { + expect( camelCaseDash( 'api-fetch' ) ).toBe( 'apiFetch' ); + expect( camelCaseDash( 'list-reusable-blocks' ) ).toBe( 'listReusableBlocks' ); + } ); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index c67b8c95c804d..a5dd63c3bd982 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,10 +3,8 @@ */ const { DefinePlugin } = require( 'webpack' ); const WebpackRTLPlugin = require( 'webpack-rtl-plugin' ); -const LiveReloadPlugin = require( 'webpack-livereload-plugin' ); const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); const postcss = require( 'postcss' ); - const { get } = require( 'lodash' ); const { basename } = require( 'path' ); @@ -15,7 +13,8 @@ const { basename } = require( 'path' ); */ const CustomTemplatedPathPlugin = require( '@wordpress/custom-templated-path-webpack-plugin' ); const LibraryExportDefaultPlugin = require( '@wordpress/library-export-default-webpack-plugin' ); -const { BundleAnalyzerPlugin } = require( 'webpack-bundle-analyzer' ); +const config = require( '@wordpress/scripts/config/webpack.config' ); +const { camelCaseDash } = require( '@wordpress/scripts/utils' ); /** * Internal dependencies @@ -24,165 +23,86 @@ const { dependencies } = require( './package' ); const WORDPRESS_NAMESPACE = '@wordpress/'; -/** - * Given a string, returns a new string with dash separators converted to - * camelCase equivalent. This is not as aggressive as `_.camelCase` in - * converting to uppercase, where Lodash will also capitalize letters - * following numbers. - * - * @param {string} string Input dash-delimited string. - * - * @return {string} Camel-cased string. - */ -function camelCaseDash( string ) { - return string.replace( - /-([a-z])/g, - ( match, letter ) => letter.toUpperCase() - ); -} - const gutenbergPackages = Object.keys( dependencies ) .filter( ( packageName ) => packageName.startsWith( WORDPRESS_NAMESPACE ) ) .map( ( packageName ) => packageName.replace( WORDPRESS_NAMESPACE, '' ) ); -const externals = { - react: 'React', - 'react-dom': 'ReactDOM', - moment: 'moment', - jquery: 'jQuery', - lodash: 'lodash', - 'lodash-es': 'lodash', +config.entry = gutenbergPackages.reduce( ( memo, packageName ) => { + const name = camelCaseDash( packageName ); + memo[ name ] = `./packages/${ packageName }`; + return memo; +}, {} ); + +config.output = { + filename: './build/[basename]/index.js', + path: __dirname, + library: [ 'wp', '[name]' ], + libraryTarget: 'this', }; -gutenbergPackages.forEach( ( name ) => { - externals[ WORDPRESS_NAMESPACE + name ] = { - this: [ 'wp', camelCaseDash( name ) ], - }; -} ); - -const isProduction = process.env.NODE_ENV === 'production'; -const mode = isProduction ? 'production' : 'development'; - -const config = { - mode, - entry: gutenbergPackages.reduce( ( memo, packageName ) => { - const name = camelCaseDash( packageName ); - memo[ name ] = `./packages/${ packageName }`; - return memo; - }, {} ), - output: { - filename: './build/[basename]/index.js', - path: __dirname, - library: [ 'wp', '[name]' ], - libraryTarget: 'this', - }, - externals, - resolve: { - modules: [ - __dirname, - 'node_modules', - ], - alias: { - 'lodash-es': 'lodash', +config.plugins.push( + new DefinePlugin( { + // Inject the `GUTENBERG_PHASE` global, used for feature flagging. + // eslint-disable-next-line @wordpress/gutenberg-phase + 'process.env.GUTENBERG_PHASE': JSON.stringify( parseInt( process.env.npm_package_config_GUTENBERG_PHASE, 10 ) || 1 ), + } ), + // Create RTL files with a -rtl suffix + new WebpackRTLPlugin( { + suffix: '-rtl', + minify: config.mode === 'production' ? { safe: true } : false, + } ), + new CustomTemplatedPathPlugin( { + basename( path, data ) { + let rawRequest; + + const entryModule = get( data, [ 'chunk', 'entryModule' ], {} ); + switch ( entryModule.type ) { + case 'javascript/auto': + rawRequest = entryModule.rawRequest; + break; + + case 'javascript/esm': + rawRequest = entryModule.rootModule.rawRequest; + break; + } + + if ( rawRequest ) { + return basename( rawRequest ); + } + + return path; }, - }, - module: { - rules: [ - { - test: /\.js$/, - use: [ 'source-map-loader' ], - enforce: 'pre', - }, - { - test: /\.js$/, - exclude: [ - /block-serialization-spec-parser/, - /is-shallow-equal/, - /node_modules/, - ], - use: 'babel-loader', - }, - ], - }, - plugins: [ - new DefinePlugin( { - // Inject the `GUTENBERG_PHASE` global, used for feature flagging. - // eslint-disable-next-line @wordpress/gutenberg-phase - 'process.env.GUTENBERG_PHASE': JSON.stringify( parseInt( process.env.npm_package_config_GUTENBERG_PHASE, 10 ) || 1 ), - } ), - // Create RTL files with a -rtl suffix - new WebpackRTLPlugin( { - suffix: '-rtl', - minify: process.env.NODE_ENV === 'production' ? { safe: true } : false, - } ), - new CustomTemplatedPathPlugin( { - basename( path, data ) { - let rawRequest; - - const entryModule = get( data, [ 'chunk', 'entryModule' ], {} ); - switch ( entryModule.type ) { - case 'javascript/auto': - rawRequest = entryModule.rawRequest; - break; - - case 'javascript/esm': - rawRequest = entryModule.rootModule.rawRequest; - break; - } - - if ( rawRequest ) { - return basename( rawRequest ); + } ), + new LibraryExportDefaultPlugin( [ + 'api-fetch', + 'deprecated', + 'dom-ready', + 'redux-routine', + 'token-list', + ].map( camelCaseDash ) ), + new CopyWebpackPlugin( + gutenbergPackages.map( ( packageName ) => ( { + from: `./packages/${ packageName }/build-style/*.css`, + to: `./build/${ packageName }/`, + flatten: true, + transform: ( content ) => { + if ( config.mode === 'production' ) { + return postcss( [ + require( 'cssnano' )( { + preset: [ 'default', { + discardComments: { + removeAll: true, + }, + } ], + } ), + ] ) + .process( content, { from: 'src/app.css', to: 'dest/app.css' } ) + .then( ( result ) => result.css ); } - - return path; + return content; }, - } ), - new LibraryExportDefaultPlugin( [ - 'api-fetch', - 'deprecated', - 'dom-ready', - 'redux-routine', - 'token-list', - ].map( camelCaseDash ) ), - new CopyWebpackPlugin( - gutenbergPackages.map( ( packageName ) => ( { - from: `./packages/${ packageName }/build-style/*.css`, - to: `./build/${ packageName }/`, - flatten: true, - transform: ( content ) => { - if ( config.mode === 'production' ) { - return postcss( [ - require( 'cssnano' )( { - preset: [ 'default', { - discardComments: { - removeAll: true, - }, - } ], - } ), - ] ) - .process( content, { from: 'src/app.css', to: 'dest/app.css' } ) - .then( ( result ) => result.css ); - } - return content; - }, - } ) ) - ), - // GUTENBERG_BUNDLE_ANALYZER global variable enables utility that represents bundle content - // as convenient interactive zoomable treemap. - process.env.GUTENBERG_BUNDLE_ANALYZER && new BundleAnalyzerPlugin(), - // GUTENBERG_LIVE_RELOAD_PORT global variable changes port on which live reload works - // when running watch mode. - ! isProduction && new LiveReloadPlugin( { port: process.env.GUTENBERG_LIVE_RELOAD_PORT || 35729 } ), - ].filter( Boolean ), - stats: { - children: false, - }, -}; - -if ( ! isProduction ) { - // GUTENBERG_DEVTOOL global variable controls how source maps are generated. - // See: https://webpack.js.org/configuration/devtool/#devtool. - config.devtool = process.env.GUTENBERG_DEVTOOL || 'source-map'; -} + } ) ) + ) +); module.exports = config; From 011a8b3d92f2bdd8b79853bb53d292999dc4c5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s?= Date: Thu, 21 Feb 2019 13:38:33 +0100 Subject: [PATCH 006/169] Use globals instead of imports in tutorials (#13995) * Use globals instead of imports * Update docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md Co-Authored-By: nosolosw * Update docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md Co-Authored-By: nosolosw --- .../developers/tutorials/metabox/meta-block-3-add.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md b/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md index 4f6e54f148ed7..e1e6749c17ff7 100644 --- a/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md +++ b/docs/designers-developers/developers/tutorials/metabox/meta-block-3-add.md @@ -60,8 +60,9 @@ Add this code to your JavaScript file (this tutorial will call the file `myguten ``` {% ESNext %} ```jsx -import { registerBlockType } from '@wordpress/blocks'; -import { TextControl } from '@wordpress/components'; + +const { registerBlockType } = wp.blocks; +const { TextControl } = wp.components; registerBlockType( 'myguten/meta-block', { title: 'Meta Block', From 10989f01abbb187805443fa9e0e839a55ae07a63 Mon Sep 17 00:00:00 2001 From: Kjell Reigstad Date: Thu, 21 Feb 2019 09:46:45 -0500 Subject: [PATCH 007/169] URL input popover visual cleanup (#13973) * Use chevron instead of ellipsis in url input field options. * Mimic toolbar icon styles for the icons in the URL popover. * Add a left divider before the URL settings toggle * Even up the spacing in the settings panel. * Add periods to code comments. * Update snapshot --- .../src/components/url-popover/index.js | 2 +- .../src/components/url-popover/style.scss | 46 ++++++++++++++++--- .../test/__snapshots__/index.js.snap | 4 +- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/url-popover/index.js b/packages/editor/src/components/url-popover/index.js index c89f76d8bfd62..5842dd6017b5e 100644 --- a/packages/editor/src/components/url-popover/index.js +++ b/packages/editor/src/components/url-popover/index.js @@ -54,7 +54,7 @@ class URLPopover extends Component { { !! renderSettings && ( svg { + padding: 5px; + border-radius: $radius-round-rectangle; + height: 30px; + width: 30px; + } + + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + box-shadow: none; + + > svg { + @include formatting-button-style__hover; + } + } + + &:not(:disabled):focus { + box-shadow: none; + + > svg { + @include formatting-button-style__focus; + } + } + } + &__settings-toggle { flex-shrink: 0; - width: $icon-button-size; - height: $icon-button-size; - .dashicon { - transform: rotate(90deg); + // Add a left divider to the toggle button. + border-radius: 0; + border-left: $border-width solid $light-gray-500; + margin-left: 1px; + + &[aria-expanded="true"] .dashicon { + transform: rotate(180deg); } } &__settings { - padding: 7px 8px; + padding: $panel-padding; border-top: $border-width solid $light-gray-500; - padding-top: 7px + $border-width; + + .components-base-control:last-child .components-base-control__field { + margin-bottom: 0; + } } } diff --git a/packages/editor/src/components/url-popover/test/__snapshots__/index.js.snap b/packages/editor/src/components/url-popover/test/__snapshots__/index.js.snap index 4bffa2ecb3747..f8f0aa9027bdc 100644 --- a/packages/editor/src/components/url-popover/test/__snapshots__/index.js.snap +++ b/packages/editor/src/components/url-popover/test/__snapshots__/index.js.snap @@ -16,7 +16,7 @@ exports[`URLPopover matches the snapshot in its default state 1`] = ` @@ -40,7 +40,7 @@ exports[`URLPopover matches the snapshot when the settings are toggled open 1`] From f5e15ae16fd96149146cb92e085ab75e5ccf4fee Mon Sep 17 00:00:00 2001 From: Stefanos Togoulidis Date: Thu, 21 Feb 2019 16:51:23 +0200 Subject: [PATCH 008/169] Reinstate "underline" in default formats list (#14008) --- packages/format-library/src/default-formats.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/format-library/src/default-formats.js b/packages/format-library/src/default-formats.js index 46d8e4b851e4a..f5d57910a7f13 100644 --- a/packages/format-library/src/default-formats.js +++ b/packages/format-library/src/default-formats.js @@ -7,6 +7,7 @@ import { image } from './image'; import { italic } from './italic'; import { link } from './link'; import { strikethrough } from './strikethrough'; +import { underline } from './underline'; export default [ bold, @@ -15,4 +16,5 @@ export default [ italic, link, strikethrough, + underline, ]; From 6f88bec8fbf15291e3e00872a5df59e310494f5b Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 21 Feb 2019 16:35:39 +0100 Subject: [PATCH 009/169] Upgrade React to 16.8.2: Welcome React Hooks (#13992) * Upgrade React to 16.8.2 * Update package-lock.json file * Expose React Hooks --- lib/client-assets.php | 4 +- package-lock.json | 200 +++--------------- package.json | 4 +- packages/element/package.json | 4 +- packages/element/src/react.js | 26 +++ phpunit/class-vendor-script-filename-test.php | 8 +- 6 files changed, 68 insertions(+), 178 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index b34e428425e80..e1a491fa9ef4e 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -540,12 +540,12 @@ function gutenberg_register_vendor_scripts() { gutenberg_register_vendor_script( 'react', - 'https://unpkg.com/react@16.6.3/umd/react' . $react_suffix . '.js', + 'https://unpkg.com/react@16.8.2/umd/react' . $react_suffix . '.js', array( 'wp-polyfill' ) ); gutenberg_register_vendor_script( 'react-dom', - 'https://unpkg.com/react-dom@16.6.3/umd/react-dom' . $react_suffix . '.js', + 'https://unpkg.com/react-dom@16.8.2/umd/react-dom' . $react_suffix . '.js', array( 'react' ) ); $moment_script = SCRIPT_DEBUG ? 'moment.js' : 'min/moment.min.js'; diff --git a/package-lock.json b/package-lock.json index 656ba9ae3cd0e..92c991e0c93f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2803,8 +2803,8 @@ "@babel/runtime": "^7.3.1", "@wordpress/escape-html": "file:packages/escape-html", "lodash": "^4.17.11", - "react": "^16.6.3", - "react-dom": "^16.6.3" + "react": "^16.8.2", + "react-dom": "^16.8.2" } }, "@wordpress/escape-html": { @@ -3164,17 +3164,6 @@ "object.entries": "^1.0.4", "prop-types": "^15.6.1", "prop-types-exact": "^1.1.2" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } } }, "ajv": { @@ -7452,24 +7441,6 @@ "prop-types": "^15.6.2", "react-is": "^16.5.2", "react-test-renderer": "^16.0.0-0" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "dev": true, - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - }, - "react-is": { - "version": "16.6.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.0.tgz", - "integrity": "sha512-q8U7k0Fi7oxF1HvQgyBjPwDXeMplEsArnKt2iYhuIF86+GBbgLHdAmokL3XUFjTd7Q363OSNG55FOGUdONVn1g==", - "dev": true - } } }, "enzyme-adapter-utils": { @@ -7481,18 +7452,6 @@ "function.prototype.name": "^1.1.0", "object.assign": "^4.1.0", "prop-types": "^15.6.2" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "dev": true, - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } } }, "enzyme-to-json": { @@ -7816,18 +7775,6 @@ "has": "^1.0.1", "jsx-ast-utils": "^2.0.1", "prop-types": "^15.6.0" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "dev": true, - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } } }, "eslint-scope": { @@ -10244,6 +10191,11 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + }, "home-or-tmp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", @@ -17785,12 +17737,13 @@ } }, "prop-types": { - "version": "15.5.10", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.5.10.tgz", - "integrity": "sha1-J5ffwxJhguOpXj37suiT3ddFYVQ=", + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", "requires": { - "fbjs": "^0.8.9", - "loose-envify": "^1.3.1" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" } }, "prop-types-exact": { @@ -18055,25 +18008,14 @@ "integrity": "sha512-pLJkPbZCe+3ml+9Q15z+R69qYZDsluj0KwrdFb8kSNaqDzYAveDUblf7voHH9hNTdKIiIvP8iIdGFFKSgffVaQ==" }, "react": { - "version": "16.6.3", - "resolved": "https://registry.npmjs.org/react/-/react-16.6.3.tgz", - "integrity": "sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw==", + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/react/-/react-16.8.2.tgz", + "integrity": "sha512-aB2ctx9uQ9vo09HVknqv3DGRpI7OIGJhCx3Bt0QqoRluEjHSaObJl+nG12GDdYH6sTgE7YiPJ6ZUyMx9kICdXw==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.11.2" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } + "scheduler": "^0.13.2" } }, "react-addons-shallow-compare": { @@ -18128,46 +18070,23 @@ "react-portal": "^4.1.5", "react-with-styles": "^3.2.0", "react-with-styles-interface-css": "^4.0.2" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } } }, "react-dom": { - "version": "16.6.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.6.3.tgz", - "integrity": "sha512-8ugJWRCWLGXy+7PmNh8WJz3g1TaTUt1XyoIcFN+x0Zbkoz+KKdUyx1AQLYJdbFXjuF41Nmjn5+j//rxvhFjgSQ==", + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.2.tgz", + "integrity": "sha512-cPGfgFfwi+VCZjk73buu14pYkYBR1b/SRMSYqkLDdhSEHnSwcuYTPu6/Bh6ZphJFIk80XLvbSe2azfcRzNF+Xg==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.11.2" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } + "scheduler": "^0.13.2" } }, "react-is": { - "version": "16.6.3", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", - "integrity": "sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA==", - "dev": true + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.2.tgz", + "integrity": "sha512-D+NxhSR2HUCjYky1q1DwpNUD44cDpUXzSmmFyC3ug1bClcU/iDNy0YNn1iwme28fn+NFhpA13IndOd42CrFb+Q==" }, "react-moment-proptypes": { "version": "1.6.0", @@ -18186,17 +18105,6 @@ "consolidated-events": "^1.1.1 || ^2.0.0", "object.values": "^1.0.4", "prop-types": "^15.6.1" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } } }, "react-portal": { @@ -18208,27 +18116,15 @@ } }, "react-test-renderer": { - "version": "16.6.3", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.6.3.tgz", - "integrity": "sha512-B5bCer+qymrQz/wN03lT0LppbZUDRq6AMfzMKrovzkGzfO81a9T+PWQW6MzkWknbwODQH/qpJno/yFQLX5IWrQ==", + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.2.tgz", + "integrity": "sha512-gsd4NoOaYrZD2R8zi+CBV9wTGMsGhE2bRe4wvenGy0WcLJgdPscRZDDz+kmLjY+/5XpYC8yRR/v4CScgYfGyoQ==", "dev": true, "requires": { "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "react-is": "^16.6.3", - "scheduler": "^0.11.2" - }, - "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "dev": true, - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } + "react-is": "^16.8.2", + "scheduler": "^0.13.2" } }, "react-with-direction": { @@ -18244,22 +18140,6 @@ "object.assign": "^4.1.0", "object.values": "^1.0.4", "prop-types": "^15.6.0" - }, - "dependencies": { - "hoist-non-react-statics": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", - "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" - }, - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } } }, "react-with-styles": { @@ -18271,22 +18151,6 @@ "hoist-non-react-statics": "^2.5.0", "prop-types": "^15.6.1", "react-with-direction": "^1.3.0" - }, - "dependencies": { - "hoist-non-react-statics": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", - "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" - }, - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - } } }, "react-with-styles-interface-css": { @@ -19337,9 +19201,9 @@ "dev": true }, "scheduler": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.11.2.tgz", - "integrity": "sha512-+WCP3s3wOaW4S7C1tl3TEXp4l9lJn0ZK8G3W3WKRWmw77Z2cIFUW2MiNTMHn5sCjxN+t7N43HAOOgMjyAg5hlg==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.2.tgz", + "integrity": "sha512-qK5P8tHS7vdEMCW5IPyt8v9MJOHqTrOUgPXib7tqm9vh834ibBX5BNhwkplX/0iOzHW5sXyluehYfS9yrkz9+w==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/package.json b/package.json index 2299994e4f385..59316766071af 100644 --- a/package.json +++ b/package.json @@ -104,8 +104,8 @@ "pegjs": "0.10.0", "phpegjs": "1.0.0-beta7", "postcss": "7.0.13", - "react-dom": "16.6.3", - "react-test-renderer": "16.6.3", + "react-dom": "16.8.2", + "react-test-renderer": "16.8.2", "redux": "4.0.0", "rimraf": "2.6.2", "rtlcss": "2.4.0", diff --git a/packages/element/package.json b/packages/element/package.json index 24df06e6702af..0aa0e7141202f 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -24,8 +24,8 @@ "@babel/runtime": "^7.3.1", "@wordpress/escape-html": "file:../escape-html", "lodash": "^4.17.11", - "react": "^16.6.3", - "react-dom": "^16.6.3" + "react": "^16.8.2", + "react-dom": "^16.8.2" }, "publishConfig": { "access": "public" diff --git a/packages/element/src/react.js b/packages/element/src/react.js index 4d42fae21c1f7..bb8b98d79d1d5 100644 --- a/packages/element/src/react.js +++ b/packages/element/src/react.js @@ -12,6 +12,16 @@ import { Fragment, isValidElement, StrictMode, + useState, + useEffect, + useContext, + useReducer, + useCallback, + useMemo, + useRef, + useImperativeHandle, + useLayoutEffect, + useDebugValue, } from 'react'; import { isString } from 'lodash'; @@ -93,6 +103,22 @@ export { isValidElement }; export { StrictMode }; +/** + * Make React Hooks available + */ +export { + useCallback, + useContext, + useDebugValue, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +}; + /** * Concatenate two or more React children objects. * diff --git a/phpunit/class-vendor-script-filename-test.php b/phpunit/class-vendor-script-filename-test.php index 0ee7a7b78b83d..ef9917e476f86 100644 --- a/phpunit/class-vendor-script-filename-test.php +++ b/phpunit/class-vendor-script-filename-test.php @@ -11,23 +11,23 @@ function vendor_script_filename_cases() { // Development mode scripts. array( 'react-handle', - 'https://unpkg.com/react@16.6.3/umd/react.development.js', + 'https://unpkg.com/react@16.8.2/umd/react.development.js', 'react-handle.HASH.js', ), array( 'react-dom-handle', - 'https://unpkg.com/react-dom@16.6.3/umd/react-dom.development.js', + 'https://unpkg.com/react-dom@16.8.2/umd/react-dom.development.js', 'react-dom-handle.HASH.js', ), // Production mode scripts. array( 'react-handle', - 'https://unpkg.com/react@16.6.3/umd/react.production.min.js', + 'https://unpkg.com/react@16.8.2/umd/react.production.min.js', 'react-handle.min.HASH.js', ), array( 'react-dom-handle', - 'https://unpkg.com/react-dom@16.6.3/umd/react-dom.production.min.js', + 'https://unpkg.com/react-dom@16.8.2/umd/react-dom.production.min.js', 'react-dom-handle.min.HASH.js', ), // Other cases. From bc065626fe83efee65a876364a6da51619042bed Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 21 Feb 2019 17:00:01 +0100 Subject: [PATCH 010/169] Revert "Upgrade React to 16.8.2: Welcome React Hooks (#13992)" (#14017) This reverts commit 6f88bec8fbf15291e3e00872a5df59e310494f5b. --- lib/client-assets.php | 4 +- package-lock.json | 200 +++++++++++++++--- package.json | 4 +- packages/element/package.json | 4 +- packages/element/src/react.js | 26 --- phpunit/class-vendor-script-filename-test.php | 8 +- 6 files changed, 178 insertions(+), 68 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index e1a491fa9ef4e..b34e428425e80 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -540,12 +540,12 @@ function gutenberg_register_vendor_scripts() { gutenberg_register_vendor_script( 'react', - 'https://unpkg.com/react@16.8.2/umd/react' . $react_suffix . '.js', + 'https://unpkg.com/react@16.6.3/umd/react' . $react_suffix . '.js', array( 'wp-polyfill' ) ); gutenberg_register_vendor_script( 'react-dom', - 'https://unpkg.com/react-dom@16.8.2/umd/react-dom' . $react_suffix . '.js', + 'https://unpkg.com/react-dom@16.6.3/umd/react-dom' . $react_suffix . '.js', array( 'react' ) ); $moment_script = SCRIPT_DEBUG ? 'moment.js' : 'min/moment.min.js'; diff --git a/package-lock.json b/package-lock.json index 92c991e0c93f8..656ba9ae3cd0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2803,8 +2803,8 @@ "@babel/runtime": "^7.3.1", "@wordpress/escape-html": "file:packages/escape-html", "lodash": "^4.17.11", - "react": "^16.8.2", - "react-dom": "^16.8.2" + "react": "^16.6.3", + "react-dom": "^16.6.3" } }, "@wordpress/escape-html": { @@ -3164,6 +3164,17 @@ "object.entries": "^1.0.4", "prop-types": "^15.6.1", "prop-types-exact": "^1.1.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "ajv": { @@ -7441,6 +7452,24 @@ "prop-types": "^15.6.2", "react-is": "^16.5.2", "react-test-renderer": "^16.0.0-0" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "dev": true, + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + }, + "react-is": { + "version": "16.6.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.0.tgz", + "integrity": "sha512-q8U7k0Fi7oxF1HvQgyBjPwDXeMplEsArnKt2iYhuIF86+GBbgLHdAmokL3XUFjTd7Q363OSNG55FOGUdONVn1g==", + "dev": true + } } }, "enzyme-adapter-utils": { @@ -7452,6 +7481,18 @@ "function.prototype.name": "^1.1.0", "object.assign": "^4.1.0", "prop-types": "^15.6.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "dev": true, + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "enzyme-to-json": { @@ -7775,6 +7816,18 @@ "has": "^1.0.1", "jsx-ast-utils": "^2.0.1", "prop-types": "^15.6.0" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "dev": true, + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "eslint-scope": { @@ -10191,11 +10244,6 @@ "minimalistic-crypto-utils": "^1.0.1" } }, - "hoist-non-react-statics": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", - "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" - }, "home-or-tmp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", @@ -17737,13 +17785,12 @@ } }, "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "version": "15.5.10", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.5.10.tgz", + "integrity": "sha1-J5ffwxJhguOpXj37suiT3ddFYVQ=", "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" + "fbjs": "^0.8.9", + "loose-envify": "^1.3.1" } }, "prop-types-exact": { @@ -18008,14 +18055,25 @@ "integrity": "sha512-pLJkPbZCe+3ml+9Q15z+R69qYZDsluj0KwrdFb8kSNaqDzYAveDUblf7voHH9hNTdKIiIvP8iIdGFFKSgffVaQ==" }, "react": { - "version": "16.8.2", - "resolved": "https://registry.npmjs.org/react/-/react-16.8.2.tgz", - "integrity": "sha512-aB2ctx9uQ9vo09HVknqv3DGRpI7OIGJhCx3Bt0QqoRluEjHSaObJl+nG12GDdYH6sTgE7YiPJ6ZUyMx9kICdXw==", + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react/-/react-16.6.3.tgz", + "integrity": "sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.2" + "scheduler": "^0.11.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "react-addons-shallow-compare": { @@ -18070,23 +18128,46 @@ "react-portal": "^4.1.5", "react-with-styles": "^3.2.0", "react-with-styles-interface-css": "^4.0.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "react-dom": { - "version": "16.8.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.2.tgz", - "integrity": "sha512-cPGfgFfwi+VCZjk73buu14pYkYBR1b/SRMSYqkLDdhSEHnSwcuYTPu6/Bh6ZphJFIk80XLvbSe2azfcRzNF+Xg==", + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.6.3.tgz", + "integrity": "sha512-8ugJWRCWLGXy+7PmNh8WJz3g1TaTUt1XyoIcFN+x0Zbkoz+KKdUyx1AQLYJdbFXjuF41Nmjn5+j//rxvhFjgSQ==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.2" + "scheduler": "^0.11.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "react-is": { - "version": "16.8.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.2.tgz", - "integrity": "sha512-D+NxhSR2HUCjYky1q1DwpNUD44cDpUXzSmmFyC3ug1bClcU/iDNy0YNn1iwme28fn+NFhpA13IndOd42CrFb+Q==" + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", + "integrity": "sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA==", + "dev": true }, "react-moment-proptypes": { "version": "1.6.0", @@ -18105,6 +18186,17 @@ "consolidated-events": "^1.1.1 || ^2.0.0", "object.values": "^1.0.4", "prop-types": "^15.6.1" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "react-portal": { @@ -18116,15 +18208,27 @@ } }, "react-test-renderer": { - "version": "16.8.2", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.2.tgz", - "integrity": "sha512-gsd4NoOaYrZD2R8zi+CBV9wTGMsGhE2bRe4wvenGy0WcLJgdPscRZDDz+kmLjY+/5XpYC8yRR/v4CScgYfGyoQ==", + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.6.3.tgz", + "integrity": "sha512-B5bCer+qymrQz/wN03lT0LppbZUDRq6AMfzMKrovzkGzfO81a9T+PWQW6MzkWknbwODQH/qpJno/yFQLX5IWrQ==", "dev": true, "requires": { "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "react-is": "^16.8.2", - "scheduler": "^0.13.2" + "react-is": "^16.6.3", + "scheduler": "^0.11.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "dev": true, + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "react-with-direction": { @@ -18140,6 +18244,22 @@ "object.assign": "^4.1.0", "object.values": "^1.0.4", "prop-types": "^15.6.0" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + }, + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "react-with-styles": { @@ -18151,6 +18271,22 @@ "hoist-non-react-statics": "^2.5.0", "prop-types": "^15.6.1", "react-with-direction": "^1.3.0" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + }, + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "requires": { + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + } } }, "react-with-styles-interface-css": { @@ -19201,9 +19337,9 @@ "dev": true }, "scheduler": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.2.tgz", - "integrity": "sha512-qK5P8tHS7vdEMCW5IPyt8v9MJOHqTrOUgPXib7tqm9vh834ibBX5BNhwkplX/0iOzHW5sXyluehYfS9yrkz9+w==", + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.11.2.tgz", + "integrity": "sha512-+WCP3s3wOaW4S7C1tl3TEXp4l9lJn0ZK8G3W3WKRWmw77Z2cIFUW2MiNTMHn5sCjxN+t7N43HAOOgMjyAg5hlg==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/package.json b/package.json index 59316766071af..2299994e4f385 100644 --- a/package.json +++ b/package.json @@ -104,8 +104,8 @@ "pegjs": "0.10.0", "phpegjs": "1.0.0-beta7", "postcss": "7.0.13", - "react-dom": "16.8.2", - "react-test-renderer": "16.8.2", + "react-dom": "16.6.3", + "react-test-renderer": "16.6.3", "redux": "4.0.0", "rimraf": "2.6.2", "rtlcss": "2.4.0", diff --git a/packages/element/package.json b/packages/element/package.json index 0aa0e7141202f..24df06e6702af 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -24,8 +24,8 @@ "@babel/runtime": "^7.3.1", "@wordpress/escape-html": "file:../escape-html", "lodash": "^4.17.11", - "react": "^16.8.2", - "react-dom": "^16.8.2" + "react": "^16.6.3", + "react-dom": "^16.6.3" }, "publishConfig": { "access": "public" diff --git a/packages/element/src/react.js b/packages/element/src/react.js index bb8b98d79d1d5..4d42fae21c1f7 100644 --- a/packages/element/src/react.js +++ b/packages/element/src/react.js @@ -12,16 +12,6 @@ import { Fragment, isValidElement, StrictMode, - useState, - useEffect, - useContext, - useReducer, - useCallback, - useMemo, - useRef, - useImperativeHandle, - useLayoutEffect, - useDebugValue, } from 'react'; import { isString } from 'lodash'; @@ -103,22 +93,6 @@ export { isValidElement }; export { StrictMode }; -/** - * Make React Hooks available - */ -export { - useCallback, - useContext, - useDebugValue, - useEffect, - useImperativeHandle, - useLayoutEffect, - useMemo, - useReducer, - useRef, - useState, -}; - /** * Concatenate two or more React children objects. * diff --git a/phpunit/class-vendor-script-filename-test.php b/phpunit/class-vendor-script-filename-test.php index ef9917e476f86..0ee7a7b78b83d 100644 --- a/phpunit/class-vendor-script-filename-test.php +++ b/phpunit/class-vendor-script-filename-test.php @@ -11,23 +11,23 @@ function vendor_script_filename_cases() { // Development mode scripts. array( 'react-handle', - 'https://unpkg.com/react@16.8.2/umd/react.development.js', + 'https://unpkg.com/react@16.6.3/umd/react.development.js', 'react-handle.HASH.js', ), array( 'react-dom-handle', - 'https://unpkg.com/react-dom@16.8.2/umd/react-dom.development.js', + 'https://unpkg.com/react-dom@16.6.3/umd/react-dom.development.js', 'react-dom-handle.HASH.js', ), // Production mode scripts. array( 'react-handle', - 'https://unpkg.com/react@16.8.2/umd/react.production.min.js', + 'https://unpkg.com/react@16.6.3/umd/react.production.min.js', 'react-handle.min.HASH.js', ), array( 'react-dom-handle', - 'https://unpkg.com/react-dom@16.8.2/umd/react-dom.production.min.js', + 'https://unpkg.com/react-dom@16.6.3/umd/react-dom.production.min.js', 'react-dom-handle.min.HASH.js', ), // Other cases. From 211f65dd854fc1f7d7e54782863ad0629abeff41 Mon Sep 17 00:00:00 2001 From: Kjell Reigstad Date: Thu, 21 Feb 2019 11:40:58 -0500 Subject: [PATCH 011/169] Cleanup URL Popover stylesheet. (#14015) As noted in #13973, this stylesheet uses a relatively non-standard SCSS method of nesting some classnames. For instance: `.editor-url-popover { &__settings-toggle { ... } }`. ... instead of: `.editor-url-popover__settings-toggle { ... }` This is different from the conventions used elsewhere in Gutenberg, and is a bit more difficult to follow for that reason. This commit un-nests those styles, and should have no effect on the compiled CSS. --- .../src/components/url-popover/style.scss | 84 +++++++++---------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/editor/src/components/url-popover/style.scss b/packages/editor/src/components/url-popover/style.scss index af277e9fc4c4a..51f16cadfb24c 100644 --- a/packages/editor/src/components/url-popover/style.scss +++ b/packages/editor/src/components/url-popover/style.scss @@ -1,61 +1,59 @@ -.editor-url-popover { - &__row { - display: flex; - } +.editor-url-popover__row { + display: flex; +} - // Any children of the popover-row that are not the settings-toggle - // should take up as much space as possible. - &__row > :not(.editor-url-popover__settings-toggle) { - flex-grow: 1; - } +// Any children of the popover-row that are not the settings-toggle +// should take up as much space as possible. +.editor-url-popover__row > :not(.editor-url-popover__settings-toggle) { + flex-grow: 1; +} - // Mimic toolbar component styles for the icons in this popover. - .components-icon-button { - padding: 3px; +// Mimic toolbar component styles for the icons in this popover. +.editor-url-popover .components-icon-button { + padding: 3px; - > svg { - padding: 5px; - border-radius: $radius-round-rectangle; - height: 30px; - width: 30px; - } + > svg { + padding: 5px; + border-radius: $radius-round-rectangle; + height: 30px; + width: 30px; + } - &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { - box-shadow: none; + &:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover { + box-shadow: none; - > svg { - @include formatting-button-style__hover; - } + > svg { + @include formatting-button-style__hover; } + } - &:not(:disabled):focus { - box-shadow: none; + &:not(:disabled):focus { + box-shadow: none; - > svg { - @include formatting-button-style__focus; - } + > svg { + @include formatting-button-style__focus; } } +} - &__settings-toggle { - flex-shrink: 0; +.editor-url-popover__settings-toggle { + flex-shrink: 0; - // Add a left divider to the toggle button. - border-radius: 0; - border-left: $border-width solid $light-gray-500; - margin-left: 1px; + // Add a left divider to the toggle button. + border-radius: 0; + border-left: $border-width solid $light-gray-500; + margin-left: 1px; - &[aria-expanded="true"] .dashicon { - transform: rotate(180deg); - } + &[aria-expanded="true"] .dashicon { + transform: rotate(180deg); } +} - &__settings { - padding: $panel-padding; - border-top: $border-width solid $light-gray-500; +.editor-url-popover__settings { + padding: $panel-padding; + border-top: $border-width solid $light-gray-500; - .components-base-control:last-child .components-base-control__field { - margin-bottom: 0; - } + .components-base-control:last-child .components-base-control__field { + margin-bottom: 0; } } From 0c4f457b0bf2dd3b97a20edda216288c11b05466 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 22 Feb 2019 09:33:04 +0100 Subject: [PATCH 012/169] Add a generic block editor module (#13088) --- .../developers/data/README.md | 3 +- .../developers/data/data-core-block-editor.md | 1050 +++++ .../developers/data/data-core-editor.md | 1053 +---- docs/manifest.json | 14 +- docs/tool/config.js | 7 +- lib/client-assets.php | 1 + lib/packages-dependencies.php | 10 + package-lock.json | 16 + package.json | 1 + packages/block-editor/.npmrc | 1 + packages/block-editor/CHANGELOG.md | 5 + packages/block-editor/README.md | 13 + packages/block-editor/package.json | 38 + packages/block-editor/src/components/index.js | 1 + .../src/components/provider/index.js | 152 + packages/block-editor/src/index.js | 11 + packages/block-editor/src/store/actions.js | 544 +++ .../src/store/array.js | 0 packages/block-editor/src/store/controls.js | 30 + packages/block-editor/src/store/defaults.js | 133 + packages/block-editor/src/store/effects.js | 150 + packages/block-editor/src/store/index.js | 29 + .../block-editor/src/store/middlewares.js | 45 + packages/block-editor/src/store/reducer.js | 901 +++++ packages/block-editor/src/store/selectors.js | 1395 +++++++ .../block-editor/src/store/test/actions.js | 336 ++ .../src/store/test/array.js | 0 .../block-editor/src/store/test/effects.js | 280 ++ .../block-editor/src/store/test/reducer.js | 1721 ++++++++ .../block-editor/src/store/test/selectors.js | 2321 +++++++++++ .../data/src/plugins/persistence/index.js | 33 +- .../specs/blocks/preformatted.test.js | 3 + .../specs/plugins/container-blocks.test.js | 1 + packages/edit-post/src/editor.js | 93 +- packages/editor/package.json | 1 + .../editor/src/components/block-list/block.js | 7 +- .../src/components/post-text-editor/index.js | 6 +- .../editor/src/components/provider/index.js | 135 +- .../editor/src/components/rich-text/index.js | 10 +- packages/editor/src/index.js | 1 + packages/editor/src/store/actions.js | 554 +-- packages/editor/src/store/controls.js | 20 +- packages/editor/src/store/defaults.js | 130 - packages/editor/src/store/effects.js | 159 +- .../src/store/effects/reusable-blocks.js | 41 +- .../src/store/effects/test/reusable-blocks.js | 157 +- packages/editor/src/store/reducer.js | 618 +-- packages/editor/src/store/selectors.js | 1844 ++------- packages/editor/src/store/test/actions.js | 328 -- packages/editor/src/store/test/effects.js | 306 +- packages/editor/src/store/test/reducer.js | 2143 ++-------- packages/editor/src/store/test/selectors.js | 3447 ++--------------- 52 files changed, 10511 insertions(+), 9787 deletions(-) create mode 100644 docs/designers-developers/developers/data/data-core-block-editor.md create mode 100644 packages/block-editor/.npmrc create mode 100644 packages/block-editor/CHANGELOG.md create mode 100644 packages/block-editor/README.md create mode 100644 packages/block-editor/package.json create mode 100644 packages/block-editor/src/components/index.js create mode 100644 packages/block-editor/src/components/provider/index.js create mode 100644 packages/block-editor/src/index.js create mode 100644 packages/block-editor/src/store/actions.js rename packages/{editor => block-editor}/src/store/array.js (100%) create mode 100644 packages/block-editor/src/store/controls.js create mode 100644 packages/block-editor/src/store/defaults.js create mode 100644 packages/block-editor/src/store/effects.js create mode 100644 packages/block-editor/src/store/index.js create mode 100644 packages/block-editor/src/store/middlewares.js create mode 100644 packages/block-editor/src/store/reducer.js create mode 100644 packages/block-editor/src/store/selectors.js create mode 100644 packages/block-editor/src/store/test/actions.js rename packages/{editor => block-editor}/src/store/test/array.js (100%) create mode 100644 packages/block-editor/src/store/test/effects.js create mode 100644 packages/block-editor/src/store/test/reducer.js create mode 100644 packages/block-editor/src/store/test/selectors.js diff --git a/docs/designers-developers/developers/data/README.md b/docs/designers-developers/developers/data/README.md index 7ba7f9264e8bc..7408d171144cf 100644 --- a/docs/designers-developers/developers/data/README.md +++ b/docs/designers-developers/developers/data/README.md @@ -3,7 +3,8 @@ - [**core**: WordPress Core Data](/docs/designers-developers/developers/data/data-core.md) - [**core/annotations**: Annotations](/docs/designers-developers/developers/data/data-core-annotations.md) - [**core/blocks**: Block Types Data](/docs/designers-developers/developers/data/data-core-blocks.md) - - [**core/editor**: The Editor’s Data](/docs/designers-developers/developers/data/data-core-editor.md) + - [**core/block-editor**: The Block Editor’s Data](/docs/designers-developers/developers/data/data-core-block-editor.md) + - [**core/editor**: The Post Editor’s Data](/docs/designers-developers/developers/data/data-core-editor.md) - [**core/edit-post**: The Editor’s UI Data](/docs/designers-developers/developers/data/data-core-edit-post.md) - [**core/notices**: Notices Data](/docs/designers-developers/developers/data/data-core-notices.md) - [**core/nux**: The NUX (New User Experience) Data](/docs/designers-developers/developers/data/data-core-nux.md) diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md new file mode 100644 index 0000000000000..3166400bd3fbf --- /dev/null +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -0,0 +1,1050 @@ +# **core/block-editor**: The Block Editor’s Data + +## Selectors + +### getBlockDependantsCacheBust + +Returns a new reference when the inner blocks of a given block client ID +change. This is used exclusively as a memoized selector dependant, relying +on this selector's shared return value and recursively those of its inner +blocks defined as dependencies. This abuses mechanics of the selector +memoization to return from the original selector function only when +dependants change. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +### getBlockName + +Returns a block's name given its client ID, or null if no block exists with +the client ID. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block name. + +### isBlockValid + +Returns whether a block is valid or not. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Is Valid. + +### getBlockAttributes + +Returns a block's attributes given its client ID, or null if no block exists with +the client ID. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block attributes. + +### getBlock + +Returns a block given its client ID. This is a parsed copy of the block, +containing its `blockName`, `clientId`, and current `attributes` state. This +is not the block's registration settings, which must be retrieved from the +blocks module registration store. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Parsed block object. + +### getBlocks + +Returns all block objects for the current post being edited as an array in +the order they appear in the post. + +Note: It's important to memoize this selector to avoid return a new instance +on each call + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Post blocks. + +### getClientIdsOfDescendants + +Returns an array containing the clientIds of all descendants +of the blocks given. + +*Parameters* + + * state: Global application state. + * clientIds: Array of blocks to inspect. + +*Returns* + +ids of descendants. + +### getClientIdsWithDescendants + +Returns an array containing the clientIds of the top-level blocks +and their descendants of any depth (for nested blocks). + +*Parameters* + + * state: Global application state. + +*Returns* + +ids of top-level and descendant blocks. + +### getGlobalBlockCount + +Returns the total number of blocks, or the total number of blocks with a specific name in a post. +The number returned includes nested blocks. + +*Parameters* + + * state: Global application state. + * blockName: Optional block name, if specified only blocks of that type will be counted. + +*Returns* + +Number of blocks in the post, or number of blocks with name equal to blockName. + +### getBlocksByClientId + +Given an array of block client IDs, returns the corresponding array of block +objects. + +*Parameters* + + * state: Editor state. + * clientIds: Client IDs for which blocks are to be returned. + +*Returns* + +Block objects. + +### getBlockCount + +Returns the number of blocks currently present in the post. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Number of blocks in the post. + +### getBlockSelectionStart + +Returns the current block selection start. This value may be null, and it +may represent either a singular block selection or multi-selection start. +A selection is singular if its start and end match. + +*Parameters* + + * state: Global application state. + +*Returns* + +Client ID of block selection start. + +### getBlockSelectionEnd + +Returns the current block selection end. This value may be null, and it +may represent either a singular block selection or multi-selection end. +A selection is singular if its start and end match. + +*Parameters* + + * state: Global application state. + +*Returns* + +Client ID of block selection end. + +### getSelectedBlockCount + +Returns the number of blocks currently selected in the post. + +*Parameters* + + * state: Global application state. + +*Returns* + +Number of blocks selected in the post. + +### hasSelectedBlock + +Returns true if there is a single selected block, or false otherwise. + +*Parameters* + + * state: Editor state. + +*Returns* + +Whether a single block is selected. + +### getSelectedBlockClientId + +Returns the currently selected block client ID, or null if there is no +selected block. + +*Parameters* + + * state: Editor state. + +*Returns* + +Selected block client ID. + +### getSelectedBlock + +Returns the currently selected block, or null if there is no selected block. + +*Parameters* + + * state: Global application state. + +*Returns* + +Selected block. + +### getBlockRootClientId + +Given a block client ID, returns the root block from which the block is +nested, an empty string for top-level blocks, or null if the block does not +exist. + +*Parameters* + + * state: Editor state. + * clientId: Block from which to find root client ID. + +*Returns* + +Root client ID, if exists + +### getBlockHierarchyRootClientId + +Given a block client ID, returns the root of the hierarchy from which the block is nested, return the block itself for root level blocks. + +*Parameters* + + * state: Editor state. + * clientId: Block from which to find root client ID. + +*Returns* + +Root client ID + +### getAdjacentBlockClientId + +Returns the client ID of the block adjacent one at the given reference +startClientId and modifier directionality. Defaults start startClientId to +the selected block, and direction as next block. Returns null if there is no +adjacent block. + +*Parameters* + + * state: Editor state. + * startClientId: Optional client ID of block from which to + search. + * modifier: Directionality multiplier (1 next, -1 + previous). + +*Returns* + +Return the client ID of the block, or null if none exists. + +### getPreviousBlockClientId + +Returns the previous block's client ID from the given reference start ID. +Defaults start to the selected block. Returns null if there is no previous +block. + +*Parameters* + + * state: Editor state. + * startClientId: Optional client ID of block from which to + search. + +*Returns* + +Adjacent block's client ID, or null if none exists. + +### getNextBlockClientId + +Returns the next block's client ID from the given reference start ID. +Defaults start to the selected block. Returns null if there is no next +block. + +*Parameters* + + * state: Editor state. + * startClientId: Optional client ID of block from which to + search. + +*Returns* + +Adjacent block's client ID, or null if none exists. + +### getSelectedBlocksInitialCaretPosition + +Returns the initial caret position for the selected block. +This position is to used to position the caret properly when the selected block changes. + +*Parameters* + + * state: Global application state. + +*Returns* + +Selected block. + +### getMultiSelectedBlockClientIds + +Returns the current multi-selection set of block client IDs, or an empty +array if there is no multi-selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +Multi-selected block client IDs. + +### getMultiSelectedBlocks + +Returns the current multi-selection set of blocks, or an empty array if +there is no multi-selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +Multi-selected block objects. + +### getFirstMultiSelectedBlockClientId + +Returns the client ID of the first block in the multi-selection set, or null +if there is no multi-selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +First block client ID in the multi-selection set. + +### getLastMultiSelectedBlockClientId + +Returns the client ID of the last block in the multi-selection set, or null +if there is no multi-selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +Last block client ID in the multi-selection set. + +### isFirstMultiSelectedBlock + +Returns true if a multi-selection exists, and the block corresponding to the +specified client ID is the first block of the multi-selection set, or false +otherwise. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is first in multi-selection. + +### isBlockMultiSelected + +Returns true if the client ID occurs within the block multi-selection, or +false otherwise. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is in multi-selection set. + +### isAncestorMultiSelected + +Returns true if an ancestor of the block is multi-selected, or false +otherwise. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether an ancestor of the block is in multi-selection + set. + +### getMultiSelectedBlocksStartClientId + +Returns the client ID of the block which begins the multi-selection set, or +null if there is no multi-selection. + +This is not necessarily the first client ID in the selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +Client ID of block beginning multi-selection. + +### getMultiSelectedBlocksEndClientId + +Returns the client ID of the block which ends the multi-selection set, or +null if there is no multi-selection. + +This is not necessarily the last client ID in the selection. + +*Parameters* + + * state: Editor state. + +*Returns* + +Client ID of block ending multi-selection. + +### getBlockOrder + +Returns an array containing all block client IDs in the editor in the order +they appear. Optionally accepts a root client ID of the block list for which +the order should be returned, defaulting to the top-level block order. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Ordered client IDs of editor blocks. + +### getBlockIndex + +Returns the index at which the block corresponding to the specified client +ID occurs within the block order, or `-1` if the block does not exist. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Index at which block exists in order. + +### isBlockSelected + +Returns true if the block corresponding to the specified client ID is +currently selected and no multi-selection exists, or false otherwise. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is selected and multi-selection exists. + +### hasSelectedInnerBlock + +Returns true if one of the block's inner blocks is selected. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + * deep: Perform a deep check. + +*Returns* + +Whether the block as an inner block selected + +### isBlockWithinSelection + +Returns true if the block corresponding to the specified client ID is +currently selected but isn't the last of the selected blocks. Here "last" +refers to the block sequence in the document, _not_ the sequence of +multi-selection, which is why `state.blockSelection.end` isn't used. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Whether block is selected and not the last in the + selection. + +### hasMultiSelection + +Returns true if a multi-selection has been made, or false otherwise. + +*Parameters* + + * state: Editor state. + +*Returns* + +Whether multi-selection has been made. + +### isMultiSelecting + +Whether in the process of multi-selecting or not. This flag is only true +while the multi-selection is being selected (by mouse move), and is false +once the multi-selection has been settled. + +*Parameters* + + * state: Global application state. + +*Returns* + +True if multi-selecting, false if not. + +### isSelectionEnabled + +Selector that returns if multi-selection is enabled or not. + +*Parameters* + + * state: Global application state. + +*Returns* + +True if it should be possible to multi-select blocks, false if multi-selection is disabled. + +### getBlockMode + +Returns the block's editing mode, defaulting to "visual" if not explicitly +assigned. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block editing mode. + +### isTyping + +Returns true if the user is typing, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether user is typing. + +### isCaretWithinFormattedText + +Returns true if the caret is within formatted text, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the caret is within formatted text. + +### getBlockInsertionPoint + +Returns the insertion point, the index at which the new inserted block would +be placed. Defaults to the last index. + +*Parameters* + + * state: Editor state. + +*Returns* + +Insertion point object with `rootClientId`, `index`. + +### isBlockInsertionPointVisible + +Returns true if we should show the block insertion point. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the insertion point is visible or not. + +### isValidTemplate + +Returns whether the blocks matches the template or not. + +*Parameters* + + * state: null + +*Returns* + +Whether the template is valid or not. + +### getTemplate + +Returns the defined block template + +*Parameters* + + * state: null + +*Returns* + +Block Template + +### getTemplateLock + +Returns the defined block template lock. Optionally accepts a root block +client ID as context, otherwise defaulting to the global context. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional block root client ID. + +*Returns* + +Block Template Lock + +### canInsertBlockType + +Determines if the given block type is allowed to be inserted into the block list. + +*Parameters* + + * state: Editor state. + * blockName: The name of the block type, e.g.' core/paragraph'. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Whether the given block type is allowed to be inserted. + +### getInserterItems + +Determines the items that appear in the inserter. Includes both static +items (e.g. a regular block type) and dynamic items (e.g. a reusable block). + +Each item object contains what's necessary to display a button in the +inserter and handle its selection. + +The 'utility' property indicates how useful we think an item will be to the +user. There are 4 levels of utility: + +1. Blocks that are contextually useful (utility = 3) +2. Blocks that have been previously inserted (utility = 2) +3. Blocks that are in the common category (utility = 1) +4. All other blocks (utility = 0) + +The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) +that combines block usage frequenty and recency. + +Items are returned ordered descendingly by their 'utility' and 'frecency'. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Items that appear in inserter. + +### hasInserterItems + +Determines whether there are items to show in the inserter. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Items that appear in inserter. + +### getBlockListSettings + +Returns the Block List settings of a block, if any exist. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Block settings of the block if set. + +### getEditorSettings + +Returns the editor settings. + +*Parameters* + + * state: Editor state. + +*Returns* + +The editor settings object. + +### isLastBlockChangePersistent + +Returns true if the most recent block change is be considered persistent, or +false otherwise. A persistent change is one committed by BlockEditorProvider +via its `onChange` callback, in addition to `onInput`. + +*Parameters* + + * state: Block editor state. + +*Returns* + +Whether the most recent block change was persistent. + +## Actions + +### resetBlocks + +Returns an action object used in signalling that blocks state should be +reset to the specified array of blocks, taking precedence over any other +content reflected as an edit in state. + +*Parameters* + + * blocks: Array of blocks. + +### receiveBlocks + +Returns an action object used in signalling that blocks have been received. +Unlike resetBlocks, these should be appended to the existing known set, not +replacing. + +*Parameters* + + * blocks: Array of block objects. + +### updateBlockAttributes + +Returns an action object used in signalling that the block attributes with +the specified client ID has been updated. + +*Parameters* + + * clientId: Block client ID. + * attributes: Block attributes to be merged. + +### updateBlock + +Returns an action object used in signalling that the block with the +specified client ID has been updated. + +*Parameters* + + * clientId: Block client ID. + * updates: Block attributes to be merged. + +### selectBlock + +Returns an action object used in signalling that the block with the +specified client ID has been selected, optionally accepting a position +value reflecting its selection directionality. An initialPosition of -1 +reflects a reverse selection. + +*Parameters* + + * clientId: Block client ID. + * initialPosition: Optional initial position. Pass as -1 to + reflect reverse selection. + +### selectPreviousBlock + +Yields action objects used in signalling that the block preceding the given +clientId should be selected. + +*Parameters* + + * clientId: Block client ID. + +### selectNextBlock + +Yields action objects used in signalling that the block following the given +clientId should be selected. + +*Parameters* + + * clientId: Block client ID. + +### startMultiSelect + +Returns an action object used in signalling that a block multi-selection has started. + +### stopMultiSelect + +Returns an action object used in signalling that block multi-selection stopped. + +### multiSelect + +Returns an action object used in signalling that block multi-selection changed. + +*Parameters* + + * start: First block of the multi selection. + * end: Last block of the multiselection. + +### clearSelectedBlock + +Returns an action object used in signalling that the block selection is cleared. + +### toggleSelection + +Returns an action object that enables or disables block selection. + +*Parameters* + + * boolean: [isSelectionEnabled=true] Whether block selection should + be enabled. + +### replaceBlocks + +Returns an action object signalling that a blocks should be replaced with +one or more replacement blocks. + +*Parameters* + + * clientIds: Block client ID(s) to replace. + * blocks: Replacement block(s). + +### replaceBlock + +Returns an action object signalling that a single block should be replaced +with one or more replacement blocks. + +*Parameters* + + * clientId: Block client ID to replace. + * block: Replacement block(s). + +### moveBlockToPosition + +Returns an action object signalling that an indexed block should be moved +to a new index. + +*Parameters* + + * clientId: The client ID of the block. + * fromRootClientId: Root client ID source. + * toRootClientId: Root client ID destination. + * index: The index to move the block into. + +### insertBlock + +Returns an action object used in signalling that a single block should be +inserted, optionally at a specific index respective a root block list. + +*Parameters* + + * block: Block object to insert. + * index: Index at which block should be inserted. + * rootClientId: Optional root client ID of block list on which to insert. + * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + +### insertBlocks + +Returns an action object used in signalling that an array of blocks should +be inserted, optionally at a specific index respective a root block list. + +*Parameters* + + * blocks: Block objects to insert. + * index: Index at which block should be inserted. + * rootClientId: Optional root client ID of block list on which to insert. + * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + +### showInsertionPoint + +Returns an action object used in signalling that the insertion point should +be shown. + +*Parameters* + + * rootClientId: Optional root client ID of block list on + which to insert. + * index: Index at which block should be inserted. + +### hideInsertionPoint + +Returns an action object hiding the insertion point. + +### setTemplateValidity + +Returns an action object resetting the template validity. + +*Parameters* + + * isValid: template validity flag. + +### synchronizeTemplate + +Returns an action object synchronize the template with the list of blocks + +### mergeBlocks + +Returns an action object used in signalling that two blocks should be merged + +*Parameters* + + * firstBlockClientId: Client ID of the first block to merge. + * secondBlockClientId: Client ID of the second block to merge. + +### removeBlocks + +Yields action objects used in signalling that the blocks corresponding to +the set of specified client IDs are to be removed. + +*Parameters* + + * clientIds: Client IDs of blocks to remove. + * selectPrevious: True if the previous block should be + selected when a block is removed. + +### removeBlock + +Returns an action object used in signalling that the block with the +specified client ID is to be removed. + +*Parameters* + + * clientId: Client ID of block to remove. + * selectPrevious: True if the previous block should be + selected when a block is removed. + +### toggleBlockMode + +Returns an action object used to toggle the block editing mode between +visual and HTML modes. + +*Parameters* + + * clientId: Block client ID. + +### startTyping + +Returns an action object used in signalling that the user has begun to type. + +### stopTyping + +Returns an action object used in signalling that the user has stopped typing. + +### enterFormattedText + +Returns an action object used in signalling that the caret has entered formatted text. + +### exitFormattedText + +Returns an action object used in signalling that the user caret has exited formatted text. + +### insertDefaultBlock + +Returns an action object used in signalling that a new block of the default +type should be added to the block list. + +*Parameters* + + * attributes: Optional attributes of the block to assign. + * rootClientId: Optional root client ID of block list on which + to append. + * index: Optional index where to insert the default block + +### updateBlockListSettings + +Returns an action object that changes the nested settings of a given block. + +*Parameters* + + * clientId: Client ID of the block whose nested setting are + being received. + * settings: Object with the new settings for the nested block. + +### updateEditorSettings + +Returns an action object used in signalling that the editor settings have been updated. + +*Parameters* + + * settings: Updated settings + +### __unstableSaveReusableBlock + +Returns an action object used in signalling that a temporary reusable blocks have been saved +in order to switch its temporary id with the real id. + +*Parameters* + + * id: Reusable block's id. + * updatedId: Updated block's id. + +### __unstableMarkLastChangeAsPersistent + +Returns an action object used in signalling that the last block change should be marked explicitely as persistent. \ No newline at end of file diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 96e65b41659de..7e95832dc5bdb 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -1,4 +1,4 @@ -# **core/editor**: The Editor’s Data +# **core/editor**: The Post Editor’s Data ## Selectors @@ -365,676 +365,6 @@ and modified date are the same. Whether the edited post has a floating date value. -### getBlockDependantsCacheBust - -Returns a new reference when the inner blocks of a given block client ID -change. This is used exclusively as a memoized selector dependant, relying -on this selector's shared return value and recursively those of its inner -blocks defined as dependencies. This abuses mechanics of the selector -memoization to return from the original selector function only when -dependants change. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -A value whose reference will change only when inner blocks of - the given block client ID change. - -### getBlockName - -Returns a block's name given its client ID, or null if no block exists with -the client ID. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block name. - -### isBlockValid - -Returns whether a block is valid or not. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Is Valid. - -### getBlockAttributes - -Returns a block's attributes given its client ID, or null if no block exists with -the client ID. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block attributes. - -### getBlock - -Returns a block given its client ID. This is a parsed copy of the block, -containing its `blockName`, `clientId`, and current `attributes` state. This -is not the block's registration settings, which must be retrieved from the -blocks module registration store. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Parsed block object. - -### getBlocks - -Returns all block objects for the current post being edited as an array in -the order they appear in the post. - -Note: It's important to memoize this selector to avoid return a new instance -on each call - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Post blocks. - -### getClientIdsOfDescendants - -Returns an array containing the clientIds of all descendants -of the blocks given. - -*Parameters* - - * state: Global application state. - * clientIds: Array of blocks to inspect. - -*Returns* - -ids of descendants. - -### getClientIdsWithDescendants - -Returns an array containing the clientIds of the top-level blocks -and their descendants of any depth (for nested blocks). - -*Parameters* - - * state: Global application state. - -*Returns* - -ids of top-level and descendant blocks. - -### getGlobalBlockCount - -Returns the total number of blocks, or the total number of blocks with a specific name in a post. -The number returned includes nested blocks. - -*Parameters* - - * state: Global application state. - * blockName: Optional block name, if specified only blocks of that type will be counted. - -*Returns* - -Number of blocks in the post, or number of blocks with name equal to blockName. - -### getBlocksByClientId - -Given an array of block client IDs, returns the corresponding array of block -objects. - -*Parameters* - - * state: Editor state. - * clientIds: Client IDs for which blocks are to be returned. - -*Returns* - -Block objects. - -### getBlockCount - -Returns the number of blocks currently present in the post. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Number of blocks in the post. - -### getBlockSelectionStart - -Returns the current block selection start. This value may be null, and it -may represent either a singular block selection or multi-selection start. -A selection is singular if its start and end match. - -*Parameters* - - * state: Global application state. - -*Returns* - -Client ID of block selection start. - -### getBlockSelectionEnd - -Returns the current block selection end. This value may be null, and it -may represent either a singular block selection or multi-selection end. -A selection is singular if its start and end match. - -*Parameters* - - * state: Global application state. - -*Returns* - -Client ID of block selection end. - -### getSelectedBlockCount - -Returns the number of blocks currently selected in the post. - -*Parameters* - - * state: Global application state. - -*Returns* - -Number of blocks selected in the post. - -### hasSelectedBlock - -Returns true if there is a single selected block, or false otherwise. - -*Parameters* - - * state: Editor state. - -*Returns* - -Whether a single block is selected. - -### getSelectedBlockClientId - -Returns the currently selected block client ID, or null if there is no -selected block. - -*Parameters* - - * state: Editor state. - -*Returns* - -Selected block client ID. - -### getSelectedBlock - -Returns the currently selected block, or null if there is no selected block. - -*Parameters* - - * state: Global application state. - -*Returns* - -Selected block. - -### getBlockRootClientId - -Given a block client ID, returns the root block from which the block is -nested, an empty string for top-level blocks, or null if the block does not -exist. - -*Parameters* - - * state: Editor state. - * clientId: Block from which to find root client ID. - -*Returns* - -Root client ID, if exists - -### getBlockHierarchyRootClientId - -Given a block client ID, returns the root of the hierarchy from which the block is nested, return the block itself for root level blocks. - -*Parameters* - - * state: Editor state. - * clientId: Block from which to find root client ID. - -*Returns* - -Root client ID - -### getAdjacentBlockClientId - -Returns the client ID of the block adjacent one at the given reference -startClientId and modifier directionality. Defaults start startClientId to -the selected block, and direction as next block. Returns null if there is no -adjacent block. - -*Parameters* - - * state: Editor state. - * startClientId: Optional client ID of block from which to - search. - * modifier: Directionality multiplier (1 next, -1 - previous). - -*Returns* - -Return the client ID of the block, or null if none exists. - -### getPreviousBlockClientId - -Returns the previous block's client ID from the given reference start ID. -Defaults start to the selected block. Returns null if there is no previous -block. - -*Parameters* - - * state: Editor state. - * startClientId: Optional client ID of block from which to - search. - -*Returns* - -Adjacent block's client ID, or null if none exists. - -### getNextBlockClientId - -Returns the next block's client ID from the given reference start ID. -Defaults start to the selected block. Returns null if there is no next -block. - -*Parameters* - - * state: Editor state. - * startClientId: Optional client ID of block from which to - search. - -*Returns* - -Adjacent block's client ID, or null if none exists. - -### getSelectedBlocksInitialCaretPosition - -Returns the initial caret position for the selected block. -This position is to used to position the caret properly when the selected block changes. - -*Parameters* - - * state: Global application state. - -*Returns* - -Selected block. - -### getMultiSelectedBlockClientIds - -Returns the current multi-selection set of block client IDs, or an empty -array if there is no multi-selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -Multi-selected block client IDs. - -### getMultiSelectedBlocks - -Returns the current multi-selection set of blocks, or an empty array if -there is no multi-selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -Multi-selected block objects. - -### getFirstMultiSelectedBlockClientId - -Returns the client ID of the first block in the multi-selection set, or null -if there is no multi-selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -First block client ID in the multi-selection set. - -### getLastMultiSelectedBlockClientId - -Returns the client ID of the last block in the multi-selection set, or null -if there is no multi-selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -Last block client ID in the multi-selection set. - -### isFirstMultiSelectedBlock - -Returns true if a multi-selection exists, and the block corresponding to the -specified client ID is the first block of the multi-selection set, or false -otherwise. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is first in multi-selection. - -### isBlockMultiSelected - -Returns true if the client ID occurs within the block multi-selection, or -false otherwise. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is in multi-selection set. - -### isAncestorMultiSelected - -Returns true if an ancestor of the block is multi-selected, or false -otherwise. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether an ancestor of the block is in multi-selection - set. - -### getMultiSelectedBlocksStartClientId - -Returns the client ID of the block which begins the multi-selection set, or -null if there is no multi-selection. - -This is not necessarily the first client ID in the selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -Client ID of block beginning multi-selection. - -### getMultiSelectedBlocksEndClientId - -Returns the client ID of the block which ends the multi-selection set, or -null if there is no multi-selection. - -This is not necessarily the last client ID in the selection. - -*Parameters* - - * state: Editor state. - -*Returns* - -Client ID of block ending multi-selection. - -### getBlockOrder - -Returns an array containing all block client IDs in the editor in the order -they appear. Optionally accepts a root client ID of the block list for which -the order should be returned, defaulting to the top-level block order. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Ordered client IDs of editor blocks. - -### getBlockIndex - -Returns the index at which the block corresponding to the specified client -ID occurs within the block order, or `-1` if the block does not exist. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Index at which block exists in order. - -### isBlockSelected - -Returns true if the block corresponding to the specified client ID is -currently selected and no multi-selection exists, or false otherwise. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is selected and multi-selection exists. - -### hasSelectedInnerBlock - -Returns true if one of the block's inner blocks is selected. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - * deep: Perform a deep check. - -*Returns* - -Whether the block as an inner block selected - -### isBlockWithinSelection - -Returns true if the block corresponding to the specified client ID is -currently selected but isn't the last of the selected blocks. Here "last" -refers to the block sequence in the document, _not_ the sequence of -multi-selection, which is why `state.blockSelection.end` isn't used. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Whether block is selected and not the last in the - selection. - -### hasMultiSelection - -Returns true if a multi-selection has been made, or false otherwise. - -*Parameters* - - * state: Editor state. - -*Returns* - -Whether multi-selection has been made. - -### isMultiSelecting - -Whether in the process of multi-selecting or not. This flag is only true -while the multi-selection is being selected (by mouse move), and is false -once the multi-selection has been settled. - -*Parameters* - - * state: Global application state. - -*Returns* - -True if multi-selecting, false if not. - -### isSelectionEnabled - -Selector that returns if multi-selection is enabled or not. - -*Parameters* - - * state: Global application state. - -*Returns* - -True if it should be possible to multi-select blocks, false if multi-selection is disabled. - -### getBlockMode - -Returns the block's editing mode, defaulting to "visual" if not explicitly -assigned. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block editing mode. - -### isTyping - -Returns true if the user is typing, or false otherwise. - -*Parameters* - - * state: Global application state. - -*Returns* - -Whether user is typing. - -### isCaretWithinFormattedText - -Returns true if the caret is within formatted text, or false otherwise. - -*Parameters* - - * state: Global application state. - -*Returns* - -Whether the caret is within formatted text. - -### getBlockInsertionPoint - -Returns the insertion point, the index at which the new inserted block would -be placed. Defaults to the last index. - -*Parameters* - - * state: Editor state. - -*Returns* - -Insertion point object with `rootClientId`, `index`. - -### isBlockInsertionPointVisible - -Returns true if we should show the block insertion point. - -*Parameters* - - * state: Global application state. - -*Returns* - -Whether the insertion point is visible or not. - -### isValidTemplate - -Returns whether the blocks matches the template or not. - -*Parameters* - - * state: null - -*Returns* - -Whether the template is valid or not. - -### getTemplate - -Returns the defined block template - -*Parameters* - - * state: null - -*Returns* - -Block Template - -### getTemplateLock - -Returns the defined block template lock. Optionally accepts a root block -client ID as context, otherwise defaulting to the global context. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional block root client ID. - -*Returns* - -Block Template Lock - ### isSavingPost Returns true if the post is currently being saved, or false otherwise. @@ -1149,63 +479,6 @@ before falling back to serialization of block state. Post content. -### canInsertBlockType - -Determines if the given block type is allowed to be inserted into the block list. - -*Parameters* - - * state: Editor state. - * blockName: The name of the block type, e.g.' core/paragraph'. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Whether the given block type is allowed to be inserted. - -### getInserterItems - -Determines the items that appear in the inserter. Includes both static -items (e.g. a regular block type) and dynamic items (e.g. a reusable block). - -Each item object contains what's necessary to display a button in the -inserter and handle its selection. - -The 'utility' property indicates how useful we think an item will be to the -user. There are 4 levels of utility: - -1. Blocks that are contextually useful (utility = 3) -2. Blocks that have been previously inserted (utility = 2) -3. Blocks that are in the common category (utility = 1) -4. All other blocks (utility = 0) - -The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) -that combines block usage frequenty and recency. - -Items are returned ordered descendingly by their 'utility' and 'frecency'. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Items that appear in inserter. - -### hasInserterItems - -Determines whether there are items to show in the inserter. - -*Parameters* - - * state: Editor state. - * rootClientId: Optional root client ID of block list. - -*Returns* - -Items that appear in inserter. - ### __experimentalGetReusableBlock Returns the reusable block with the given ID. @@ -1336,44 +609,6 @@ before state satisfies the given predicate function. Whether predicate matches for some history. -### getBlockListSettings - -Returns the Block List settings of a block, if any exist. - -*Parameters* - - * state: Editor state. - * clientId: Block client ID. - -*Returns* - -Block settings of the block if set. - -### getEditorSettings - -Returns the editor settings. - -*Parameters* - - * state: Editor state. - -*Returns* - -The editor settings object. - -### getTokenSettings - -Returns the token settings. - -*Parameters* - - * state: Editor state. - * name: Token name. - -*Returns* - -Token settings object, or the named token settings object if set. - ### isPostLocked Returns whether the post is locked. @@ -1459,6 +694,30 @@ or skipped when the user clicks the "publish" button. Whether the pre-publish panel should be shown or not. +### getEditorBlocks + +Return the current block list. + +*Parameters* + + * state: null + +*Returns* + +Block list. + +### __unstableIsEditorReady + +Is the editor ready + +*Parameters* + + * state: null + +*Returns* + +is Ready. + ## Actions ### setupEditor @@ -1470,6 +729,7 @@ the specified post object and editor settings. * post: Post object. * edits: Initial edited attributes object. + * template: Block Template. ### resetPost @@ -1505,170 +765,6 @@ Returns an action object used to setup the editor state when first opening an ed *Parameters* * post: Post object. - * blocks: Array of blocks. - -### resetBlocks - -Returns an action object used in signalling that blocks state should be -reset to the specified array of blocks, taking precedence over any other -content reflected as an edit in state. - -*Parameters* - - * blocks: Array of blocks. - -### receiveBlocks - -Returns an action object used in signalling that blocks have been received. -Unlike resetBlocks, these should be appended to the existing known set, not -replacing. - -*Parameters* - - * blocks: Array of block objects. - -### updateBlockAttributes - -Returns an action object used in signalling that the block attributes with -the specified client ID has been updated. - -*Parameters* - - * clientId: Block client ID. - * attributes: Block attributes to be merged. - -### updateBlock - -Returns an action object used in signalling that the block with the -specified client ID has been updated. - -*Parameters* - - * clientId: Block client ID. - * updates: Block attributes to be merged. - -### selectBlock - -Returns an action object used in signalling that the block with the -specified client ID has been selected, optionally accepting a position -value reflecting its selection directionality. An initialPosition of -1 -reflects a reverse selection. - -*Parameters* - - * clientId: Block client ID. - * initialPosition: Optional initial position. Pass as -1 to - reflect reverse selection. - -### selectPreviousBlock - -Yields action objects used in signalling that the block preceding the given -clientId should be selected. - -*Parameters* - - * clientId: Block client ID. - -### selectNextBlock - -Yields action objects used in signalling that the block following the given -clientId should be selected. - -*Parameters* - - * clientId: Block client ID. - -### toggleSelection - -Returns an action object that enables or disables block selection. - -*Parameters* - - * boolean: [isSelectionEnabled=true] Whether block selection should - be enabled. - -### replaceBlocks - -Returns an action object signalling that a blocks should be replaced with -one or more replacement blocks. - -*Parameters* - - * clientIds: Block client ID(s) to replace. - * blocks: Replacement block(s). - -### replaceBlock - -Returns an action object signalling that a single block should be replaced -with one or more replacement blocks. - -*Parameters* - - * clientId: Block client ID to replace. - * block: Replacement block(s). - -### moveBlockToPosition - -Returns an action object signalling that an indexed block should be moved -to a new index. - -*Parameters* - - * clientId: The client ID of the block. - * fromRootClientId: Root client ID source. - * toRootClientId: Root client ID destination. - * index: The index to move the block into. - -### insertBlock - -Returns an action object used in signalling that a single block should be -inserted, optionally at a specific index respective a root block list. - -*Parameters* - - * block: Block object to insert. - * index: Index at which block should be inserted. - * rootClientId: Optional root client ID of block list on which to insert. - * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. - -### insertBlocks - -Returns an action object used in signalling that an array of blocks should -be inserted, optionally at a specific index respective a root block list. - -*Parameters* - - * blocks: Block objects to insert. - * index: Index at which block should be inserted. - * rootClientId: Optional root client ID of block list on which to insert. - * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. - -### showInsertionPoint - -Returns an action object used in signalling that the insertion point should -be shown. - -*Parameters* - - * rootClientId: Optional root client ID of block list on - which to insert. - * index: Index at which block should be inserted. - -### hideInsertionPoint - -Returns an action object hiding the insertion point. - -### setTemplateValidity - -Returns an action object resetting the template validity. - -*Parameters* - - * isValid: template validity flag. - -### synchronizeTemplate - -Returns an action object synchronize the template with the list of blocks ### editPost @@ -1688,15 +784,6 @@ Returns an action object to save the post. * options: Options for the save. * options.isAutosave: Perform an autosave if true. -### mergeBlocks - -Returns an action object used in signalling that two blocks should be merged - -*Parameters* - - * firstBlockClientId: Client ID of the first block to merge. - * secondBlockClientId: Client ID of the second block to merge. - ### autosave Returns an action object used in signalling that the post should autosave. @@ -1719,53 +806,6 @@ Returns an action object used in signalling that undo history should pop. Returns an action object used in signalling that undo history record should be created. -### removeBlocks - -Yields action objects used in signalling that the blocks corresponding to -the set of specified client IDs are to be removed. - -*Parameters* - - * clientIds: Client IDs of blocks to remove. - * selectPrevious: True if the previous block should be - selected when a block is removed. - -### removeBlock - -Returns an action object used in signalling that the block with the -specified client ID is to be removed. - -*Parameters* - - * clientId: Client ID of block to remove. - * selectPrevious: True if the previous block should be - selected when a block is removed. - -### toggleBlockMode - -Returns an action object used to toggle the block editing mode between -visual and HTML modes. - -*Parameters* - - * clientId: Block client ID. - -### startTyping - -Returns an action object used in signalling that the user has begun to type. - -### stopTyping - -Returns an action object used in signalling that the user has stopped typing. - -### enterFormattedText - -Returns an action object used in signalling that the caret has entered formatted text. - -### exitFormattedText - -Returns an action object used in signalling that the user caret has exited formatted text. - ### updatePostLock Returns an action object used to lock the editor. @@ -1838,36 +878,6 @@ Returns an action object used to convert a static block into a reusable block. * clientIds: The client IDs of the block to detach. -### insertDefaultBlock - -Returns an action object used in signalling that a new block of the default -type should be added to the block list. - -*Parameters* - - * attributes: Optional attributes of the block to assign. - * rootClientId: Optional root client ID of block list on which - to append. - * index: Optional index where to insert the default block - -### updateBlockListSettings - -Returns an action object that changes the nested settings of a given block. - -*Parameters* - - * clientId: Client ID of the block whose nested setting are - being received. - * settings: Object with the new settings for the nested block. - -### updateEditorSettings - -Returns an action object used in signalling that the editor settings have been updated. - -*Parameters* - - * settings: Updated settings - ### enablePublishSidebar Returns an action object used in signalling that the user has enabled the publish sidebar. @@ -1890,4 +900,13 @@ Returns an action object used to signal that post saving is unlocked. *Parameters* - * lockName: The lock name. \ No newline at end of file + * lockName: The lock name. + +### resetEditorBlocks + +Returns an action object used to signal that the blocks have been updated. + +*Parameters* + + * blocks: Block Array. + * options: Optional options. \ No newline at end of file diff --git a/docs/manifest.json b/docs/manifest.json index 026790426f1ec..c6eccbce1d73d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -557,6 +557,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/blob/README.md", "parent": "packages" }, + { + "title": "@wordpress/block-editor", + "slug": "packages-block-editor", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/block-editor/README.md", + "parent": "packages" + }, { "title": "@wordpress/block-library", "slug": "packages-block-library", @@ -1248,7 +1254,13 @@ "parent": "data" }, { - "title": "The Editor’s Data", + "title": "The Block Editor’s Data", + "slug": "data-core-block-editor", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-block-editor.md", + "parent": "data" + }, + { + "title": "The Post Editor’s Data", "slug": "data-core-editor", "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-editor.md", "parent": "data" diff --git a/docs/tool/config.js b/docs/tool/config.js index 87900c4c624fa..6dd2341e3d3c8 100644 --- a/docs/tool/config.js +++ b/docs/tool/config.js @@ -25,8 +25,13 @@ module.exports = { selectors: [ path.resolve( root, 'packages/blocks/src/store/selectors.js' ) ], actions: [ path.resolve( root, 'packages/blocks/src/store/actions.js' ) ], }, + 'core/block-editor': { + title: 'The Block Editor’s Data', + selectors: [ path.resolve( root, 'packages/block-editor/src/store/selectors.js' ) ], + actions: [ path.resolve( root, 'packages/block-editor/src/store/actions.js' ) ], + }, 'core/editor': { - title: 'The Editor’s Data', + title: 'The Post Editor’s Data', selectors: [ path.resolve( root, 'packages/editor/src/store/selectors.js' ) ], actions: [ path.resolve( root, 'packages/editor/src/store/actions.js' ) ], }, diff --git a/lib/client-assets.php b/lib/client-assets.php index b34e428425e80..72d3522075a01 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -205,6 +205,7 @@ function gutenberg_register_scripts_and_styles() { ' wp.data', ' .use( wp.data.plugins.persistence, { storageKey: storageKey } )', ' .use( wp.data.plugins.controls );', + ' wp.data.plugins.persistence.__unstableMigrate( { storageKey: storageKey } );', '} )()', ) ) diff --git a/lib/packages-dependencies.php b/lib/packages-dependencies.php index 9da875e8df2cc..e2f135bda24d9 100644 --- a/lib/packages-dependencies.php +++ b/lib/packages-dependencies.php @@ -44,6 +44,15 @@ ), 'wp-block-serialization-default-parser' => array(), 'wp-block-serialization-spec-parser' => array(), + 'wp-block-editor' => array( + 'lodash', + 'wp-blocks', + 'wp-compose', + 'wp-components', + 'wp-data', + 'wp-element', + 'wp-i18n', + ), 'wp-blocks' => array( 'lodash', 'wp-autop', @@ -138,6 +147,7 @@ 'wp-a11y', 'wp-api-fetch', 'wp-blob', + 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', diff --git a/package-lock.json b/package-lock.json index 656ba9ae3cd0e..a5737be8fefbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2531,6 +2531,21 @@ "@babel/runtime": "^7.3.1" } }, + "@wordpress/block-editor": { + "version": "file:packages/block-editor", + "requires": { + "@babel/runtime": "^7.0.0", + "@wordpress/blocks": "file:packages/blocks", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "lodash": "^4.17.10", + "refx": "^3.0.0", + "rememo": "^3.0.0" + } + }, "@wordpress/block-library": { "version": "file:packages/block-library", "requires": { @@ -2763,6 +2778,7 @@ "@wordpress/a11y": "file:packages/a11y", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/blob": "file:packages/blob", + "@wordpress/block-editor": "file:packages/block-editor", "@wordpress/blocks": "file:packages/blocks", "@wordpress/components": "file:packages/components", "@wordpress/compose": "file:packages/compose", diff --git a/package.json b/package.json index 2299994e4f385..88a28a598a7f7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/autop": "file:packages/autop", "@wordpress/blob": "file:packages/blob", + "@wordpress/block-editor": "file:packages/block-editor", "@wordpress/block-library": "file:packages/block-library", "@wordpress/block-serialization-default-parser": "file:packages/block-serialization-default-parser", "@wordpress/block-serialization-spec-parser": "file:packages/block-serialization-spec-parser", diff --git a/packages/block-editor/.npmrc b/packages/block-editor/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/packages/block-editor/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md new file mode 100644 index 0000000000000..b5527e39e4e7c --- /dev/null +++ b/packages/block-editor/CHANGELOG.md @@ -0,0 +1,5 @@ +## 1.0.0 (Unreleased) + +### New Features + +- Initial version. diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md new file mode 100644 index 0000000000000..9e07cf10eb2ce --- /dev/null +++ b/packages/block-editor/README.md @@ -0,0 +1,13 @@ +# Block Editor + +Generic Block Editor Module. + +## Installation + +Install the module + +```bash +npm install @wordpress/block-editor --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json new file mode 100644 index 0000000000000..d4f3cf7f757c7 --- /dev/null +++ b/packages/block-editor/package.json @@ -0,0 +1,38 @@ +{ + "name": "@wordpress/block-editor", + "version": "1.0.0-alpha.0", + "description": "Generic Block Editor.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "editor", + "blocks" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/block-editor/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@babel/runtime": "^7.0.0", + "@wordpress/blocks": "file:../blocks", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "lodash": "^4.17.10", + "refx": "^3.0.0", + "rememo": "^3.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js new file mode 100644 index 0000000000000..cb2cca8f110ba --- /dev/null +++ b/packages/block-editor/src/components/index.js @@ -0,0 +1 @@ +export { default as BlockEditorProvider } from './provider'; diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js new file mode 100644 index 0000000000000..a1d3063962b5d --- /dev/null +++ b/packages/block-editor/src/components/provider/index.js @@ -0,0 +1,152 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { DropZoneProvider, SlotFillProvider } from '@wordpress/components'; +import { withDispatch, RegistryConsumer } from '@wordpress/data'; +import { createHigherOrderComponent, compose } from '@wordpress/compose'; + +/** + * Higher-order component which renders the original component with the current + * registry context passed as its `registry` prop. + * + * @param {WPComponent} OriginalComponent Original component. + * + * @return {WPComponent} Enhanced component. + */ +const withRegistry = createHigherOrderComponent( + ( OriginalComponent ) => ( props ) => ( + + { ( registry ) => ( + + ) } + + ), + 'withRegistry' +); + +class BlockEditorProvider extends Component { + componentDidMount() { + this.props.updateEditorSettings( this.props.settings ); + this.props.resetBlocks( this.props.value ); + this.attachChangeObserver( this.props.registry ); + } + + componentDidUpdate( prevProps ) { + const { + settings, + updateEditorSettings, + value, + resetBlocks, + registry, + } = this.props; + + if ( settings !== prevProps.settings ) { + updateEditorSettings( settings ); + } + + if ( registry !== prevProps.registry ) { + this.attachChangeObserver( registry ); + } + + if ( this.isSyncingOutcomingValue ) { + this.isSyncingOutcomingValue = false; + } else if ( value !== prevProps.value ) { + this.isSyncingIncomingValue = true; + resetBlocks( value ); + } + } + + componentWillUnmount() { + if ( this.unsubscribe ) { + this.unsubscribe(); + } + } + + /** + * Given a registry object, overrides the default dispatch behavior for the + * `core/block-editor` store to interpret a state change and decide whether + * we should call `onChange` or `onInput` depending on whether the change + * is persistent or not. + * + * This needs to be done synchronously after state changes (instead of using + * `componentDidUpdate`) in order to avoid batching these changes. + * + * @param {WPDataRegistry} registry Registry from which block editor + * dispatch is to be overriden. + */ + attachChangeObserver( registry ) { + if ( this.unsubscribe ) { + this.unsubscribe(); + } + + const { + getBlocks, + isLastBlockChangePersistent, + } = registry.select( 'core/block-editor' ); + + let blocks = getBlocks(); + let isPersistent = isLastBlockChangePersistent(); + + this.unsubscribe = registry.subscribe( () => { + const { + onChange, + onInput, + } = this.props; + const newBlocks = getBlocks(); + const newIsPersistent = isLastBlockChangePersistent(); + if ( newBlocks !== blocks && this.isSyncingIncomingValue ) { + this.isSyncingIncomingValue = false; + blocks = newBlocks; + isPersistent = newIsPersistent; + return; + } + + if ( + newBlocks !== blocks || + // This happens when a previous input is explicitely marked as persistent. + ( newIsPersistent && ! isPersistent ) + ) { + blocks = newBlocks; + isPersistent = newIsPersistent; + + this.isSyncingOutcomingValue = true; + if ( isPersistent ) { + onChange( blocks ); + } else { + onInput( blocks ); + } + } + } ); + } + + render() { + const { children } = this.props; + + return ( + + + { children } + + + ); + } +} + +export default compose( [ + withDispatch( ( dispatch ) => { + const { + updateEditorSettings, + resetBlocks, + } = dispatch( 'core/block-editor' ); + + return { + updateEditorSettings, + resetBlocks, + }; + } ), + withRegistry, +] )( BlockEditorProvider ); diff --git a/packages/block-editor/src/index.js b/packages/block-editor/src/index.js new file mode 100644 index 0000000000000..9421db61f16e9 --- /dev/null +++ b/packages/block-editor/src/index.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import './store'; + +export * from './components'; diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js new file mode 100644 index 0000000000000..382a25f438d34 --- /dev/null +++ b/packages/block-editor/src/store/actions.js @@ -0,0 +1,544 @@ +/** + * External dependencies + */ +import { castArray } from 'lodash'; + +/** + * WordPress dependencies + */ +import { getDefaultBlockName, createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { select } from './controls'; + +/** + * Returns an action object used in signalling that blocks state should be + * reset to the specified array of blocks, taking precedence over any other + * content reflected as an edit in state. + * + * @param {Array} blocks Array of blocks. + * + * @return {Object} Action object. + */ +export function resetBlocks( blocks ) { + return { + type: 'RESET_BLOCKS', + blocks, + }; +} + +/** + * Returns an action object used in signalling that blocks have been received. + * Unlike resetBlocks, these should be appended to the existing known set, not + * replacing. + * + * @param {Object[]} blocks Array of block objects. + * + * @return {Object} Action object. + */ +export function receiveBlocks( blocks ) { + return { + type: 'RECEIVE_BLOCKS', + blocks, + }; +} + +/** + * Returns an action object used in signalling that the block attributes with + * the specified client ID has been updated. + * + * @param {string} clientId Block client ID. + * @param {Object} attributes Block attributes to be merged. + * + * @return {Object} Action object. + */ +export function updateBlockAttributes( clientId, attributes ) { + return { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId, + attributes, + }; +} + +/** + * Returns an action object used in signalling that the block with the + * specified client ID has been updated. + * + * @param {string} clientId Block client ID. + * @param {Object} updates Block attributes to be merged. + * + * @return {Object} Action object. + */ +export function updateBlock( clientId, updates ) { + return { + type: 'UPDATE_BLOCK', + clientId, + updates, + }; +} + +/** + * Returns an action object used in signalling that the block with the + * specified client ID has been selected, optionally accepting a position + * value reflecting its selection directionality. An initialPosition of -1 + * reflects a reverse selection. + * + * @param {string} clientId Block client ID. + * @param {?number} initialPosition Optional initial position. Pass as -1 to + * reflect reverse selection. + * + * @return {Object} Action object. + */ +export function selectBlock( clientId, initialPosition = null ) { + return { + type: 'SELECT_BLOCK', + initialPosition, + clientId, + }; +} + +/** + * Yields action objects used in signalling that the block preceding the given + * clientId should be selected. + * + * @param {string} clientId Block client ID. + */ +export function* selectPreviousBlock( clientId ) { + const previousBlockClientId = yield select( + 'core/editor', + 'getPreviousBlockClientId', + clientId + ); + + yield selectBlock( previousBlockClientId, -1 ); +} + +/** + * Yields action objects used in signalling that the block following the given + * clientId should be selected. + * + * @param {string} clientId Block client ID. + */ +export function* selectNextBlock( clientId ) { + const nextBlockClientId = yield select( + 'core/editor', + 'getNextBlockClientId', + clientId + ); + + yield selectBlock( nextBlockClientId ); +} + +/** + * Returns an action object used in signalling that a block multi-selection has started. + * + * @return {Object} Action object. + */ +export function startMultiSelect() { + return { + type: 'START_MULTI_SELECT', + }; +} + +/** + * Returns an action object used in signalling that block multi-selection stopped. + * + * @return {Object} Action object. + */ +export function stopMultiSelect() { + return { + type: 'STOP_MULTI_SELECT', + }; +} + +/** + * Returns an action object used in signalling that block multi-selection changed. + * + * @param {string} start First block of the multi selection. + * @param {string} end Last block of the multiselection. + * + * @return {Object} Action object. + */ +export function multiSelect( start, end ) { + return { + type: 'MULTI_SELECT', + start, + end, + }; +} + +/** + * Returns an action object used in signalling that the block selection is cleared. + * + * @return {Object} Action object. + */ +export function clearSelectedBlock() { + return { + type: 'CLEAR_SELECTED_BLOCK', + }; +} + +/** + * Returns an action object that enables or disables block selection. + * + * @param {boolean} [isSelectionEnabled=true] Whether block selection should + * be enabled. + + * @return {Object} Action object. + */ +export function toggleSelection( isSelectionEnabled = true ) { + return { + type: 'TOGGLE_SELECTION', + isSelectionEnabled, + }; +} + +/** + * Returns an action object signalling that a blocks should be replaced with + * one or more replacement blocks. + * + * @param {(string|string[])} clientIds Block client ID(s) to replace. + * @param {(Object|Object[])} blocks Replacement block(s). + * + * @return {Object} Action object. + */ +export function replaceBlocks( clientIds, blocks ) { + return { + type: 'REPLACE_BLOCKS', + clientIds: castArray( clientIds ), + blocks: castArray( blocks ), + time: Date.now(), + }; +} + +/** + * Returns an action object signalling that a single block should be replaced + * with one or more replacement blocks. + * + * @param {(string|string[])} clientId Block client ID to replace. + * @param {(Object|Object[])} block Replacement block(s). + * + * @return {Object} Action object. + */ +export function replaceBlock( clientId, block ) { + return replaceBlocks( clientId, block ); +} + +/** + * Higher-order action creator which, given the action type to dispatch creates + * an action creator for managing block movement. + * + * @param {string} type Action type to dispatch. + * + * @return {Function} Action creator. + */ +function createOnMove( type ) { + return ( clientIds, rootClientId ) => { + return { + clientIds: castArray( clientIds ), + type, + rootClientId, + }; + }; +} + +export const moveBlocksDown = createOnMove( 'MOVE_BLOCKS_DOWN' ); +export const moveBlocksUp = createOnMove( 'MOVE_BLOCKS_UP' ); + +/** + * Returns an action object signalling that an indexed block should be moved + * to a new index. + * + * @param {?string} clientId The client ID of the block. + * @param {?string} fromRootClientId Root client ID source. + * @param {?string} toRootClientId Root client ID destination. + * @param {number} index The index to move the block into. + * + * @return {Object} Action object. + */ +export function moveBlockToPosition( clientId, fromRootClientId, toRootClientId, index ) { + return { + type: 'MOVE_BLOCK_TO_POSITION', + fromRootClientId, + toRootClientId, + clientId, + index, + }; +} + +/** + * Returns an action object used in signalling that a single block should be + * inserted, optionally at a specific index respective a root block list. + * + * @param {Object} block Block object to insert. + * @param {?number} index Index at which block should be inserted. + * @param {?string} rootClientId Optional root client ID of block list on which to insert. + * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. + * + * @return {Object} Action object. + */ +export function insertBlock( block, index, rootClientId, updateSelection = true ) { + return insertBlocks( [ block ], index, rootClientId, updateSelection ); +} + +/** + * Returns an action object used in signalling that an array of blocks should + * be inserted, optionally at a specific index respective a root block list. + * + * @param {Object[]} blocks Block objects to insert. + * @param {?number} index Index at which block should be inserted. + * @param {?string} rootClientId Optional root client ID of block list on which to insert. + * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. + * + * @return {Object} Action object. + */ +export function insertBlocks( blocks, index, rootClientId, updateSelection = true ) { + return { + type: 'INSERT_BLOCKS', + blocks: castArray( blocks ), + index, + rootClientId, + time: Date.now(), + updateSelection, + }; +} + +/** + * Returns an action object used in signalling that the insertion point should + * be shown. + * + * @param {?string} rootClientId Optional root client ID of block list on + * which to insert. + * @param {?number} index Index at which block should be inserted. + * + * @return {Object} Action object. + */ +export function showInsertionPoint( rootClientId, index ) { + return { + type: 'SHOW_INSERTION_POINT', + rootClientId, + index, + }; +} + +/** + * Returns an action object hiding the insertion point. + * + * @return {Object} Action object. + */ +export function hideInsertionPoint() { + return { + type: 'HIDE_INSERTION_POINT', + }; +} + +/** + * Returns an action object resetting the template validity. + * + * @param {boolean} isValid template validity flag. + * + * @return {Object} Action object. + */ +export function setTemplateValidity( isValid ) { + return { + type: 'SET_TEMPLATE_VALIDITY', + isValid, + }; +} + +/** + * Returns an action object synchronize the template with the list of blocks + * + * @return {Object} Action object. + */ +export function synchronizeTemplate() { + return { + type: 'SYNCHRONIZE_TEMPLATE', + }; +} + +/** + * Returns an action object used in signalling that two blocks should be merged + * + * @param {string} firstBlockClientId Client ID of the first block to merge. + * @param {string} secondBlockClientId Client ID of the second block to merge. + * + * @return {Object} Action object. + */ +export function mergeBlocks( firstBlockClientId, secondBlockClientId ) { + return { + type: 'MERGE_BLOCKS', + blocks: [ firstBlockClientId, secondBlockClientId ], + }; +} + +/** + * Yields action objects used in signalling that the blocks corresponding to + * the set of specified client IDs are to be removed. + * + * @param {string|string[]} clientIds Client IDs of blocks to remove. + * @param {boolean} selectPrevious True if the previous block should be + * selected when a block is removed. + */ +export function* removeBlocks( clientIds, selectPrevious = true ) { + clientIds = castArray( clientIds ); + + if ( selectPrevious ) { + yield selectPreviousBlock( clientIds[ 0 ] ); + } + + yield { + type: 'REMOVE_BLOCKS', + clientIds, + }; +} + +/** + * Returns an action object used in signalling that the block with the + * specified client ID is to be removed. + * + * @param {string} clientId Client ID of block to remove. + * @param {boolean} selectPrevious True if the previous block should be + * selected when a block is removed. + * + * @return {Object} Action object. + */ +export function removeBlock( clientId, selectPrevious ) { + return removeBlocks( [ clientId ], selectPrevious ); +} + +/** + * Returns an action object used to toggle the block editing mode between + * visual and HTML modes. + * + * @param {string} clientId Block client ID. + * + * @return {Object} Action object. + */ +export function toggleBlockMode( clientId ) { + return { + type: 'TOGGLE_BLOCK_MODE', + clientId, + }; +} + +/** + * Returns an action object used in signalling that the user has begun to type. + * + * @return {Object} Action object. + */ +export function startTyping() { + return { + type: 'START_TYPING', + }; +} + +/** + * Returns an action object used in signalling that the user has stopped typing. + * + * @return {Object} Action object. + */ +export function stopTyping() { + return { + type: 'STOP_TYPING', + }; +} + +/** + * Returns an action object used in signalling that the caret has entered formatted text. + * + * @return {Object} Action object. + */ +export function enterFormattedText() { + return { + type: 'ENTER_FORMATTED_TEXT', + }; +} + +/** + * Returns an action object used in signalling that the user caret has exited formatted text. + * + * @return {Object} Action object. + */ +export function exitFormattedText() { + return { + type: 'EXIT_FORMATTED_TEXT', + }; +} + +/** + * Returns an action object used in signalling that a new block of the default + * type should be added to the block list. + * + * @param {?Object} attributes Optional attributes of the block to assign. + * @param {?string} rootClientId Optional root client ID of block list on which + * to append. + * @param {?number} index Optional index where to insert the default block + * + * @return {Object} Action object + */ +export function insertDefaultBlock( attributes, rootClientId, index ) { + const block = createBlock( getDefaultBlockName(), attributes ); + + return insertBlock( block, index, rootClientId ); +} + +/** + * Returns an action object that changes the nested settings of a given block. + * + * @param {string} clientId Client ID of the block whose nested setting are + * being received. + * @param {Object} settings Object with the new settings for the nested block. + * + * @return {Object} Action object + */ +export function updateBlockListSettings( clientId, settings ) { + return { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId, + settings, + }; +} + +/* + * Returns an action object used in signalling that the editor settings have been updated. + * + * @param {Object} settings Updated settings + * + * @return {Object} Action object + */ +export function updateEditorSettings( settings ) { + return { + type: 'UPDATE_EDITOR_SETTINGS', + settings, + }; +} + +/** + * Returns an action object used in signalling that a temporary reusable blocks have been saved + * in order to switch its temporary id with the real id. + * + * @param {string} id Reusable block's id. + * @param {string} updatedId Updated block's id. + * + * @return {Object} Action object. + */ +export function __unstableSaveReusableBlock( id, updatedId ) { + return { + type: 'SAVE_REUSABLE_BLOCK_SUCCESS', + id, + updatedId, + }; +} + +/** + * Returns an action object used in signalling that the last block change should be marked explicitely as persistent. + * + * @return {Object} Action object. + */ +export function __unstableMarkLastChangeAsPersistent() { + return { type: 'MARK_LAST_CHANGE_AS_PERSISTENT' }; +} + diff --git a/packages/editor/src/store/array.js b/packages/block-editor/src/store/array.js similarity index 100% rename from packages/editor/src/store/array.js rename to packages/block-editor/src/store/array.js diff --git a/packages/block-editor/src/store/controls.js b/packages/block-editor/src/store/controls.js new file mode 100644 index 0000000000000..5012ab244c21c --- /dev/null +++ b/packages/block-editor/src/store/controls.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { createRegistryControl } from '@wordpress/data'; + +/** + * Calls a selector using the current state. + * + * @param {string} storeName Store name. + * @param {string} selectorName Selector name. + * @param {Array} args Selector arguments. + * + * @return {Object} control descriptor. + */ +export function select( storeName, selectorName, ...args ) { + return { + type: 'SELECT', + storeName, + selectorName, + args, + }; +} + +const controls = { + SELECT: createRegistryControl( ( registry ) => ( { storeName, selectorName, args } ) => { + return registry.select( storeName )[ selectorName ]( ...args ); + } ), +}; + +export default controls; diff --git a/packages/block-editor/src/store/defaults.js b/packages/block-editor/src/store/defaults.js new file mode 100644 index 0000000000000..31d1574d6283a --- /dev/null +++ b/packages/block-editor/src/store/defaults.js @@ -0,0 +1,133 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; + +export const PREFERENCES_DEFAULTS = { + insertUsage: {}, +}; + +/** + * The default editor settings + * + * alignWide boolean Enable/Disable Wide/Full Alignments + * colors Array Palette colors + * fontSizes Array Available font sizes + * imageSizes Array Available image sizes + * maxWidth number Max width to constraint resizing + * blockTypes boolean|Array Allowed block types + * hasFixedToolbar boolean Whether or not the editor toolbar is fixed + * focusMode boolean Whether the focus mode is enabled or not + * richEditingEnabled boolean Whether rich editing is enabled or not + */ +export const EDITOR_SETTINGS_DEFAULTS = { + alignWide: false, + colors: [ + { + name: __( 'Pale pink' ), + slug: 'pale-pink', + color: '#f78da7', + }, + { name: __( 'Vivid red' ), + slug: 'vivid-red', + color: '#cf2e2e', + }, + { + name: __( 'Luminous vivid orange' ), + slug: 'luminous-vivid-orange', + color: '#ff6900', + }, + { + name: __( 'Luminous vivid amber' ), + slug: 'luminous-vivid-amber', + color: '#fcb900', + }, + { + name: __( 'Light green cyan' ), + slug: 'light-green-cyan', + color: '#7bdcb5', + }, + { + name: __( 'Vivid green cyan' ), + slug: 'vivid-green-cyan', + color: '#00d084', + }, + { + name: __( 'Pale cyan blue' ), + slug: 'pale-cyan-blue', + color: '#8ed1fc', + }, + { + name: __( 'Vivid cyan blue' ), + slug: 'vivid-cyan-blue', + color: '#0693e3', + }, + { + name: __( 'Very light gray' ), + slug: 'very-light-gray', + color: '#eeeeee', + }, + { + name: __( 'Cyan bluish gray' ), + slug: 'cyan-bluish-gray', + color: '#abb8c3', + }, + { + name: __( 'Very dark gray' ), + slug: 'very-dark-gray', + color: '#313131', + }, + ], + + fontSizes: [ + { + name: _x( 'Small', 'font size name' ), + size: 13, + slug: 'small', + }, + { + name: _x( 'Normal', 'font size name' ), + size: 16, + slug: 'normal', + }, + { + name: _x( 'Medium', 'font size name' ), + size: 20, + slug: 'medium', + }, + { + name: _x( 'Large', 'font size name' ), + size: 36, + slug: 'large', + }, + { + name: _x( 'Huge', 'font size name' ), + size: 48, + slug: 'huge', + }, + ], + + imageSizes: [ + { slug: 'thumbnail', label: __( 'Thumbnail' ) }, + { slug: 'medium', label: __( 'Medium' ) }, + { slug: 'large', label: __( 'Large' ) }, + { slug: 'full', label: __( 'Full Size' ) }, + ], + + // This is current max width of the block inner area + // It's used to constraint image resizing and this value could be overridden later by themes + maxWidth: 580, + + // Allowed block types for the editor, defaulting to true (all supported). + allowedBlockTypes: true, + + // Maximum upload size in bytes allowed for the site. + maxUploadFileSize: 0, + + // List of allowed mime types and file extensions. + allowedMimeTypes: null, + + // Whether richs editing is enabled or not. + richEditingEnabled: true, +}; + diff --git a/packages/block-editor/src/store/effects.js b/packages/block-editor/src/store/effects.js new file mode 100644 index 0000000000000..f02ef6fbd7231 --- /dev/null +++ b/packages/block-editor/src/store/effects.js @@ -0,0 +1,150 @@ +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; +import { + getBlockType, + doBlocksMatchTemplate, + switchToBlockType, + synchronizeBlocksWithTemplate, +} from '@wordpress/blocks'; +import { _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + replaceBlocks, + selectBlock, + setTemplateValidity, + insertDefaultBlock, + resetBlocks, +} from './actions'; +import { + getBlock, + getBlocks, + getSelectedBlockCount, + getBlockCount, + getTemplateLock, + getTemplate, + isValidTemplate, +} from './selectors'; + +/** + * Block validity is a function of blocks state (at the point of a + * reset) and the template setting. As a compromise to its placement + * across distinct parts of state, it is implemented here as a side- + * effect of the block reset action. + * + * @param {Object} action RESET_BLOCKS action. + * @param {Object} store Store instance. + * + * @return {?Object} New validity set action if validity has changed. + */ +export function validateBlocksToTemplate( action, store ) { + const state = store.getState(); + const template = getTemplate( state ); + const templateLock = getTemplateLock( state ); + + // Unlocked templates are considered always valid because they act + // as default values only. + const isBlocksValidToTemplate = ( + ! template || + templateLock !== 'all' || + doBlocksMatchTemplate( action.blocks, template ) + ); + + // Update if validity has changed. + if ( isBlocksValidToTemplate !== isValidTemplate( state ) ) { + return setTemplateValidity( isBlocksValidToTemplate ); + } +} + +/** + * Effect handler which will return a default block insertion action if there + * are no other blocks at the root of the editor. This is expected to be used + * in actions which may result in no blocks remaining in the editor (removal, + * replacement, etc). + * + * @param {Object} action Action which had initiated the effect handler. + * @param {Object} store Store instance. + * + * @return {?Object} Default block insert action, if no other blocks exist. + */ +export function ensureDefaultBlock( action, store ) { + if ( ! getBlockCount( store.getState() ) ) { + return insertDefaultBlock(); + } +} + +export default { + MERGE_BLOCKS( action, store ) { + const { dispatch } = store; + const state = store.getState(); + const [ firstBlockClientId, secondBlockClientId ] = action.blocks; + const blockA = getBlock( state, firstBlockClientId ); + const blockType = getBlockType( blockA.name ); + + // Only focus the previous block if it's not mergeable + if ( ! blockType.merge ) { + dispatch( selectBlock( blockA.clientId ) ); + return; + } + + // We can only merge blocks with similar types + // thus, we transform the block to merge first + const blockB = getBlock( state, secondBlockClientId ); + const blocksWithTheSameType = blockA.name === blockB.name ? + [ blockB ] : + switchToBlockType( blockB, blockA.name ); + + // If the block types can not match, do nothing + if ( ! blocksWithTheSameType || ! blocksWithTheSameType.length ) { + return; + } + + // Calling the merge to update the attributes and remove the block to be merged + const updatedAttributes = blockType.merge( + blockA.attributes, + blocksWithTheSameType[ 0 ].attributes + ); + + dispatch( selectBlock( blockA.clientId, -1 ) ); + dispatch( replaceBlocks( + [ blockA.clientId, blockB.clientId ], + [ + { + ...blockA, + attributes: { + ...blockA.attributes, + ...updatedAttributes, + }, + }, + ...blocksWithTheSameType.slice( 1 ), + ] + ) ); + }, + RESET_BLOCKS: [ + validateBlocksToTemplate, + ], + REMOVE_BLOCKS: [ + ensureDefaultBlock, + ], + REPLACE_BLOCKS: [ + ensureDefaultBlock, + ], + MULTI_SELECT: ( action, { getState } ) => { + const blockCount = getSelectedBlockCount( getState() ); + + /* translators: %s: number of selected blocks */ + speak( sprintf( _n( '%s block selected.', '%s blocks selected.', blockCount ), blockCount ), 'assertive' ); + }, + SYNCHRONIZE_TEMPLATE( action, { getState } ) { + const state = getState(); + const blocks = getBlocks( state ); + const template = getTemplate( state ); + const updatedBlockList = synchronizeBlocksWithTemplate( blocks, template ); + + return resetBlocks( updatedBlockList ); + }, +}; diff --git a/packages/block-editor/src/store/index.js b/packages/block-editor/src/store/index.js new file mode 100644 index 0000000000000..0119e63d7a3d1 --- /dev/null +++ b/packages/block-editor/src/store/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import applyMiddlewares from './middlewares'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import controls from './controls'; + +/** + * Module Constants + */ +const MODULE_KEY = 'core/block-editor'; + +const store = registerStore( MODULE_KEY, { + reducer, + selectors, + actions, + controls, + persist: [ 'preferences' ], +} ); +applyMiddlewares( store ); + +export default store; diff --git a/packages/block-editor/src/store/middlewares.js b/packages/block-editor/src/store/middlewares.js new file mode 100644 index 0000000000000..6381132bb81e0 --- /dev/null +++ b/packages/block-editor/src/store/middlewares.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import refx from 'refx'; +import multi from 'redux-multi'; +import { flowRight } from 'lodash'; + +/** + * Internal dependencies + */ +import effects from './effects'; + +/** + * Applies the custom middlewares used specifically in the editor module. + * + * @param {Object} store Store Object. + * + * @return {Object} Update Store Object. + */ +function applyMiddlewares( store ) { + const middlewares = [ + refx( effects ), + multi, + ]; + + let enhancedDispatch = () => { + throw new Error( + 'Dispatching while constructing your middleware is not allowed. ' + + 'Other middleware would not be applied to this dispatch.' + ); + }; + let chain = []; + + const middlewareAPI = { + getState: store.getState, + dispatch: ( ...args ) => enhancedDispatch( ...args ), + }; + chain = middlewares.map( ( middleware ) => middleware( middlewareAPI ) ); + enhancedDispatch = flowRight( ...chain )( store.dispatch ); + + store.dispatch = enhancedDispatch; + return store; +} + +export default applyMiddlewares; diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js new file mode 100644 index 0000000000000..d6cc2444af050 --- /dev/null +++ b/packages/block-editor/src/store/reducer.js @@ -0,0 +1,901 @@ +/** + * External dependencies + */ +import { + flow, + reduce, + first, + last, + omit, + without, + mapValues, + keys, + isEqual, + isEmpty, +} from 'lodash'; + +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; +import { isReusableBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + PREFERENCES_DEFAULTS, + EDITOR_SETTINGS_DEFAULTS, +} from './defaults'; +import { insertAt, moveTo } from './array'; + +/** + * Given an array of blocks, returns an object where each key is a nesting + * context, the value of which is an array of block client IDs existing within + * that nesting context. + * + * @param {Array} blocks Blocks to map. + * @param {?string} rootClientId Assumed root client ID. + * + * @return {Object} Block order map object. + */ +function mapBlockOrder( blocks, rootClientId = '' ) { + const result = { [ rootClientId ]: [] }; + + blocks.forEach( ( block ) => { + const { clientId, innerBlocks } = block; + + result[ rootClientId ].push( clientId ); + + Object.assign( result, mapBlockOrder( innerBlocks, clientId ) ); + } ); + + return result; +} + +/** + * Helper method to iterate through all blocks, recursing into inner blocks, + * applying a transformation function to each one. + * Returns a flattened object with the transformed blocks. + * + * @param {Array} blocks Blocks to flatten. + * @param {Function} transform Transforming function to be applied to each block. + * + * @return {Object} Flattened object. + */ +function flattenBlocks( blocks, transform ) { + const result = {}; + + const stack = [ ...blocks ]; + while ( stack.length ) { + const { innerBlocks, ...block } = stack.shift(); + stack.push( ...innerBlocks ); + result[ block.clientId ] = transform( block ); + } + + return result; +} + +/** + * Given an array of blocks, returns an object containing all blocks, without + * attributes, recursing into inner blocks. Keys correspond to the block client + * ID, the value of which is the attributes object. + * + * @param {Array} blocks Blocks to flatten. + * + * @return {Object} Flattened block attributes object. + */ +function getFlattenedBlocksWithoutAttributes( blocks ) { + return flattenBlocks( blocks, ( block ) => omit( block, 'attributes' ) ); +} + +/** + * Given an array of blocks, returns an object containing all block attributes, + * recursing into inner blocks. Keys correspond to the block client ID, the + * value of which is the attributes object. + * + * @param {Array} blocks Blocks to flatten. + * + * @return {Object} Flattened block attributes object. + */ +function getFlattenedBlockAttributes( blocks ) { + return flattenBlocks( blocks, ( block ) => block.attributes ); +} + +/** + * Given a block order map object, returns *all* of the block client IDs that are + * a descendant of the given root client ID. + * + * Calling this with `rootClientId` set to `''` results in a list of client IDs + * that are in the post. That is, it excludes blocks like fetched reusable + * blocks which are stored into state but not visible. + * + * @param {Object} blocksOrder Object that maps block client IDs to a list of + * nested block client IDs. + * @param {?string} rootClientId The root client ID to search. Defaults to ''. + * + * @return {Array} List of descendant client IDs. + */ +function getNestedBlockClientIds( blocksOrder, rootClientId = '' ) { + return reduce( blocksOrder[ rootClientId ], ( result, clientId ) => [ + ...result, + clientId, + ...getNestedBlockClientIds( blocksOrder, clientId ), + ], [] ); +} + +/** + * Returns an object against which it is safe to perform mutating operations, + * given the original object and its current working copy. + * + * @param {Object} original Original object. + * @param {Object} working Working object. + * + * @return {Object} Mutation-safe object. + */ +function getMutateSafeObject( original, working ) { + if ( original === working ) { + return { ...original }; + } + + return working; +} + +/** + * Returns true if the two object arguments have the same keys, or false + * otherwise. + * + * @param {Object} a First object. + * @param {Object} b Second object. + * + * @return {boolean} Whether the two objects have the same keys. + */ +export function hasSameKeys( a, b ) { + return isEqual( keys( a ), keys( b ) ); +} + +/** + * Returns true if, given the currently dispatching action and the previously + * dispatched action, the two actions are updating the same block attribute, or + * false otherwise. + * + * @param {Object} action Currently dispatching action. + * @param {Object} lastAction Previously dispatched action. + * + * @return {boolean} Whether actions are updating the same block attribute. + */ +export function isUpdatingSameBlockAttribute( action, lastAction ) { + return ( + action.type === 'UPDATE_BLOCK_ATTRIBUTES' && + lastAction !== undefined && + lastAction.type === 'UPDATE_BLOCK_ATTRIBUTES' && + action.clientId === lastAction.clientId && + hasSameKeys( action.attributes, lastAction.attributes ) + ); +} + +/** + * Higher-order reducer intended to augment the blocks reducer, assigning an + * `isPersistentChange` property value corresponding to whether a change in + * state can be considered as persistent. All changes are considered persistent + * except when updating the same block attribute as in the previous action. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +function withPersistentBlockChange( reducer ) { + let lastAction; + + return ( state, action ) => { + let nextState = reducer( state, action ); + const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT'; + + if ( state !== nextState || isExplicitPersistentChange ) { + nextState = { + ...nextState, + isPersistentChange: ( + isExplicitPersistentChange || + ! isUpdatingSameBlockAttribute( action, lastAction ) + ), + }; + } + + lastAction = action; + + return nextState; + }; +} + +/** + * Higher-order reducer targeting the combined blocks reducer, augmenting + * block client IDs in remove action to include cascade of inner blocks. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withInnerBlocksRemoveCascade = ( reducer ) => ( state, action ) => { + if ( state && action.type === 'REMOVE_BLOCKS' ) { + const clientIds = [ ...action.clientIds ]; + + // For each removed client ID, include its inner blocks to remove, + // recursing into those so long as inner blocks exist. + for ( let i = 0; i < clientIds.length; i++ ) { + clientIds.push( ...state.order[ clientIds[ i ] ] ); + } + + action = { ...action, clientIds }; + } + + return reducer( state, action ); +}; + +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `RESET_BLOCKS` action. When dispatched, this action will replace all + * blocks that exist in the post, leaving blocks that exist only in state (e.g. + * reusable blocks) alone. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withBlockReset = ( reducer ) => ( state, action ) => { + if ( state && action.type === 'RESET_BLOCKS' ) { + const visibleClientIds = getNestedBlockClientIds( state.order ); + return { + ...state, + byClientId: { + ...omit( state.byClientId, visibleClientIds ), + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }, + attributes: { + ...omit( state.attributes, visibleClientIds ), + ...getFlattenedBlockAttributes( action.blocks ), + }, + order: { + ...omit( state.order, visibleClientIds ), + ...mapBlockOrder( action.blocks ), + }, + }; + } + + return reducer( state, action ); +}; + +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `SAVE_REUSABLE_BLOCK_SUCCESS` action. This action can't be handled by + * regular reducers and needs a higher-order reducer since it needs access to + * both `byClientId` and `attributes` simultaneously. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withSaveReusableBlock = ( reducer ) => ( state, action ) => { + if ( state && action.type === 'SAVE_REUSABLE_BLOCK_SUCCESS' ) { + const { id, updatedId } = action; + + // If a temporary reusable block is saved, we swap the temporary id with the final one + if ( id === updatedId ) { + return state; + } + + state = { ...state }; + + state.attributes = mapValues( state.attributes, ( attributes, clientId ) => { + const { name } = state.byClientId[ clientId ]; + if ( name === 'core/block' && attributes.ref === id ) { + return { + ...attributes, + ref: updatedId, + }; + } + + return attributes; + } ); + } + + return reducer( state, action ); +}; + +/** + * Reducer returning the blocks state. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @returns {Object} Updated state. + */ +export const blocks = flow( + combineReducers, + withInnerBlocksRemoveCascade, + withBlockReset, + withSaveReusableBlock, + withPersistentBlockChange, +)( { + byClientId( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return getFlattenedBlocksWithoutAttributes( action.blocks ); + + case 'RECEIVE_BLOCKS': + return { + ...state, + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }; + + case 'UPDATE_BLOCK': + // Ignore updates if block isn't known + if ( ! state[ action.clientId ] ) { + return state; + } + + // Do nothing if only attributes change. + const changes = omit( action.updates, 'attributes' ); + if ( isEmpty( changes ) ) { + return state; + } + + return { + ...state, + [ action.clientId ]: { + ...state[ action.clientId ], + ...changes, + }, + }; + + case 'INSERT_BLOCKS': + return { + ...state, + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }; + + case 'REPLACE_BLOCKS': + if ( ! action.blocks ) { + return state; + } + + return { + ...omit( state, action.clientIds ), + ...getFlattenedBlocksWithoutAttributes( action.blocks ), + }; + + case 'REMOVE_BLOCKS': + return omit( state, action.clientIds ); + } + + return state; + }, + + attributes( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return getFlattenedBlockAttributes( action.blocks ); + + case 'RECEIVE_BLOCKS': + return { + ...state, + ...getFlattenedBlockAttributes( action.blocks ), + }; + + case 'UPDATE_BLOCK': + // Ignore updates if block isn't known or there are no attribute changes. + if ( ! state[ action.clientId ] || ! action.updates.attributes ) { + return state; + } + + return { + ...state, + [ action.clientId ]: { + ...state[ action.clientId ], + ...action.updates.attributes, + }, + }; + + case 'UPDATE_BLOCK_ATTRIBUTES': + // Ignore updates if block isn't known + if ( ! state[ action.clientId ] ) { + return state; + } + + // Consider as updates only changed values + const nextAttributes = reduce( action.attributes, ( result, value, key ) => { + if ( value !== result[ key ] ) { + result = getMutateSafeObject( state[ action.clientId ], result ); + result[ key ] = value; + } + + return result; + }, state[ action.clientId ] ); + + // Skip update if nothing has been changed. The reference will + // match the original block if `reduce` had no changed values. + if ( nextAttributes === state[ action.clientId ] ) { + return state; + } + + // Otherwise replace attributes in state + return { + ...state, + [ action.clientId ]: nextAttributes, + }; + + case 'INSERT_BLOCKS': + return { + ...state, + ...getFlattenedBlockAttributes( action.blocks ), + }; + + case 'REPLACE_BLOCKS': + if ( ! action.blocks ) { + return state; + } + + return { + ...omit( state, action.clientIds ), + ...getFlattenedBlockAttributes( action.blocks ), + }; + + case 'REMOVE_BLOCKS': + return omit( state, action.clientIds ); + } + + return state; + }, + + order( state = {}, action ) { + switch ( action.type ) { + case 'RESET_BLOCKS': + return mapBlockOrder( action.blocks ); + + case 'RECEIVE_BLOCKS': + return { + ...state, + ...omit( mapBlockOrder( action.blocks ), '' ), + }; + + case 'INSERT_BLOCKS': { + const { rootClientId = '' } = action; + const subState = state[ rootClientId ] || []; + const mappedBlocks = mapBlockOrder( action.blocks, rootClientId ); + const { index = subState.length } = action; + + return { + ...state, + ...mappedBlocks, + [ rootClientId ]: insertAt( subState, mappedBlocks[ rootClientId ], index ), + }; + } + + case 'MOVE_BLOCK_TO_POSITION': { + const { fromRootClientId = '', toRootClientId = '', clientId } = action; + const { index = state[ toRootClientId ].length } = action; + + // Moving inside the same parent block + if ( fromRootClientId === toRootClientId ) { + const subState = state[ toRootClientId ]; + const fromIndex = subState.indexOf( clientId ); + return { + ...state, + [ toRootClientId ]: moveTo( state[ toRootClientId ], fromIndex, index ), + }; + } + + // Moving from a parent block to another + return { + ...state, + [ fromRootClientId ]: without( state[ fromRootClientId ], clientId ), + [ toRootClientId ]: insertAt( state[ toRootClientId ], clientId, index ), + }; + } + + case 'MOVE_BLOCKS_UP': { + const { clientIds, rootClientId = '' } = action; + const firstClientId = first( clientIds ); + const subState = state[ rootClientId ]; + + if ( ! subState.length || firstClientId === first( subState ) ) { + return state; + } + + const firstIndex = subState.indexOf( firstClientId ); + + return { + ...state, + [ rootClientId ]: moveTo( subState, firstIndex, firstIndex - 1, clientIds.length ), + }; + } + + case 'MOVE_BLOCKS_DOWN': { + const { clientIds, rootClientId = '' } = action; + const firstClientId = first( clientIds ); + const lastClientId = last( clientIds ); + const subState = state[ rootClientId ]; + + if ( ! subState.length || lastClientId === last( subState ) ) { + return state; + } + + const firstIndex = subState.indexOf( firstClientId ); + + return { + ...state, + [ rootClientId ]: moveTo( subState, firstIndex, firstIndex + 1, clientIds.length ), + }; + } + + case 'REPLACE_BLOCKS': { + const { clientIds } = action; + if ( ! action.blocks ) { + return state; + } + + const mappedBlocks = mapBlockOrder( action.blocks ); + + return flow( [ + ( nextState ) => omit( nextState, clientIds ), + ( nextState ) => ( { + ...nextState, + ...omit( mappedBlocks, '' ), + } ), + ( nextState ) => mapValues( nextState, ( subState ) => ( + reduce( subState, ( result, clientId ) => { + if ( clientId === clientIds[ 0 ] ) { + return [ + ...result, + ...mappedBlocks[ '' ], + ]; + } + + if ( clientIds.indexOf( clientId ) === -1 ) { + result.push( clientId ); + } + + return result; + }, [] ) + ) ), + ] )( state ); + } + + case 'REMOVE_BLOCKS': + return flow( [ + // Remove inner block ordering for removed blocks + ( nextState ) => omit( nextState, action.clientIds ), + + // Remove deleted blocks from other blocks' orderings + ( nextState ) => mapValues( nextState, ( subState ) => ( + without( subState, ...action.clientIds ) + ) ), + ] )( state ); + } + + return state; + }, +} ); + +/** + * Reducer returning typing state. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function isTyping( state = false, action ) { + switch ( action.type ) { + case 'START_TYPING': + return true; + + case 'STOP_TYPING': + return false; + } + + return state; +} + +/** + * Reducer returning whether the caret is within formatted text. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function isCaretWithinFormattedText( state = false, action ) { + switch ( action.type ) { + case 'ENTER_FORMATTED_TEXT': + return true; + + case 'EXIT_FORMATTED_TEXT': + return false; + } + + return state; +} + +/** + * Reducer returning the block selection's state. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function blockSelection( state = { + start: null, + end: null, + isMultiSelecting: false, + isEnabled: true, + initialPosition: null, +}, action ) { + switch ( action.type ) { + case 'CLEAR_SELECTED_BLOCK': + if ( state.start === null && state.end === null && ! state.isMultiSelecting ) { + return state; + } + + return { + ...state, + start: null, + end: null, + isMultiSelecting: false, + initialPosition: null, + }; + case 'START_MULTI_SELECT': + if ( state.isMultiSelecting ) { + return state; + } + + return { + ...state, + isMultiSelecting: true, + initialPosition: null, + }; + case 'STOP_MULTI_SELECT': + if ( ! state.isMultiSelecting ) { + return state; + } + + return { + ...state, + isMultiSelecting: false, + initialPosition: null, + }; + case 'MULTI_SELECT': + return { + ...state, + start: action.start, + end: action.end, + initialPosition: null, + }; + case 'SELECT_BLOCK': + if ( action.clientId === state.start && action.clientId === state.end ) { + return state; + } + return { + ...state, + start: action.clientId, + end: action.clientId, + initialPosition: action.initialPosition, + }; + case 'INSERT_BLOCKS': { + if ( action.updateSelection ) { + return { + ...state, + start: action.blocks[ 0 ].clientId, + end: action.blocks[ 0 ].clientId, + initialPosition: null, + isMultiSelecting: false, + }; + } + return state; + } + case 'REMOVE_BLOCKS': + if ( ! action.clientIds || ! action.clientIds.length || action.clientIds.indexOf( state.start ) === -1 ) { + return state; + } + return { + ...state, + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + }; + case 'REPLACE_BLOCKS': + if ( action.clientIds.indexOf( state.start ) === -1 ) { + return state; + } + + // If there are replacement blocks, assign last block as the next + // selected block, otherwise set to null. + const lastBlock = last( action.blocks ); + const nextSelectedBlockClientId = lastBlock ? lastBlock.clientId : null; + + if ( nextSelectedBlockClientId === state.start && nextSelectedBlockClientId === state.end ) { + return state; + } + + return { + ...state, + start: nextSelectedBlockClientId, + end: nextSelectedBlockClientId, + initialPosition: null, + isMultiSelecting: false, + }; + case 'TOGGLE_SELECTION': + return { + ...state, + isEnabled: action.isSelectionEnabled, + }; + } + + return state; +} + +export function blocksMode( state = {}, action ) { + if ( action.type === 'TOGGLE_BLOCK_MODE' ) { + const { clientId } = action; + return { + ...state, + [ clientId ]: state[ clientId ] && state[ clientId ] === 'html' ? 'visual' : 'html', + }; + } + + return state; +} + +/** + * Reducer returning the block insertion point visibility, either null if there + * is not an explicit insertion point assigned, or an object of its `index` and + * `rootClientId`. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function insertionPoint( state = null, action ) { + switch ( action.type ) { + case 'SHOW_INSERTION_POINT': + const { rootClientId, index } = action; + return { rootClientId, index }; + + case 'HIDE_INSERTION_POINT': + return null; + } + + return state; +} + +/** + * Reducer returning whether the post blocks match the defined template or not. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function template( state = { isValid: true }, action ) { + switch ( action.type ) { + case 'SET_TEMPLATE_VALIDITY': + return { + ...state, + isValid: action.isValid, + }; + } + + return state; +} + +/** + * Reducer returning the editor setting. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function settings( state = EDITOR_SETTINGS_DEFAULTS, action ) { + switch ( action.type ) { + case 'UPDATE_EDITOR_SETTINGS': + return { + ...state, + ...action.settings, + }; + } + + return state; +} + +/** + * Reducer returning the user preferences. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {string} Updated state. + */ +export function preferences( state = PREFERENCES_DEFAULTS, action ) { + switch ( action.type ) { + case 'INSERT_BLOCKS': + case 'REPLACE_BLOCKS': + return action.blocks.reduce( ( prevState, block ) => { + let id = block.name; + const insert = { name: block.name }; + if ( isReusableBlock( block ) ) { + insert.ref = block.attributes.ref; + id += '/' + block.attributes.ref; + } + + return { + ...prevState, + insertUsage: { + ...prevState.insertUsage, + [ id ]: { + time: action.time, + count: prevState.insertUsage[ id ] ? prevState.insertUsage[ id ].count + 1 : 1, + insert, + }, + }, + }; + }, state ); + } + + return state; +} + +/** + * Reducer returning an object where each key is a block client ID, its value + * representing the settings for its nested blocks. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export const blockListSettings = ( state = {}, action ) => { + switch ( action.type ) { + // Even if the replaced blocks have the same client ID, our logic + // should correct the state. + case 'REPLACE_BLOCKS' : + case 'REMOVE_BLOCKS': { + return omit( state, action.clientIds ); + } + case 'UPDATE_BLOCK_LIST_SETTINGS': { + const { clientId } = action; + if ( ! action.settings ) { + if ( state.hasOwnProperty( clientId ) ) { + return omit( state, clientId ); + } + + return state; + } + + if ( isEqual( state[ clientId ], action.settings ) ) { + return state; + } + + return { + ...state, + [ clientId ]: action.settings, + }; + } + } + return state; +}; + +export default combineReducers( { + blocks, + isTyping, + isCaretWithinFormattedText, + blockSelection, + blocksMode, + blockListSettings, + insertionPoint, + template, + settings, + preferences, +} ); diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js new file mode 100644 index 0000000000000..efadb31bf0ff2 --- /dev/null +++ b/packages/block-editor/src/store/selectors.js @@ -0,0 +1,1395 @@ +/** + * External dependencies + */ +import { + castArray, + flatMap, + first, + get, + includes, + isArray, + isBoolean, + last, + map, + orderBy, + reduce, + some, +} from 'lodash'; +import createSelector from 'rememo'; + +/** + * WordPress dependencies + */ +import { + getBlockType, + getBlockTypes, + hasBlockSupport, + hasChildBlocksWithInserterSupport, +} from '@wordpress/blocks'; + +/*** + * Module constants + */ +export const INSERTER_UTILITY_HIGH = 3; +export const INSERTER_UTILITY_MEDIUM = 2; +export const INSERTER_UTILITY_LOW = 1; +export const INSERTER_UTILITY_NONE = 0; +const MILLISECONDS_PER_HOUR = 3600 * 1000; +const MILLISECONDS_PER_DAY = 24 * 3600 * 1000; +const MILLISECONDS_PER_WEEK = 7 * 24 * 3600 * 1000; + +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation, as in a connected or + * other pure component which performs `shouldComponentUpdate` check on props. + * This should be used as a last resort, since the normalized data should be + * maintained by the reducer result in state. + * + * @type {Array} + */ +const EMPTY_ARRAY = []; + +/** + * Shared reference to an empty object for cases where it is important to avoid + * returning a new object reference on every invocation. + * + * @type {Object} + */ +const EMPTY_OBJECT = {}; + +/** + * Returns a new reference when the inner blocks of a given block client ID + * change. This is used exclusively as a memoized selector dependant, relying + * on this selector's shared return value and recursively those of its inner + * blocks defined as dependencies. This abuses mechanics of the selector + * memoization to return from the original selector function only when + * dependants change. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {*} A value whose reference will change only when inner blocks of + * the given block client ID change. + */ +export const getBlockDependantsCacheBust = createSelector( + () => [], + ( state, clientId ) => map( + getBlockOrder( state, clientId ), + ( innerBlockClientId ) => getBlock( state, innerBlockClientId ), + ), +); + +/** + * Returns a block's name given its client ID, or null if no block exists with + * the client ID. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {string} Block name. + */ +export function getBlockName( state, clientId ) { + const block = state.blocks.byClientId[ clientId ]; + return block ? block.name : null; +} + +/** + * Returns whether a block is valid or not. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Is Valid. + */ +export function isBlockValid( state, clientId ) { + const block = state.blocks.byClientId[ clientId ]; + return !! block && block.isValid; +} + +/** + * Returns a block's attributes given its client ID, or null if no block exists with + * the client ID. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {Object?} Block attributes. + */ +export const getBlockAttributes = createSelector( + ( state, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } + + let attributes = state.blocks.attributes[ clientId ]; + + // Inject custom source attribute values. + // + // TODO: Create generic external sourcing pattern, not explicitly + // targeting meta attributes. + const type = getBlockType( block.name ); + if ( type ) { + attributes = reduce( type.attributes, ( result, value, key ) => { + if ( value.source === 'meta' ) { + if ( result === attributes ) { + result = { ...result }; + } + + result[ key ] = getPostMeta( state, value.meta ); + } + + return result; + }, attributes ); + } + + return attributes; + }, + ( state, clientId ) => [ + state.blocks.byClientId[ clientId ], + state.blocks.attributes[ clientId ], + getPostMeta( state ), + ] +); + +/** + * Returns a block given its client ID. This is a parsed copy of the block, + * containing its `blockName`, `clientId`, and current `attributes` state. This + * is not the block's registration settings, which must be retrieved from the + * blocks module registration store. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {Object} Parsed block object. + */ +export const getBlock = createSelector( + ( state, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } + + return { + ...block, + attributes: getBlockAttributes( state, clientId ), + innerBlocks: getBlocks( state, clientId ), + }; + }, + ( state, clientId ) => [ + ...getBlockAttributes.getDependants( state, clientId ), + getBlockDependantsCacheBust( state, clientId ), + ] +); + +export const __unstableGetBlockWithoutInnerBlocks = createSelector( + ( state, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + if ( ! block ) { + return null; + } + + return { + ...block, + attributes: getBlockAttributes( state, clientId ), + }; + }, + ( state, clientId ) => [ + state.blocks.byClientId[ clientId ], + ...getBlockAttributes.getDependants( state, clientId ), + ] +); + +/** + * Returns all block objects for the current post being edited as an array in + * the order they appear in the post. + * + * Note: It's important to memoize this selector to avoid return a new instance + * on each call + * + * @param {Object} state Editor state. + * @param {?String} rootClientId Optional root client ID of block list. + * + * @return {Object[]} Post blocks. + */ +export const getBlocks = createSelector( + ( state, rootClientId ) => { + return map( + getBlockOrder( state, rootClientId ), + ( clientId ) => getBlock( state, clientId ) + ); + }, + ( state ) => [ + state.blocks.byClientId, + state.blocks.order, + state.blocks.attributes, + ] +); + +/** + * Returns an array containing the clientIds of all descendants + * of the blocks given. + * + * @param {Object} state Global application state. + * @param {Array} clientIds Array of blocks to inspect. + * + * @return {Array} ids of descendants. + */ +export const getClientIdsOfDescendants = ( state, clientIds ) => flatMap( clientIds, ( clientId ) => { + const descendants = getBlockOrder( state, clientId ); + return [ ...descendants, ...getClientIdsOfDescendants( state, descendants ) ]; +} ); + +/** + * Returns an array containing the clientIds of the top-level blocks + * and their descendants of any depth (for nested blocks). + * + * @param {Object} state Global application state. + * + * @return {Array} ids of top-level and descendant blocks. + */ +export const getClientIdsWithDescendants = createSelector( + ( state ) => { + const topLevelIds = getBlockOrder( state ); + return [ ...topLevelIds, ...getClientIdsOfDescendants( state, topLevelIds ) ]; + }, + ( state ) => [ + state.blocks.order, + ] +); + +/** + * Returns the total number of blocks, or the total number of blocks with a specific name in a post. + * The number returned includes nested blocks. + * + * @param {Object} state Global application state. + * @param {?String} blockName Optional block name, if specified only blocks of that type will be counted. + * + * @return {number} Number of blocks in the post, or number of blocks with name equal to blockName. + */ +export const getGlobalBlockCount = createSelector( + ( state, blockName ) => { + const clientIds = getClientIdsWithDescendants( state ); + if ( ! blockName ) { + return clientIds.length; + } + return reduce( clientIds, ( count, clientId ) => { + const block = state.blocks.byClientId[ clientId ]; + return block.name === blockName ? count + 1 : count; + }, 0 ); + }, + ( state ) => [ + state.blocks.order, + state.blocks.byClientId, + ] +); + +/** + * Given an array of block client IDs, returns the corresponding array of block + * objects. + * + * @param {Object} state Editor state. + * @param {string[]} clientIds Client IDs for which blocks are to be returned. + * + * @return {WPBlock[]} Block objects. + */ +export const getBlocksByClientId = createSelector( + ( state, clientIds ) => map( + castArray( clientIds ), + ( clientId ) => getBlock( state, clientId ) + ), + ( state ) => [ + getPostMeta( state ), + state.blocks.byClientId, + state.blocks.order, + state.blocks.attributes, + ] +); + +/** + * Returns the number of blocks currently present in the post. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {number} Number of blocks in the post. + */ +export function getBlockCount( state, rootClientId ) { + return getBlockOrder( state, rootClientId ).length; +} + +/** + * Returns the current block selection start. This value may be null, and it + * may represent either a singular block selection or multi-selection start. + * A selection is singular if its start and end match. + * + * @param {Object} state Global application state. + * + * @return {?string} Client ID of block selection start. + */ +export function getBlockSelectionStart( state ) { + return state.blockSelection.start; +} + +/** + * Returns the current block selection end. This value may be null, and it + * may represent either a singular block selection or multi-selection end. + * A selection is singular if its start and end match. + * + * @param {Object} state Global application state. + * + * @return {?string} Client ID of block selection end. + */ +export function getBlockSelectionEnd( state ) { + return state.blockSelection.end; +} + +/** + * Returns the number of blocks currently selected in the post. + * + * @param {Object} state Global application state. + * + * @return {number} Number of blocks selected in the post. + */ +export function getSelectedBlockCount( state ) { + const multiSelectedBlockCount = getMultiSelectedBlockClientIds( state ).length; + + if ( multiSelectedBlockCount ) { + return multiSelectedBlockCount; + } + + return state.blockSelection.start ? 1 : 0; +} + +/** + * Returns true if there is a single selected block, or false otherwise. + * + * @param {Object} state Editor state. + * + * @return {boolean} Whether a single block is selected. + */ +export function hasSelectedBlock( state ) { + const { start, end } = state.blockSelection; + return !! start && start === end; +} + +/** + * Returns the currently selected block client ID, or null if there is no + * selected block. + * + * @param {Object} state Editor state. + * + * @return {?string} Selected block client ID. + */ +export function getSelectedBlockClientId( state ) { + const { start, end } = state.blockSelection; + // We need to check the block exists because the current blockSelection + // reducer doesn't take into account when blocks are reset via undo. To be + // removed when that's fixed. + return start && start === end && !! state.blocks.byClientId[ start ] ? start : null; +} + +/** + * Returns the currently selected block, or null if there is no selected block. + * + * @param {Object} state Global application state. + * + * @return {?Object} Selected block. + */ +export function getSelectedBlock( state ) { + const clientId = getSelectedBlockClientId( state ); + return clientId ? getBlock( state, clientId ) : null; +} + +/** + * Given a block client ID, returns the root block from which the block is + * nested, an empty string for top-level blocks, or null if the block does not + * exist. + * + * @param {Object} state Editor state. + * @param {string} clientId Block from which to find root client ID. + * + * @return {?string} Root client ID, if exists + */ +export const getBlockRootClientId = createSelector( + ( state, clientId ) => { + const { order } = state.blocks; + + for ( const rootClientId in order ) { + if ( includes( order[ rootClientId ], clientId ) ) { + return rootClientId; + } + } + + return null; + }, + ( state ) => [ + state.blocks.order, + ] +); + +/** + * Given a block client ID, returns the root of the hierarchy from which the block is nested, return the block itself for root level blocks. + * + * @param {Object} state Editor state. + * @param {string} clientId Block from which to find root client ID. + * + * @return {string} Root client ID + */ +export const getBlockHierarchyRootClientId = createSelector( + ( state, clientId ) => { + let rootClientId = clientId; + let current = clientId; + while ( rootClientId ) { + current = rootClientId; + rootClientId = getBlockRootClientId( state, current ); + } + + return current; + }, + ( state ) => [ + state.blocks.order, + ] +); + +/** + * Returns the client ID of the block adjacent one at the given reference + * startClientId and modifier directionality. Defaults start startClientId to + * the selected block, and direction as next block. Returns null if there is no + * adjacent block. + * + * @param {Object} state Editor state. + * @param {?string} startClientId Optional client ID of block from which to + * search. + * @param {?number} modifier Directionality multiplier (1 next, -1 + * previous). + * + * @return {?string} Return the client ID of the block, or null if none exists. + */ +export function getAdjacentBlockClientId( state, startClientId, modifier = 1 ) { + // Default to selected block. + if ( startClientId === undefined ) { + startClientId = getSelectedBlockClientId( state ); + } + + // Try multi-selection starting at extent based on modifier. + if ( startClientId === undefined ) { + if ( modifier < 0 ) { + startClientId = getFirstMultiSelectedBlockClientId( state ); + } else { + startClientId = getLastMultiSelectedBlockClientId( state ); + } + } + + // Validate working start client ID. + if ( ! startClientId ) { + return null; + } + + // Retrieve start block root client ID, being careful to allow the falsey + // empty string top-level root by explicitly testing against null. + const rootClientId = getBlockRootClientId( state, startClientId ); + if ( rootClientId === null ) { + return null; + } + + const { order } = state.blocks; + const orderSet = order[ rootClientId ]; + const index = orderSet.indexOf( startClientId ); + const nextIndex = ( index + ( 1 * modifier ) ); + + // Block was first in set and we're attempting to get previous. + if ( nextIndex < 0 ) { + return null; + } + + // Block was last in set and we're attempting to get next. + if ( nextIndex === orderSet.length ) { + return null; + } + + // Assume incremented index is within the set. + return orderSet[ nextIndex ]; +} + +/** + * Returns the previous block's client ID from the given reference start ID. + * Defaults start to the selected block. Returns null if there is no previous + * block. + * + * @param {Object} state Editor state. + * @param {?string} startClientId Optional client ID of block from which to + * search. + * + * @return {?string} Adjacent block's client ID, or null if none exists. + */ +export function getPreviousBlockClientId( state, startClientId ) { + return getAdjacentBlockClientId( state, startClientId, -1 ); +} + +/** + * Returns the next block's client ID from the given reference start ID. + * Defaults start to the selected block. Returns null if there is no next + * block. + * + * @param {Object} state Editor state. + * @param {?string} startClientId Optional client ID of block from which to + * search. + * + * @return {?string} Adjacent block's client ID, or null if none exists. + */ +export function getNextBlockClientId( state, startClientId ) { + return getAdjacentBlockClientId( state, startClientId, 1 ); +} + +/** + * Returns the initial caret position for the selected block. + * This position is to used to position the caret properly when the selected block changes. + * + * @param {Object} state Global application state. + * + * @return {?Object} Selected block. + */ +export function getSelectedBlocksInitialCaretPosition( state ) { + const { start, end } = state.blockSelection; + if ( start !== end || ! start ) { + return null; + } + + return state.blockSelection.initialPosition; +} + +/** + * Returns the current multi-selection set of block client IDs, or an empty + * array if there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {Array} Multi-selected block client IDs. + */ +export const getMultiSelectedBlockClientIds = createSelector( + ( state ) => { + const { start, end } = state.blockSelection; + if ( start === end ) { + return []; + } + + // Retrieve root client ID to aid in retrieving relevant nested block + // order, being careful to allow the falsey empty string top-level root + // by explicitly testing against null. + const rootClientId = getBlockRootClientId( state, start ); + if ( rootClientId === null ) { + return []; + } + + const blockOrder = getBlockOrder( state, rootClientId ); + const startIndex = blockOrder.indexOf( start ); + const endIndex = blockOrder.indexOf( end ); + + if ( startIndex > endIndex ) { + return blockOrder.slice( endIndex, startIndex + 1 ); + } + + return blockOrder.slice( startIndex, endIndex + 1 ); + }, + ( state ) => [ + state.blocks.order, + state.blockSelection.start, + state.blockSelection.end, + ], +); + +/** + * Returns the current multi-selection set of blocks, or an empty array if + * there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {Array} Multi-selected block objects. + */ +export const getMultiSelectedBlocks = createSelector( + ( state ) => { + const multiSelectedBlockClientIds = getMultiSelectedBlockClientIds( state ); + if ( ! multiSelectedBlockClientIds.length ) { + return EMPTY_ARRAY; + } + + return multiSelectedBlockClientIds.map( ( clientId ) => getBlock( state, clientId ) ); + }, + ( state ) => [ + ...getMultiSelectedBlockClientIds.getDependants( state ), + state.blocks.byClientId, + state.blocks.order, + state.blocks.attributes, + getPostMeta( state ), + ] +); + +/** + * Returns the client ID of the first block in the multi-selection set, or null + * if there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {?string} First block client ID in the multi-selection set. + */ +export function getFirstMultiSelectedBlockClientId( state ) { + return first( getMultiSelectedBlockClientIds( state ) ) || null; +} + +/** + * Returns the client ID of the last block in the multi-selection set, or null + * if there is no multi-selection. + * + * @param {Object} state Editor state. + * + * @return {?string} Last block client ID in the multi-selection set. + */ +export function getLastMultiSelectedBlockClientId( state ) { + return last( getMultiSelectedBlockClientIds( state ) ) || null; +} + +/** + * Checks if possibleAncestorId is an ancestor of possibleDescendentId. + * + * @param {Object} state Editor state. + * @param {string} possibleAncestorId Possible ancestor client ID. + * @param {string} possibleDescendentId Possible descent client ID. + * + * @return {boolean} True if possibleAncestorId is an ancestor + * of possibleDescendentId, and false otherwise. + */ +const isAncestorOf = createSelector( + ( state, possibleAncestorId, possibleDescendentId ) => { + let idToCheck = possibleDescendentId; + while ( possibleAncestorId !== idToCheck && idToCheck ) { + idToCheck = getBlockRootClientId( state, idToCheck ); + } + return possibleAncestorId === idToCheck; + }, + ( state ) => [ + state.blocks.order, + ], +); + +/** + * Returns true if a multi-selection exists, and the block corresponding to the + * specified client ID is the first block of the multi-selection set, or false + * otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is first in multi-selection. + */ +export function isFirstMultiSelectedBlock( state, clientId ) { + return getFirstMultiSelectedBlockClientId( state ) === clientId; +} + +/** + * Returns true if the client ID occurs within the block multi-selection, or + * false otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is in multi-selection set. + */ +export function isBlockMultiSelected( state, clientId ) { + return getMultiSelectedBlockClientIds( state ).indexOf( clientId ) !== -1; +} + +/** + * Returns true if an ancestor of the block is multi-selected, or false + * otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether an ancestor of the block is in multi-selection + * set. + */ +export const isAncestorMultiSelected = createSelector( + ( state, clientId ) => { + let ancestorClientId = clientId; + let isMultiSelected = false; + while ( ancestorClientId && ! isMultiSelected ) { + ancestorClientId = getBlockRootClientId( state, ancestorClientId ); + isMultiSelected = isBlockMultiSelected( state, ancestorClientId ); + } + return isMultiSelected; + }, + ( state ) => [ + state.blocks.order, + state.blockSelection.start, + state.blockSelection.end, + ], +); +/** + * Returns the client ID of the block which begins the multi-selection set, or + * null if there is no multi-selection. + * + * This is not necessarily the first client ID in the selection. + * + * @see getFirstMultiSelectedBlockClientId + * + * @param {Object} state Editor state. + * + * @return {?string} Client ID of block beginning multi-selection. + */ +export function getMultiSelectedBlocksStartClientId( state ) { + const { start, end } = state.blockSelection; + if ( start === end ) { + return null; + } + return start || null; +} + +/** + * Returns the client ID of the block which ends the multi-selection set, or + * null if there is no multi-selection. + * + * This is not necessarily the last client ID in the selection. + * + * @see getLastMultiSelectedBlockClientId + * + * @param {Object} state Editor state. + * + * @return {?string} Client ID of block ending multi-selection. + */ +export function getMultiSelectedBlocksEndClientId( state ) { + const { start, end } = state.blockSelection; + if ( start === end ) { + return null; + } + return end || null; +} + +/** + * Returns an array containing all block client IDs in the editor in the order + * they appear. Optionally accepts a root client ID of the block list for which + * the order should be returned, defaulting to the top-level block order. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Array} Ordered client IDs of editor blocks. + */ +export function getBlockOrder( state, rootClientId ) { + return state.blocks.order[ rootClientId || '' ] || EMPTY_ARRAY; +} + +/** + * Returns the index at which the block corresponding to the specified client + * ID occurs within the block order, or `-1` if the block does not exist. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {number} Index at which block exists in order. + */ +export function getBlockIndex( state, clientId, rootClientId ) { + return getBlockOrder( state, rootClientId ).indexOf( clientId ); +} + +/** + * Returns true if the block corresponding to the specified client ID is + * currently selected and no multi-selection exists, or false otherwise. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is selected and multi-selection exists. + */ +export function isBlockSelected( state, clientId ) { + const { start, end } = state.blockSelection; + + if ( start !== end ) { + return false; + } + + return start === clientId; +} + +/** + * Returns true if one of the block's inner blocks is selected. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * @param {boolean} deep Perform a deep check. + * + * @return {boolean} Whether the block as an inner block selected + */ +export function hasSelectedInnerBlock( state, clientId, deep = false ) { + return some( + getBlockOrder( state, clientId ), + ( innerClientId ) => ( + isBlockSelected( state, innerClientId ) || + isBlockMultiSelected( state, innerClientId ) || + ( deep && hasSelectedInnerBlock( state, innerClientId, deep ) ) + ) + ); +} + +/** + * Returns true if the block corresponding to the specified client ID is + * currently selected but isn't the last of the selected blocks. Here "last" + * refers to the block sequence in the document, _not_ the sequence of + * multi-selection, which is why `state.blockSelection.end` isn't used. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Whether block is selected and not the last in the + * selection. + */ +export function isBlockWithinSelection( state, clientId ) { + if ( ! clientId ) { + return false; + } + + const clientIds = getMultiSelectedBlockClientIds( state ); + const index = clientIds.indexOf( clientId ); + return index > -1 && index < clientIds.length - 1; +} + +/** + * Returns true if a multi-selection has been made, or false otherwise. + * + * @param {Object} state Editor state. + * + * @return {boolean} Whether multi-selection has been made. + */ +export function hasMultiSelection( state ) { + const { start, end } = state.blockSelection; + return start !== end; +} + +/** + * Whether in the process of multi-selecting or not. This flag is only true + * while the multi-selection is being selected (by mouse move), and is false + * once the multi-selection has been settled. + * + * @see hasMultiSelection + * + * @param {Object} state Global application state. + * + * @return {boolean} True if multi-selecting, false if not. + */ +export function isMultiSelecting( state ) { + return state.blockSelection.isMultiSelecting; +} + +/** + * Selector that returns if multi-selection is enabled or not. + * + * @param {Object} state Global application state. + * + * @return {boolean} True if it should be possible to multi-select blocks, false if multi-selection is disabled. + */ +export function isSelectionEnabled( state ) { + return state.blockSelection.isEnabled; +} + +/** + * Returns the block's editing mode, defaulting to "visual" if not explicitly + * assigned. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {Object} Block editing mode. + */ +export function getBlockMode( state, clientId ) { + return state.blocksMode[ clientId ] || 'visual'; +} + +/** + * Returns true if the user is typing, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether user is typing. + */ +export function isTyping( state ) { + return state.isTyping; +} + +/** + * Returns true if the caret is within formatted text, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether the caret is within formatted text. + */ +export function isCaretWithinFormattedText( state ) { + return state.isCaretWithinFormattedText; +} + +/** + * Returns the insertion point, the index at which the new inserted block would + * be placed. Defaults to the last index. + * + * @param {Object} state Editor state. + * + * @return {Object} Insertion point object with `rootClientId`, `index`. + */ +export function getBlockInsertionPoint( state ) { + let rootClientId, index; + + const { insertionPoint, blockSelection } = state; + if ( insertionPoint !== null ) { + return insertionPoint; + } + + const { end } = blockSelection; + if ( end ) { + rootClientId = getBlockRootClientId( state, end ) || undefined; + index = getBlockIndex( state, end, rootClientId ) + 1; + } else { + index = getBlockOrder( state ).length; + } + + return { rootClientId, index }; +} + +/** + * Returns true if we should show the block insertion point. + * + * @param {Object} state Global application state. + * + * @return {?boolean} Whether the insertion point is visible or not. + */ +export function isBlockInsertionPointVisible( state ) { + return state.insertionPoint !== null; +} + +/** + * Returns whether the blocks matches the template or not. + * + * @param {boolean} state + * @return {?boolean} Whether the template is valid or not. + */ +export function isValidTemplate( state ) { + return state.template.isValid; +} + +/** + * Returns the defined block template + * + * @param {boolean} state + * @return {?Array} Block Template + */ +export function getTemplate( state ) { + return state.settings.template; +} + +/** + * Returns the defined block template lock. Optionally accepts a root block + * client ID as context, otherwise defaulting to the global context. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional block root client ID. + * + * @return {?string} Block Template Lock + */ +export function getTemplateLock( state, rootClientId ) { + if ( ! rootClientId ) { + return state.settings.templateLock; + } + + const blockListSettings = getBlockListSettings( state, rootClientId ); + if ( ! blockListSettings ) { + return null; + } + + return blockListSettings.templateLock; +} + +/** + * Determines if the given block type is allowed to be inserted into the block list. + * This function is not exported and not memoized because using a memoized selector + * inside another memoized selector is just a waste of time. + * + * @param {Object} state Editor state. + * @param {string} blockName The name of the block type, e.g.' core/paragraph'. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be inserted. + */ +const canInsertBlockTypeUnmemoized = ( state, blockName, rootClientId = null ) => { + const checkAllowList = ( list, item, defaultResult = null ) => { + if ( isBoolean( list ) ) { + return list; + } + if ( isArray( list ) ) { + return includes( list, item ); + } + return defaultResult; + }; + + const blockType = getBlockType( blockName ); + if ( ! blockType ) { + return false; + } + + const { allowedBlockTypes } = getEditorSettings( state ); + + const isBlockAllowedInEditor = checkAllowList( allowedBlockTypes, blockName, true ); + if ( ! isBlockAllowedInEditor ) { + return false; + } + + const isLocked = !! getTemplateLock( state, rootClientId ); + if ( isLocked ) { + return false; + } + + const parentBlockListSettings = getBlockListSettings( state, rootClientId ); + const parentAllowedBlocks = get( parentBlockListSettings, [ 'allowedBlocks' ] ); + const hasParentAllowedBlock = checkAllowList( parentAllowedBlocks, blockName ); + + const blockAllowedParentBlocks = blockType.parent; + const parentName = getBlockName( state, rootClientId ); + const hasBlockAllowedParent = checkAllowList( blockAllowedParentBlocks, parentName ); + + if ( hasParentAllowedBlock !== null && hasBlockAllowedParent !== null ) { + return hasParentAllowedBlock || hasBlockAllowedParent; + } else if ( hasParentAllowedBlock !== null ) { + return hasParentAllowedBlock; + } else if ( hasBlockAllowedParent !== null ) { + return hasBlockAllowedParent; + } + + return true; +}; + +/** + * Determines if the given block type is allowed to be inserted into the block list. + * + * @param {Object} state Editor state. + * @param {string} blockName The name of the block type, e.g.' core/paragraph'. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be inserted. + */ +export const canInsertBlockType = createSelector( + canInsertBlockTypeUnmemoized, + ( state, blockName, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.blocks.byClientId[ rootClientId ], + state.settings.allowedBlockTypes, + state.settings.templateLock, + ], +); + +/** + * Returns information about how recently and frequently a block has been inserted. + * + * @param {Object} state Global application state. + * @param {string} id A string which identifies the insert, e.g. 'core/block/12' + * + * @return {?{ time: number, count: number }} An object containing `time` which is when the last + * insert occurred as a UNIX epoch, and `count` which is + * the number of inserts that have occurred. + */ +function getInsertUsage( state, id ) { + return state.preferences.insertUsage[ id ] || null; +} + +/** + * Returns whether we can show a block type in the inserter + * + * @param {Object} state Global State + * @param {Object} blockType BlockType + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be shown in the inserter. + */ +const canIncludeBlockTypeInInserter = ( state, blockType, rootClientId ) => { + if ( ! hasBlockSupport( blockType, 'inserter', true ) ) { + return false; + } + + return canInsertBlockTypeUnmemoized( state, blockType.name, rootClientId ); +}; + +/** + * Returns whether we can show a reusable block in the inserter + * + * @param {Object} state Global State + * @param {Object} reusableBlock Reusable block object + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be shown in the inserter. + */ +const canIncludeReusableBlockInInserter = ( state, reusableBlock, rootClientId ) => { + if ( ! canInsertBlockTypeUnmemoized( state, 'core/block', rootClientId ) ) { + return false; + } + + const referencedBlockName = getBlockName( state, reusableBlock.clientId ); + if ( ! referencedBlockName ) { + return false; + } + + const referencedBlockType = getBlockType( referencedBlockName ); + if ( ! referencedBlockType ) { + return false; + } + + if ( ! canInsertBlockTypeUnmemoized( state, referencedBlockName, rootClientId ) ) { + return false; + } + + if ( isAncestorOf( state, reusableBlock.clientId, rootClientId ) ) { + return false; + } + + return true; +}; + +/** + * Determines the items that appear in the inserter. Includes both static + * items (e.g. a regular block type) and dynamic items (e.g. a reusable block). + * + * Each item object contains what's necessary to display a button in the + * inserter and handle its selection. + * + * The 'utility' property indicates how useful we think an item will be to the + * user. There are 4 levels of utility: + * + * 1. Blocks that are contextually useful (utility = 3) + * 2. Blocks that have been previously inserted (utility = 2) + * 3. Blocks that are in the common category (utility = 1) + * 4. All other blocks (utility = 0) + * + * The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) + * that combines block usage frequenty and recency. + * + * Items are returned ordered descendingly by their 'utility' and 'frecency'. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Editor.InserterItem[]} Items that appear in inserter. + * + * @typedef {Object} Editor.InserterItem + * @property {string} id Unique identifier for the item. + * @property {string} name The type of block to create. + * @property {Object} initialAttributes Attributes to pass to the newly created block. + * @property {string} title Title of the item, as it appears in the inserter. + * @property {string} icon Dashicon for the item, as it appears in the inserter. + * @property {string} category Block category that the item is associated with. + * @property {string[]} keywords Keywords that can be searched to find this item. + * @property {boolean} isDisabled Whether or not the user should be prevented from inserting + * this item. + * @property {number} utility How useful we think this item is, between 0 and 3. + * @property {number} frecency Hueristic that combines frequency and recency. + */ +export const getInserterItems = createSelector( + ( state, rootClientId = null ) => { + const calculateUtility = ( category, count, isContextual ) => { + if ( isContextual ) { + return INSERTER_UTILITY_HIGH; + } else if ( count > 0 ) { + return INSERTER_UTILITY_MEDIUM; + } else if ( category === 'common' ) { + return INSERTER_UTILITY_LOW; + } + return INSERTER_UTILITY_NONE; + }; + + const calculateFrecency = ( time, count ) => { + if ( ! time ) { + return count; + } + + // The selector is cached, which means Date.now() is the last time that the + // relevant state changed. This suits our needs. + const duration = Date.now() - time; + + switch ( true ) { + case duration < MILLISECONDS_PER_HOUR: + return count * 4; + case duration < MILLISECONDS_PER_DAY: + return count * 2; + case duration < MILLISECONDS_PER_WEEK: + return count / 2; + default: + return count / 4; + } + }; + + const buildBlockTypeInserterItem = ( blockType ) => { + const id = blockType.name; + + let isDisabled = false; + if ( ! hasBlockSupport( blockType.name, 'multiple', true ) ) { + isDisabled = some( getBlocksByClientId( state, getClientIdsWithDescendants( state ) ), { name: blockType.name } ); + } + + const isContextual = isArray( blockType.parent ); + const { time, count = 0 } = getInsertUsage( state, id ) || {}; + + return { + id, + name: blockType.name, + initialAttributes: {}, + title: blockType.title, + icon: blockType.icon, + category: blockType.category, + keywords: blockType.keywords, + isDisabled, + utility: calculateUtility( blockType.category, count, isContextual ), + frecency: calculateFrecency( time, count ), + hasChildBlocksWithInserterSupport: hasChildBlocksWithInserterSupport( blockType.name ), + }; + }; + + const buildReusableBlockInserterItem = ( reusableBlock ) => { + const id = `core/block/${ reusableBlock.id }`; + + const referencedBlockName = getBlockName( state, reusableBlock.clientId ); + const referencedBlockType = getBlockType( referencedBlockName ); + + const { time, count = 0 } = getInsertUsage( state, id ) || {}; + const utility = calculateUtility( 'reusable', count, false ); + const frecency = calculateFrecency( time, count ); + + return { + id, + name: 'core/block', + initialAttributes: { ref: reusableBlock.id }, + title: reusableBlock.title, + icon: referencedBlockType.icon, + category: 'reusable', + keywords: [], + isDisabled: false, + utility, + frecency, + }; + }; + + const blockTypeInserterItems = getBlockTypes() + .filter( ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) ) + .map( buildBlockTypeInserterItem ); + + const reusableBlockInserterItems = getReusableBlocks( state ) + .filter( ( block ) => canIncludeReusableBlockInInserter( state, block, rootClientId ) ) + .map( buildReusableBlockInserterItem ); + + return orderBy( + [ ...blockTypeInserterItems, ...reusableBlockInserterItems ], + [ 'utility', 'frecency' ], + [ 'desc', 'desc' ] + ); + }, + ( state, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.blocks.byClientId, + state.blocks.order, + state.preferences.insertUsage, + state.settings.allowedBlockTypes, + state.settings.templateLock, + getReusableBlocks( state ), + getBlockTypes(), + ], +); + +/** + * Determines whether there are items to show in the inserter. + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Items that appear in inserter. + */ +export const hasInserterItems = createSelector( + ( state, rootClientId = null ) => { + const hasBlockType = some( + getBlockTypes(), + ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) + ); + if ( hasBlockType ) { + return true; + } + const hasReusableBlock = some( + getReusableBlocks( state ), + ( block ) => canIncludeReusableBlockInInserter( state, block, rootClientId ) + ); + + return hasReusableBlock; + }, + ( state, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.blocks.byClientId, + state.settings.allowedBlockTypes, + state.settings.templateLock, + getReusableBlocks( state ), + getBlockTypes(), + ], +); + +/** + * Returns the Block List settings of a block, if any exist. + * + * @param {Object} state Editor state. + * @param {?string} clientId Block client ID. + * + * @return {?Object} Block settings of the block if set. + */ +export function getBlockListSettings( state, clientId ) { + return state.blockListSettings[ clientId ]; +} + +/** + * Returns the editor settings. + * + * @param {Object} state Editor state. + * + * @return {Object} The editor settings object. + */ +export function getEditorSettings( state ) { + return state.settings; +} + +/** + * Returns true if the most recent block change is be considered persistent, or + * false otherwise. A persistent change is one committed by BlockEditorProvider + * via its `onChange` callback, in addition to `onInput`. + * + * @param {Object} state Block editor state. + * + * @return {boolean} Whether the most recent block change was persistent. + */ +export function isLastBlockChangePersistent( state ) { + return state.blocks.isPersistentChange; +} + +/** + * Returns the value of a post meta from the editor settings. + * + * @param {Object} state Global application state. + * @param {string} key Meta Key to retrieve + * + * @return {*} Meta value + */ +function getPostMeta( state, key ) { + if ( key === undefined ) { + return get( state, [ 'settings', '__experimentalMetaSource', 'value' ], EMPTY_OBJECT ); + } + + return get( state, [ 'settings', '__experimentalMetaSource', 'value', key ] ); +} + +/** + * Returns the available reusable blocks + * + * @param {Object} state Global application state. + * + * @return {Array} Reusable blocks + */ +function getReusableBlocks( state ) { + return get( state, [ 'settings', '__experimentalReusableBlocks' ], EMPTY_ARRAY ); +} diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js new file mode 100644 index 0000000000000..3ae9039505356 --- /dev/null +++ b/packages/block-editor/src/store/test/actions.js @@ -0,0 +1,336 @@ +/** + * Internal dependencies + */ +import { + replaceBlocks, + startTyping, + stopTyping, + enterFormattedText, + exitFormattedText, + toggleSelection, + resetBlocks, + updateBlockAttributes, + updateBlock, + selectBlock, + selectPreviousBlock, + startMultiSelect, + stopMultiSelect, + multiSelect, + clearSelectedBlock, + replaceBlock, + insertBlock, + insertBlocks, + showInsertionPoint, + hideInsertionPoint, + mergeBlocks, + removeBlocks, + removeBlock, + toggleBlockMode, + updateBlockListSettings, +} from '../actions'; + +describe( 'actions', () => { + describe( 'resetBlocks', () => { + it( 'should return the RESET_BLOCKS actions', () => { + const blocks = []; + const result = resetBlocks( blocks ); + expect( result ).toEqual( { + type: 'RESET_BLOCKS', + blocks, + } ); + } ); + } ); + + describe( 'updateBlockAttributes', () => { + it( 'should return the UPDATE_BLOCK_ATTRIBUTES action', () => { + const clientId = 'myclientid'; + const attributes = {}; + const result = updateBlockAttributes( clientId, attributes ); + expect( result ).toEqual( { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId, + attributes, + } ); + } ); + } ); + + describe( 'updateBlock', () => { + it( 'should return the UPDATE_BLOCK action', () => { + const clientId = 'myclientid'; + const updates = {}; + const result = updateBlock( clientId, updates ); + expect( result ).toEqual( { + type: 'UPDATE_BLOCK', + clientId, + updates, + } ); + } ); + } ); + + describe( 'selectBlock', () => { + it( 'should return the SELECT_BLOCK action', () => { + const clientId = 'myclientid'; + const result = selectBlock( clientId, -1 ); + expect( result ).toEqual( { + type: 'SELECT_BLOCK', + initialPosition: -1, + clientId, + } ); + } ); + } ); + + describe( 'startMultiSelect', () => { + it( 'should return the START_MULTI_SELECT', () => { + expect( startMultiSelect() ).toEqual( { + type: 'START_MULTI_SELECT', + } ); + } ); + } ); + + describe( 'stopMultiSelect', () => { + it( 'should return the Stop_MULTI_SELECT', () => { + expect( stopMultiSelect() ).toEqual( { + type: 'STOP_MULTI_SELECT', + } ); + } ); + } ); + describe( 'multiSelect', () => { + it( 'should return MULTI_SELECT action', () => { + const start = 'start'; + const end = 'end'; + expect( multiSelect( start, end ) ).toEqual( { + type: 'MULTI_SELECT', + start, + end, + } ); + } ); + } ); + + describe( 'clearSelectedBlock', () => { + it( 'should return CLEAR_SELECTED_BLOCK action', () => { + expect( clearSelectedBlock() ).toEqual( { + type: 'CLEAR_SELECTED_BLOCK', + } ); + } ); + } ); + + describe( 'replaceBlock', () => { + it( 'should return the REPLACE_BLOCKS action', () => { + const block = { + clientId: 'ribs', + }; + + expect( replaceBlock( [ 'chicken' ], block ) ).toEqual( { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ block ], + time: expect.any( Number ), + } ); + } ); + } ); + + describe( 'replaceBlocks', () => { + it( 'should return the REPLACE_BLOCKS action', () => { + const blocks = [ { + clientId: 'ribs', + } ]; + + expect( replaceBlocks( [ 'chicken' ], blocks ) ).toEqual( { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks, + time: expect.any( Number ), + } ); + } ); + } ); + + describe( 'insertBlock', () => { + it( 'should return the INSERT_BLOCKS action', () => { + const block = { + clientId: 'ribs', + }; + const index = 5; + expect( insertBlock( block, index, 'testclientid' ) ).toEqual( { + type: 'INSERT_BLOCKS', + blocks: [ block ], + index, + rootClientId: 'testclientid', + time: expect.any( Number ), + updateSelection: true, + } ); + } ); + } ); + + describe( 'insertBlocks', () => { + it( 'should return the INSERT_BLOCKS action', () => { + const blocks = [ { + clientId: 'ribs', + } ]; + const index = 3; + expect( insertBlocks( blocks, index, 'testclientid' ) ).toEqual( { + type: 'INSERT_BLOCKS', + blocks, + index, + rootClientId: 'testclientid', + time: expect.any( Number ), + updateSelection: true, + } ); + } ); + } ); + + describe( 'showInsertionPoint', () => { + it( 'should return the SHOW_INSERTION_POINT action', () => { + expect( showInsertionPoint() ).toEqual( { + type: 'SHOW_INSERTION_POINT', + } ); + } ); + } ); + + describe( 'hideInsertionPoint', () => { + it( 'should return the HIDE_INSERTION_POINT action', () => { + expect( hideInsertionPoint() ).toEqual( { + type: 'HIDE_INSERTION_POINT', + } ); + } ); + } ); + + describe( 'mergeBlocks', () => { + it( 'should return MERGE_BLOCKS action', () => { + const firstBlockClientId = 'blockA'; + const secondBlockClientId = 'blockB'; + expect( mergeBlocks( firstBlockClientId, secondBlockClientId ) ).toEqual( { + type: 'MERGE_BLOCKS', + blocks: [ firstBlockClientId, secondBlockClientId ], + } ); + } ); + } ); + + describe( 'removeBlocks', () => { + it( 'should return REMOVE_BLOCKS action', () => { + const clientId = 'clientId'; + const clientIds = [ clientId ]; + + const actions = Array.from( removeBlocks( clientIds ) ); + + expect( actions ).toEqual( [ + selectPreviousBlock( clientId ), + { + type: 'REMOVE_BLOCKS', + clientIds, + }, + ] ); + } ); + } ); + + describe( 'removeBlock', () => { + it( 'should return REMOVE_BLOCKS action', () => { + const clientId = 'myclientid'; + + const actions = Array.from( removeBlock( clientId ) ); + + expect( actions ).toEqual( [ + selectPreviousBlock( clientId ), + { + type: 'REMOVE_BLOCKS', + clientIds: [ clientId ], + }, + ] ); + } ); + + it( 'should return REMOVE_BLOCKS action, opting out of remove previous', () => { + const clientId = 'myclientid'; + + const actions = Array.from( removeBlock( clientId, false ) ); + + expect( actions ).toEqual( [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ clientId ], + }, + ] ); + } ); + } ); + + describe( 'toggleBlockMode', () => { + it( 'should return TOGGLE_BLOCK_MODE action', () => { + const clientId = 'myclientid'; + expect( toggleBlockMode( clientId ) ).toEqual( { + type: 'TOGGLE_BLOCK_MODE', + clientId, + } ); + } ); + } ); + + describe( 'startTyping', () => { + it( 'should return the START_TYPING action', () => { + expect( startTyping() ).toEqual( { + type: 'START_TYPING', + } ); + } ); + } ); + + describe( 'stopTyping', () => { + it( 'should return the STOP_TYPING action', () => { + expect( stopTyping() ).toEqual( { + type: 'STOP_TYPING', + } ); + } ); + } ); + + describe( 'enterFormattedText', () => { + it( 'should return the ENTER_FORMATTED_TEXT action', () => { + expect( enterFormattedText() ).toEqual( { + type: 'ENTER_FORMATTED_TEXT', + } ); + } ); + } ); + + describe( 'exitFormattedText', () => { + it( 'should return the EXIT_FORMATTED_TEXT action', () => { + expect( exitFormattedText() ).toEqual( { + type: 'EXIT_FORMATTED_TEXT', + } ); + } ); + } ); + + describe( 'toggleSelection', () => { + it( 'should return the TOGGLE_SELECTION action with default value for isSelectionEnabled = true', () => { + expect( toggleSelection() ).toEqual( { + type: 'TOGGLE_SELECTION', + isSelectionEnabled: true, + } ); + } ); + + it( 'should return the TOGGLE_SELECTION action with isSelectionEnabled = true as passed in the argument', () => { + expect( toggleSelection( true ) ).toEqual( { + type: 'TOGGLE_SELECTION', + isSelectionEnabled: true, + } ); + } ); + + it( 'should return the TOGGLE_SELECTION action with isSelectionEnabled = false as passed in the argument', () => { + expect( toggleSelection( false ) ).toEqual( { + type: 'TOGGLE_SELECTION', + isSelectionEnabled: false, + } ); + } ); + } ); + + describe( 'updateBlockListSettings', () => { + it( 'should return the UPDATE_BLOCK_LIST_SETTINGS with undefined settings', () => { + expect( updateBlockListSettings( 'chicken' ) ).toEqual( { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: 'chicken', + settings: undefined, + } ); + } ); + + it( 'should return the UPDATE_BLOCK_LIST_SETTINGS action with the passed settings', () => { + expect( updateBlockListSettings( 'chicken', { chicken: 'ribs' } ) ).toEqual( { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: 'chicken', + settings: { chicken: 'ribs' }, + } ); + } ); + } ); +} ); diff --git a/packages/editor/src/store/test/array.js b/packages/block-editor/src/store/test/array.js similarity index 100% rename from packages/editor/src/store/test/array.js rename to packages/block-editor/src/store/test/array.js diff --git a/packages/block-editor/src/store/test/effects.js b/packages/block-editor/src/store/test/effects.js new file mode 100644 index 0000000000000..090779cbad0d9 --- /dev/null +++ b/packages/block-editor/src/store/test/effects.js @@ -0,0 +1,280 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + getBlockTypes, + unregisterBlockType, + registerBlockType, + createBlock, +} from '@wordpress/blocks'; +import { createRegistry } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import actions, { + updateEditorSettings, + mergeBlocks, + replaceBlocks, + resetBlocks, + selectBlock, + setTemplateValidity, +} from '../actions'; +import effects, { validateBlocksToTemplate } from '../effects'; +import * as selectors from '../selectors'; +import reducer from '../reducer'; +import applyMiddlewares from '../middlewares'; +import '../../'; + +describe( 'effects', () => { + const defaultBlockSettings = { save: () => 'Saved', category: 'common', title: 'block title' }; + + describe( '.MERGE_BLOCKS', () => { + const handler = effects.MERGE_BLOCKS; + const defaultGetBlock = selectors.getBlock; + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + selectors.getBlock = defaultGetBlock; + } ); + + it( 'should only focus the blockA if the blockA has no merge function', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + const blockA = { + clientId: 'chicken', + name: 'core/test-block', + }; + const blockB = { + clientId: 'ribs', + name: 'core/test-block', + }; + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + + const dispatch = jest.fn(); + const getState = () => ( {} ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( selectBlock( 'chicken' ) ); + } ); + + it( 'should merge the blocks if blocks of the same type', () => { + registerBlockType( 'core/test-block', { + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + const blockA = { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + }; + const blockB = { + clientId: 'ribs', + name: 'core/test-block', + attributes: { content: 'ribs' }, + }; + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + const dispatch = jest.fn(); + const getState = () => ( {} ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + expect( dispatch ).toHaveBeenCalledWith( selectBlock( 'chicken', -1 ) ); + expect( dispatch ).toHaveBeenCalledWith( { + ...replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ), + time: expect.any( Number ), + } ); + } ); + + it( 'should not merge the blocks have different types without transformation', () => { + registerBlockType( 'core/test-block', { + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + registerBlockType( 'core/test-block-2', defaultBlockSettings ); + const blockA = { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + }; + const blockB = { + clientId: 'ribs', + name: 'core/test-block2', + attributes: { content: 'ribs' }, + }; + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + const dispatch = jest.fn(); + const getState = () => ( {} ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).not.toHaveBeenCalled(); + } ); + + it( 'should transform and merge the blocks', () => { + registerBlockType( 'core/test-block', { + attributes: { + content: { + type: 'string', + }, + }, + merge( attributes, attributesToMerge ) { + return { + content: attributes.content + ' ' + attributesToMerge.content, + }; + }, + save: noop, + category: 'common', + title: 'test block', + } ); + registerBlockType( 'core/test-block-2', { + attributes: { + content: { + type: 'string', + }, + }, + transforms: { + to: [ { + type: 'block', + blocks: [ 'core/test-block' ], + transform: ( { content2 } ) => { + return createBlock( 'core/test-block', { + content: content2, + } ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'test block 2', + } ); + const blockA = { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken' }, + }; + const blockB = { + clientId: 'ribs', + name: 'core/test-block-2', + attributes: { content2: 'ribs' }, + }; + selectors.getBlock = ( state, clientId ) => { + return blockA.clientId === clientId ? blockA : blockB; + }; + const dispatch = jest.fn(); + const getState = () => ( {} ); + handler( mergeBlocks( blockA.clientId, blockB.clientId ), { dispatch, getState } ); + + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + // expect( dispatch ).toHaveBeenCalledWith( focusBlock( 'chicken', { offset: -1 } ) ); + expect( dispatch ).toHaveBeenCalledWith( { + ...replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ), + time: expect.any( Number ), + } ); + } ); + } ); + + describe( 'validateBlocksToTemplate', () => { + let store; + beforeEach( () => { + store = createRegistry().registerStore( 'test', { + actions, + selectors, + reducer, + } ); + applyMiddlewares( store ); + + registerBlockType( 'core/test-block', defaultBlockSettings ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'should return undefined if no template assigned', () => { + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toBe( undefined ); + } ); + + it( 'should return undefined if invalid but unlocked', () => { + store.dispatch( updateEditorSettings( { + template: [ + [ 'core/foo', {} ], + ], + } ) ); + + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toBe( undefined ); + } ); + + it( 'should return undefined if locked and valid', () => { + store.dispatch( updateEditorSettings( { + template: [ + [ 'core/test-block' ], + ], + templateLock: 'all', + } ) ); + + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toBe( undefined ); + } ); + + it( 'should return validity set action if invalid on default state', () => { + store.dispatch( updateEditorSettings( { + template: [ + [ 'core/foo' ], + ], + templateLock: 'all', + } ) ); + + const result = validateBlocksToTemplate( resetBlocks( [ + createBlock( 'core/test-block' ), + ] ), store ); + + expect( result ).toEqual( setTemplateValidity( false ) ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js new file mode 100644 index 0000000000000..6f1fae9dc0f03 --- /dev/null +++ b/packages/block-editor/src/store/test/reducer.js @@ -0,0 +1,1721 @@ +/** + * External dependencies + */ +import { values, noop } from 'lodash'; +import deepFreeze from 'deep-freeze'; + +/** + * WordPress dependencies + */ +import { + registerBlockType, + unregisterBlockType, + createBlock, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + hasSameKeys, + isUpdatingSameBlockAttribute, + blocks, + isTyping, + isCaretWithinFormattedText, + blockSelection, + preferences, + blocksMode, + insertionPoint, + template, + blockListSettings, +} from '../reducer'; + +describe( 'state', () => { + describe( 'hasSameKeys()', () => { + it( 'returns false if two objects do not have the same keys', () => { + const a = { foo: 10 }; + const b = { bar: 10 }; + + expect( hasSameKeys( a, b ) ).toBe( false ); + } ); + + it( 'returns false if two objects have the same keys', () => { + const a = { foo: 10 }; + const b = { foo: 20 }; + + expect( hasSameKeys( a, b ) ).toBe( true ); + } ); + } ); + + describe( 'isUpdatingSameBlockAttribute()', () => { + it( 'should return false if not updating block attributes', () => { + const action = { + type: 'SELECT_BLOCK', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }; + const previousAction = { + type: 'SELECT_BLOCK', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if last action was not updating block attributes', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'SELECT_BLOCK', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if not updating the same block', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + attributes: { + foo: 20, + }, + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if not updating the same block attributes', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + bar: 20, + }, + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return false if no previous action', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = undefined; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( false ); + } ); + + it( 'should return true if updating the same block attributes', () => { + const action = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 10, + }, + }; + const previousAction = { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + attributes: { + foo: 20, + }, + }; + + expect( isUpdatingSameBlockAttribute( action, previousAction ) ).toBe( true ); + } ); + } ); + + describe( 'blocks()', () => { + beforeAll( () => { + registerBlockType( 'core/test-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test block', + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-block' ); + } ); + + it( 'should return empty byClientId, attributes, order by default', () => { + const state = blocks( undefined, {} ); + + expect( state ).toEqual( { + byClientId: {}, + attributes: {}, + order: {}, + isPersistentChange: true, + } ); + } ); + + it( 'should key by reset blocks clientId', () => { + [ + undefined, + blocks( undefined, {} ), + ].forEach( ( original ) => { + const state = blocks( original, { + type: 'RESET_BLOCKS', + blocks: [ { clientId: 'bananas', innerBlocks: [] } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 1 ); + expect( values( state.byClientId )[ 0 ].clientId ).toBe( 'bananas' ); + expect( state.order ).toEqual( { + '': [ 'bananas' ], + bananas: [], + } ); + } ); + } ); + + it( 'should key by reset blocks clientId, including inner blocks', () => { + const original = blocks( undefined, {} ); + const state = blocks( original, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'bananas', + innerBlocks: [ { clientId: 'apples', innerBlocks: [] } ], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 2 ); + expect( state.order ).toEqual( { + '': [ 'bananas' ], + apples: [], + bananas: [ 'apples' ], + } ); + } ); + + it( 'should insert block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 2 ); + expect( values( state.byClientId )[ 1 ].clientId ).toBe( 'ribs' ); + expect( state.order ).toEqual( { + '': [ 'chicken', 'ribs' ], + chicken: [], + ribs: [], + } ); + } ); + + it( 'should replace the block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 1 ); + expect( values( state.byClientId )[ 0 ].name ).toBe( 'core/freeform' ); + expect( values( state.byClientId )[ 0 ].clientId ).toBe( 'wings' ); + expect( state.order ).toEqual( { + '': [ 'wings' ], + wings: [], + } ); + } ); + + it( 'should replace the nested block', () => { + const nestedBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ nestedBlock ] ); + const replacementBlock = createBlock( 'core/test-block' ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + + const state = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ nestedBlock.clientId ], + blocks: [ replacementBlock ], + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ replacementBlock.clientId ], + [ replacementBlock.clientId ]: [], + } ); + } ); + + it( 'should replace the block even if the new block clientId is the same', () => { + const originalState = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const replacedState = blocks( originalState, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'chicken', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( replacedState.byClientId ) ).toHaveLength( 1 ); + expect( values( originalState.byClientId )[ 0 ].name ).toBe( 'core/test-block' ); + expect( values( replacedState.byClientId )[ 0 ].name ).toBe( 'core/freeform' ); + expect( values( replacedState.byClientId )[ 0 ].clientId ).toBe( 'chicken' ); + expect( replacedState.order ).toEqual( { + '': [ 'chicken' ], + chicken: [], + } ); + + const nestedBlock = { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }; + const wrapperBlock = createBlock( 'core/test-block', {}, [ nestedBlock ] ); + const replacementNestedBlock = { + clientId: 'chicken', + name: 'core/freeform', + attributes: {}, + innerBlocks: [], + }; + + const originalNestedState = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + + const replacedNestedState = blocks( originalNestedState, { + type: 'REPLACE_BLOCKS', + clientIds: [ nestedBlock.clientId ], + blocks: [ replacementNestedBlock ], + } ); + + expect( replacedNestedState.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ replacementNestedBlock.clientId ], + [ replacementNestedBlock.clientId ]: [], + } ); + + expect( originalNestedState.byClientId.chicken.name ).toBe( 'core/test-block' ); + expect( replacedNestedState.byClientId.chicken.name ).toBe( 'core/freeform' ); + } ); + + it( 'should update the block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + isValid: false, + innerBlocks: [], + } ], + } ); + const state = blocks( deepFreeze( original ), { + type: 'UPDATE_BLOCK', + clientId: 'chicken', + updates: { + attributes: { content: 'ribs' }, + isValid: true, + }, + } ); + + expect( state.byClientId.chicken ).toEqual( { + clientId: 'chicken', + name: 'core/test-block', + isValid: true, + } ); + + expect( state.attributes.chicken ).toEqual( { + content: 'ribs', + } ); + } ); + + it( 'should update the reusable block reference if the temporary id is swapped', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/block', + attributes: { + ref: 'random-clientId', + }, + isValid: false, + innerBlocks: [], + } ], + } ); + + const state = blocks( deepFreeze( original ), { + type: 'SAVE_REUSABLE_BLOCK_SUCCESS', + id: 'random-clientId', + updatedId: 3, + } ); + + expect( state.byClientId.chicken ).toEqual( { + clientId: 'chicken', + name: 'core/block', + isValid: false, + } ); + + expect( state.attributes.chicken ).toEqual( { + ref: 3, + } ); + } ); + + it( 'should move the block up', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'ribs' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'chicken' ] ); + } ); + + it( 'should move the nested block up', () => { + const movedBlock = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ siblingBlock, movedBlock ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ movedBlock.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ movedBlock.clientId, siblingBlock.clientId ], + [ movedBlock.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + } ); + + it( 'should move multiple blocks up', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'ribs', 'veggies' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'veggies', 'chicken' ] ); + } ); + + it( 'should move multiple nested blocks up', () => { + const movedBlockA = createBlock( 'core/test-block' ); + const movedBlockB = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ siblingBlock, movedBlockA, movedBlockB ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ movedBlockA.clientId, movedBlockB.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ movedBlockA.clientId, movedBlockB.clientId, siblingBlock.clientId ], + [ movedBlockA.clientId ]: [], + [ movedBlockB.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + } ); + + it( 'should not move the first block up', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'chicken' ], + } ); + + expect( state.order ).toBe( original.order ); + } ); + + it( 'should move the block down', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ 'chicken' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'chicken' ] ); + } ); + + it( 'should move the nested block down', () => { + const movedBlock = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ movedBlock, siblingBlock ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ movedBlock.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ siblingBlock.clientId, movedBlock.clientId ], + [ movedBlock.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + } ); + + it( 'should move multiple blocks down', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ 'chicken', 'ribs' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'veggies', 'chicken', 'ribs' ] ); + } ); + + it( 'should move multiple nested blocks down', () => { + const movedBlockA = createBlock( 'core/test-block' ); + const movedBlockB = createBlock( 'core/test-block' ); + const siblingBlock = createBlock( 'core/test-block' ); + const wrapperBlock = createBlock( 'core/test-block', {}, [ movedBlockA, movedBlockB, siblingBlock ] ); + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ wrapperBlock ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ movedBlockA.clientId, movedBlockB.clientId ], + rootClientId: wrapperBlock.clientId, + } ); + + expect( state.order ).toEqual( { + '': [ wrapperBlock.clientId ], + [ wrapperBlock.clientId ]: [ siblingBlock.clientId, movedBlockA.clientId, movedBlockB.clientId ], + [ movedBlockA.clientId ]: [], + [ movedBlockB.clientId ]: [], + [ siblingBlock.clientId ]: [], + } ); + } ); + + it( 'should not move the last block down', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCKS_DOWN', + clientIds: [ 'ribs' ], + } ); + + expect( state.order ).toBe( original.order ); + } ); + + it( 'should remove the block', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'chicken' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs' ] ); + expect( state.order ).not.toHaveProperty( 'chicken' ); + expect( state.byClientId ).toEqual( { + ribs: { + clientId: 'ribs', + name: 'core/test-block', + }, + } ); + expect( state.attributes ).toEqual( { + ribs: {}, + } ); + } ); + + it( 'should remove multiple blocks', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'chicken', 'veggies' ], + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs' ] ); + expect( state.order ).not.toHaveProperty( 'chicken' ); + expect( state.order ).not.toHaveProperty( 'veggies' ); + expect( state.byClientId ).toEqual( { + ribs: { + clientId: 'ribs', + name: 'core/test-block', + }, + } ); + expect( state.attributes ).toEqual( { + ribs: {}, + } ); + } ); + + it( 'should cascade remove to include inner blocks', () => { + const block = createBlock( 'core/test-block', {}, [ + createBlock( 'core/test-block', {}, [ + createBlock( 'core/test-block' ), + ] ), + ] ); + + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ block ], + } ); + + const state = blocks( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ block.clientId ], + } ); + + expect( state.byClientId ).toEqual( {} ); + expect( state.order ).toEqual( { + '': [], + } ); + } ); + + it( 'should insert at the specified index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'loquat', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + + const state = blocks( original, { + type: 'INSERT_BLOCKS', + index: 1, + blocks: [ { + clientId: 'persimmon', + name: 'core/freeform', + innerBlocks: [], + } ], + } ); + + expect( Object.keys( state.byClientId ) ).toHaveLength( 3 ); + expect( state.order[ '' ] ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] ); + } ); + + it( 'should move block to lower index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCK_TO_POSITION', + clientId: 'ribs', + index: 0, + } ); + + expect( state.order[ '' ] ).toEqual( [ 'ribs', 'chicken', 'veggies' ] ); + } ); + + it( 'should move block to higher index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCK_TO_POSITION', + clientId: 'ribs', + index: 2, + } ); + + expect( state.order[ '' ] ).toEqual( [ 'chicken', 'veggies', 'ribs' ] ); + } ); + + it( 'should not move block if passed same index', () => { + const original = blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'ribs', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + }, { + clientId: 'veggies', + name: 'core/test-block', + attributes: {}, + innerBlocks: [], + } ], + } ); + const state = blocks( original, { + type: 'MOVE_BLOCK_TO_POSITION', + clientId: 'ribs', + index: 1, + } ); + + expect( state.order[ '' ] ).toEqual( [ 'chicken', 'ribs', 'veggies' ] ); + } ); + + describe( 'blocks', () => { + it( 'should not reset any blocks that are not in the post', () => { + const actions = [ + { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'block1', + innerBlocks: [ + { clientId: 'block11', innerBlocks: [] }, + { clientId: 'block12', innerBlocks: [] }, + ], + }, + ], + }, + { + type: 'RECEIVE_BLOCKS', + blocks: [ + { + clientId: 'block2', + innerBlocks: [ + { clientId: 'block21', innerBlocks: [] }, + { clientId: 'block22', innerBlocks: [] }, + ], + }, + ], + }, + ]; + const original = deepFreeze( actions.reduce( blocks, undefined ) ); + + const state = blocks( original, { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'block3', + innerBlocks: [ + { clientId: 'block31', innerBlocks: [] }, + { clientId: 'block32', innerBlocks: [] }, + ], + }, + ], + } ); + + expect( state.byClientId ).toEqual( { + block2: { clientId: 'block2' }, + block21: { clientId: 'block21' }, + block22: { clientId: 'block22' }, + block3: { clientId: 'block3' }, + block31: { clientId: 'block31' }, + block32: { clientId: 'block32' }, + } ); + } ); + + describe( 'byClientId', () => { + it( 'should ignore updates to non-existent block', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.byClientId ).toBe( original.byClientId ); + } ); + + it( 'should return with same reference if no changes in updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: { + updated: true, + }, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.byClientId ).toBe( state.byClientId ); + } ); + } ); + + describe( 'attributes', () => { + it( 'should return with attribute block updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.attributes.kumquat.updated ).toBe( true ); + } ); + + it( 'should accumulate attribute block updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: { + updated: true, + }, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + moreUpdated: true, + }, + } ); + + expect( state.attributes.kumquat ).toEqual( { + updated: true, + moreUpdated: true, + } ); + } ); + + it( 'should ignore updates to non-existent block', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.attributes ).toBe( original.attributes ); + } ); + + it( 'should return with same reference if no changes in updates', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: { + updated: true, + }, + innerBlocks: [], + } ], + } ) ); + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.attributes ).toBe( state.attributes ); + } ); + } ); + + describe( 'isPersistentChange', () => { + it( 'should consider any non-exempt block change as persistent', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [], + } ) ); + + const state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.isPersistentChange ).toBe( true ); + } ); + + it( 'should consider same block attribute update as exempt', () => { + const original = deepFreeze( blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + } ], + } ) ); + let state = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: false, + }, + } ); + + state = blocks( state, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientId: 'kumquat', + attributes: { + updated: true, + }, + } ); + + expect( state.isPersistentChange ).toBe( false ); + } ); + } ); + } ); + } ); + + describe( 'insertionPoint', () => { + it( 'should default to null', () => { + const state = insertionPoint( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'should set insertion point', () => { + const state = insertionPoint( null, { + type: 'SHOW_INSERTION_POINT', + rootClientId: 'clientId1', + index: 0, + } ); + + expect( state ).toEqual( { + rootClientId: 'clientId1', + index: 0, + } ); + } ); + + it( 'should clear the insertion point', () => { + const original = deepFreeze( { + rootClientId: 'clientId1', + index: 0, + } ); + const state = insertionPoint( original, { + type: 'HIDE_INSERTION_POINT', + } ); + + expect( state ).toBe( null ); + } ); + } ); + + describe( 'isTyping()', () => { + it( 'should set the typing flag to true', () => { + const state = isTyping( false, { + type: 'START_TYPING', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should set the typing flag to false', () => { + const state = isTyping( false, { + type: 'STOP_TYPING', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'isCaretWithinFormattedText()', () => { + it( 'should set the flag to true', () => { + const state = isCaretWithinFormattedText( false, { + type: 'ENTER_FORMATTED_TEXT', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should set the flag to false', () => { + const state = isCaretWithinFormattedText( true, { + type: 'EXIT_FORMATTED_TEXT', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'blockSelection()', () => { + it( 'should return with block clientId as selected', () => { + const state = blockSelection( undefined, { + type: 'SELECT_BLOCK', + clientId: 'kumquat', + initialPosition: -1, + } ); + + expect( state ).toEqual( { + start: 'kumquat', + end: 'kumquat', + initialPosition: -1, + isMultiSelecting: false, + isEnabled: true, + } ); + } ); + + it( 'should set multi selection', () => { + const original = deepFreeze( { isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should set continuous multi selection', () => { + const original = deepFreeze( { isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'MULTI_SELECT', + start: 'ribs', + end: 'chicken', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'chicken', + initialPosition: null, + isMultiSelecting: true, + } ); + } ); + + it( 'should start multi selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'START_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'ribs', + initialPosition: null, + isMultiSelecting: true, + } ); + } ); + + it( 'should return same reference if already multi-selecting', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'START_MULTI_SELECT', + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should end multi selection with selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken', isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should return same reference if already ended multi-selecting', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken', isMultiSelecting: false } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should end multi selection without selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs', isMultiSelecting: true } ); + const state = blockSelection( original, { + type: 'STOP_MULTI_SELECT', + } ); + + expect( state ).toEqual( { + start: 'ribs', + end: 'ribs', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not update the state if the block is already selected', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs' } ); + + const state1 = blockSelection( original, { + type: 'SELECT_BLOCK', + clientId: 'ribs', + } ); + + expect( state1 ).toBe( original ); + } ); + + it( 'should unset multi selection', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken' } ); + + const state1 = blockSelection( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).toEqual( { + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should return same reference if clearing selection but no selection', () => { + const original = deepFreeze( { start: null, end: null, isMultiSelecting: false } ); + + const state1 = blockSelection( original, { + type: 'CLEAR_SELECTED_BLOCK', + } ); + + expect( state1 ).toBe( original ); + } ); + + it( 'should select inserted block', () => { + const original = deepFreeze( { start: 'ribs', end: 'chicken' } ); + + const state3 = blockSelection( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + } ], + updateSelection: true, + } ); + + expect( state3 ).toEqual( { + start: 'ribs', + end: 'ribs', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not select inserted block if updateSelection flag is false', () => { + const original = deepFreeze( { start: 'a', end: 'b' } ); + + const state3 = blockSelection( original, { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'ribs', + name: 'core/freeform', + } ], + updateSelection: false, + } ); + + expect( state3 ).toEqual( { + start: 'a', + end: 'b', + } ); + } ); + + it( 'should not update the state if the block moved is already selected', () => { + const original = deepFreeze( { start: 'ribs', end: 'ribs' } ); + const state = blockSelection( original, { + type: 'MOVE_BLOCKS_UP', + clientIds: [ 'ribs' ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should replace the selected block', () => { + const original = deepFreeze( { start: 'chicken', end: 'chicken' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toEqual( { + start: 'wings', + end: 'wings', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not replace the selected block if we keep it at the end when replacing blocks', () => { + const original = deepFreeze( { start: 'wings', end: 'wings' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'wings' ], + blocks: [ + { + clientId: 'chicken', + name: 'core/freeform', + }, + { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should replace the selected block if we keep it not at the end when replacing blocks', () => { + const original = deepFreeze( { start: 'chicken', end: 'chicken' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [ + { + clientId: 'chicken', + name: 'core/freeform', + }, + { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toEqual( { + start: 'wings', + end: 'wings', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should reset if replacing with empty set', () => { + const original = deepFreeze( { start: 'chicken', end: 'chicken' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'chicken' ], + blocks: [], + } ); + + expect( state ).toEqual( { + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should keep the selected block', () => { + const original = deepFreeze( { start: 'chicken', end: 'chicken' } ); + const state = blockSelection( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'ribs' ], + blocks: [ { + clientId: 'wings', + name: 'core/freeform', + } ], + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should remove the selection if we are removing the selected block', () => { + const original = deepFreeze( { + start: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const state = blockSelection( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'chicken' ], + } ); + + expect( state ).toEqual( { + start: null, + end: null, + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should keep the selection if we are not removing the selected block', () => { + const original = deepFreeze( { + start: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const state = blockSelection( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'ribs' ], + } ); + + expect( state ).toBe( original ); + } ); + } ); + + describe( 'preferences()', () => { + it( 'should apply all defaults', () => { + const state = preferences( undefined, {} ); + + expect( state ).toEqual( { + insertUsage: {}, + } ); + } ); + it( 'should record recently used blocks', () => { + const state = preferences( deepFreeze( { insertUsage: {} } ), { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'bacon', + name: 'core-embed/twitter', + } ], + time: 123456, + } ); + + expect( state ).toEqual( { + insertUsage: { + 'core-embed/twitter': { + time: 123456, + count: 1, + insert: { name: 'core-embed/twitter' }, + }, + }, + } ); + + const twoRecentBlocks = preferences( deepFreeze( { + insertUsage: { + 'core-embed/twitter': { + time: 123456, + count: 1, + insert: { name: 'core-embed/twitter' }, + }, + }, + } ), { + type: 'INSERT_BLOCKS', + blocks: [ { + clientId: 'eggs', + name: 'core-embed/twitter', + }, { + clientId: 'bacon', + name: 'core/block', + attributes: { ref: 123 }, + } ], + time: 123457, + } ); + + expect( twoRecentBlocks ).toEqual( { + insertUsage: { + 'core-embed/twitter': { + time: 123457, + count: 2, + insert: { name: 'core-embed/twitter' }, + }, + 'core/block/123': { + time: 123457, + count: 1, + insert: { name: 'core/block', ref: 123 }, + }, + }, + } ); + } ); + } ); + + describe( 'blocksMode', () => { + it( 'should set mode to html if not set', () => { + const action = { + type: 'TOGGLE_BLOCK_MODE', + clientId: 'chicken', + }; + const value = blocksMode( deepFreeze( {} ), action ); + + expect( value ).toEqual( { chicken: 'html' } ); + } ); + + it( 'should toggle mode to visual if set as html', () => { + const action = { + type: 'TOGGLE_BLOCK_MODE', + clientId: 'chicken', + }; + const value = blocksMode( deepFreeze( { chicken: 'html' } ), action ); + + expect( value ).toEqual( { chicken: 'visual' } ); + } ); + } ); + + describe( 'template', () => { + it( 'should default to visible', () => { + const state = template( undefined, {} ); + + expect( state ).toEqual( { isValid: true } ); + } ); + + it( 'should reset the validity flag', () => { + const original = deepFreeze( { isValid: false, template: [] } ); + const state = template( original, { + type: 'SET_TEMPLATE_VALIDITY', + isValid: true, + } ); + + expect( state ).toEqual( { isValid: true, template: [] } ); + } ); + } ); + + describe( 'blockListSettings', () => { + it( 'should add new settings', () => { + const original = deepFreeze( {} ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + settings: { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + expect( state ).toEqual( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + } ); + + it( 'should return same reference if updated as the same', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + settings: { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should return same reference if updated settings not assigned and id not exists', () => { + const original = deepFreeze( {} ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + } ); + + expect( state ).toBe( original ); + } ); + + it( 'should update the settings of a block', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + settings: { + allowedBlocks: [ 'core/list' ], + }, + } ); + + expect( state ).toEqual( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/list' ], + }, + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + } ); + + it( 'should remove existing settings if updated settings not assigned', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + + const state = blockListSettings( original, { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189', + } ); + + expect( state ).toEqual( {} ); + } ); + + it( 'should remove the settings of a block when it is replaced', () => { + const original = deepFreeze( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + + const state = blockListSettings( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ], + } ); + + expect( state ).toEqual( { + '9db792c6-a25a-495d-adbd-97d56a4c4189': { + allowedBlocks: [ 'core/paragraph' ], + }, + } ); + } ); + + it( 'should remove the settings of a block when it is removed', () => { + const original = deepFreeze( { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + allowedBlocks: true, + }, + } ); + + const state = blockListSettings( original, { + type: 'REMOVE_BLOCKS', + clientIds: [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ], + } ); + + expect( state ).toEqual( {} ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js new file mode 100644 index 0000000000000..bcda13ad755ff --- /dev/null +++ b/packages/block-editor/src/store/test/selectors.js @@ -0,0 +1,2321 @@ +/** + * External dependencies + */ +import { filter } from 'lodash'; + +/** + * WordPress dependencies + */ +import { + registerBlockType, + unregisterBlockType, + setFreeformContentHandlerName, +} from '@wordpress/blocks'; +import { RawHTML } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as selectors from '../selectors'; + +const { + getBlockDependantsCacheBust, + getBlockName, + getBlock, + getBlocks, + getBlockCount, + getClientIdsWithDescendants, + getClientIdsOfDescendants, + hasSelectedBlock, + getSelectedBlock, + getSelectedBlockClientId, + getBlockRootClientId, + getBlockHierarchyRootClientId, + getGlobalBlockCount, + getMultiSelectedBlockClientIds, + getMultiSelectedBlocks, + getMultiSelectedBlocksStartClientId, + getMultiSelectedBlocksEndClientId, + getBlockOrder, + getBlockIndex, + getPreviousBlockClientId, + getNextBlockClientId, + isBlockSelected, + hasSelectedInnerBlock, + isBlockWithinSelection, + hasMultiSelection, + isBlockMultiSelected, + isFirstMultiSelectedBlock, + getBlockMode, + isTyping, + isCaretWithinFormattedText, + getBlockInsertionPoint, + isBlockInsertionPointVisible, + isSelectionEnabled, + canInsertBlockType, + getInserterItems, + isValidTemplate, + getTemplate, + getTemplateLock, + getBlockListSettings, + INSERTER_UTILITY_HIGH, + INSERTER_UTILITY_MEDIUM, + INSERTER_UTILITY_LOW, +} = selectors; + +describe( 'selectors', () => { + let cachedSelectors; + + beforeAll( () => { + cachedSelectors = filter( selectors, ( selector ) => selector.clear ); + } ); + + beforeEach( () => { + registerBlockType( 'core/block', { + save: () => null, + category: 'reusable', + title: 'Reusable Block Stub', + supports: { + inserter: false, + }, + } ); + + registerBlockType( 'core/test-block-a', { + save: ( props ) => props.attributes.text, + category: 'formatting', + title: 'Test Block A', + icon: 'test', + keywords: [ 'testing' ], + } ); + + registerBlockType( 'core/test-block-b', { + save: ( props ) => props.attributes.text, + category: 'common', + title: 'Test Block B', + icon: 'test', + keywords: [ 'testing' ], + supports: { + multiple: false, + }, + } ); + + registerBlockType( 'core/test-block-c', { + save: ( props ) => props.attributes.text, + category: 'common', + title: 'Test Block C', + icon: 'test', + keywords: [ 'testing' ], + parent: [ 'core/test-block-b' ], + } ); + + registerBlockType( 'core/test-freeform', { + save: ( props ) => { props.attributes.content }, + category: 'common', + title: 'Test Freeform Content Handler', + icon: 'test', + attributes: { + content: { + type: 'string', + }, + }, + } ); + + setFreeformContentHandlerName( 'core/test-freeform' ); + + cachedSelectors.forEach( ( { clear } ) => clear() ); + } ); + + afterEach( () => { + unregisterBlockType( 'core/block' ); + unregisterBlockType( 'core/test-block-a' ); + unregisterBlockType( 'core/test-block-b' ); + unregisterBlockType( 'core/test-block-c' ); + unregisterBlockType( 'core/test-freeform' ); + + setFreeformContentHandlerName( undefined ); + } ); + + describe( 'getBlockDependantsCacheBust', () => { + const rootBlock = { clientId: 123, name: 'core/paragraph' }; + const rootBlockAttributes = {}; + const rootOrder = [ 123 ]; + + it( 'returns an unchanging reference', () => { + const rootBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + }, + attributes: { + 123: rootBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + }, + attributes: { + 123: rootBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns a new reference on added inner block', () => { + const state = { + blocks: { + byClientId: { + 123: rootBlock, + }, + attributes: { + 123: rootBlockAttributes, + }, + order: { + '': rootOrder, + 123: [], + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: {}, + }, + order: { + '': rootOrder, + 123: [ 456 ], + 456: [], + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).not.toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns an unchanging reference on unchanging inner block', () => { + const rootBlockOrder = [ 456 ]; + const childBlock = { clientId: 456, name: 'core/paragraph' }; + const childBlockAttributes = {}; + const childBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns a new reference on updated inner block', () => { + const rootBlockOrder = [ 456 ]; + const childBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: {}, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: { content: [ 'foo' ] }, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).not.toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + + it( 'returns a new reference on updated grandchild inner block', () => { + const rootBlockOrder = [ 456 ]; + const childBlock = { clientId: 456, name: 'core/paragraph' }; + const childBlockAttributes = {}; + const childBlockOrder = [ 789 ]; + const grandChildBlockOrder = []; + + const state = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + 789: {}, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + 789: grandChildBlockOrder, + }, + }, + }; + + const nextState = { + blocks: { + byClientId: { + 123: rootBlock, + 456: childBlock, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: rootBlockAttributes, + 456: childBlockAttributes, + 789: { content: [ 'foo' ] }, + }, + order: { + '': rootOrder, + 123: rootBlockOrder, + 456: childBlockOrder, + 789: grandChildBlockOrder, + }, + }, + }; + + expect( + getBlockDependantsCacheBust( state, 123 ) + ).not.toBe( getBlockDependantsCacheBust( nextState, 123 ) ); + } ); + } ); + + describe( 'getBlockName', () => { + it( 'returns null if no block by clientId', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + }, + }; + + const name = getBlockName( state, 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ); + + expect( name ).toBe( null ); + } ); + + it( 'returns block name', () => { + const state = { + blocks: { + byClientId: { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': { + clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + name: 'core/paragraph', + }, + }, + attributes: { + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': {}, + }, + order: { + '': [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ], + 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1': [], + }, + }, + }; + + const name = getBlockName( state, 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ); + + expect( name ).toBe( 'core/paragraph' ); + } ); + } ); + + describe( 'getBlock', () => { + it( 'should return the block', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + }, + order: { + '': [ 123 ], + 123: [], + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + clientId: 123, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + } ); + } ); + + it( 'should return null if the block is not present in state', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + }, + }; + + expect( getBlock( state, 123 ) ).toBe( null ); + } ); + + it( 'should include inner blocks', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/paragraph' }, + 456: { clientId: 456, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + 456: {}, + }, + order: { + '': [ 123 ], + 123: [ 456 ], + 456: [], + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + clientId: 123, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [ { + clientId: 456, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + } ], + } ); + } ); + + it( 'should merge meta attributes for the block', () => { + registerBlockType( 'core/meta-block', { + save: ( props ) => props.attributes.text, + category: 'common', + title: 'test block', + attributes: { + foo: { + type: 'string', + source: 'meta', + meta: 'foo', + }, + }, + } ); + + const state = { + settings: { + __experimentalMetaSource: { + value: { + foo: 'bar', + }, + }, + }, + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/meta-block' }, + }, + attributes: { + 123: {}, + }, + order: { + '': [ 123 ], + 123: [], + }, + }, + }; + + expect( getBlock( state, 123 ) ).toEqual( { + clientId: 123, + name: 'core/meta-block', + attributes: { + foo: 'bar', + }, + innerBlocks: [], + } ); + + unregisterBlockType( 'core/meta-block' ); + } ); + } ); + + describe( 'getBlocks', () => { + it( 'should return the ordered blocks', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getBlocks( state ) ).toEqual( [ + { clientId: 123, name: 'core/paragraph', attributes: {}, innerBlocks: [] }, + { clientId: 23, name: 'core/heading', attributes: {}, innerBlocks: [] }, + ] ); + } ); + } ); + + describe( 'getClientIdsOfDescendants', () => { + it( 'should return the ids of any descendants, given an array of clientIds', () => { + const state = { + blocks: { + byClientId: { + 'uuid-2': { clientId: 'uuid-2', name: 'core/image' }, + 'uuid-4': { clientId: 'uuid-4', name: 'core/paragraph' }, + 'uuid-6': { clientId: 'uuid-6', name: 'core/paragraph' }, + 'uuid-8': { clientId: 'uuid-8', name: 'core/block' }, + 'uuid-10': { clientId: 'uuid-10', name: 'core/columns' }, + 'uuid-12': { clientId: 'uuid-12', name: 'core/column' }, + 'uuid-14': { clientId: 'uuid-14', name: 'core/column' }, + 'uuid-16': { clientId: 'uuid-16', name: 'core/quote' }, + 'uuid-18': { clientId: 'uuid-18', name: 'core/block' }, + 'uuid-20': { clientId: 'uuid-20', name: 'core/gallery' }, + 'uuid-22': { clientId: 'uuid-22', name: 'core/block' }, + 'uuid-24': { clientId: 'uuid-24', name: 'core/columns' }, + 'uuid-26': { clientId: 'uuid-26', name: 'core/column' }, + 'uuid-28': { clientId: 'uuid-28', name: 'core/column' }, + 'uuid-30': { clientId: 'uuid-30', name: 'core/paragraph' }, + }, + attributes: { + 'uuid-2': {}, + 'uuid-4': {}, + 'uuid-6': {}, + 'uuid-8': {}, + 'uuid-10': {}, + 'uuid-12': {}, + 'uuid-14': {}, + 'uuid-16': {}, + 'uuid-18': {}, + 'uuid-20': {}, + 'uuid-22': {}, + 'uuid-24': {}, + 'uuid-26': {}, + 'uuid-28': {}, + 'uuid-30': {}, + }, + order: { + '': [ 'uuid-6', 'uuid-8', 'uuid-10', 'uuid-22' ], + 'uuid-2': [ ], + 'uuid-4': [ ], + 'uuid-6': [ ], + 'uuid-8': [ ], + 'uuid-10': [ 'uuid-12', 'uuid-14' ], + 'uuid-12': [ 'uuid-16' ], + 'uuid-14': [ 'uuid-18' ], + 'uuid-16': [ ], + 'uuid-18': [ 'uuid-24' ], + 'uuid-20': [ ], + 'uuid-22': [ ], + 'uuid-24': [ 'uuid-26', 'uuid-28' ], + 'uuid-26': [ ], + 'uuid-28': [ 'uuid-30' ], + }, + }, + }; + expect( getClientIdsOfDescendants( state, [ 'uuid-10' ] ) ).toEqual( [ + 'uuid-12', + 'uuid-14', + 'uuid-16', + 'uuid-18', + 'uuid-24', + 'uuid-26', + 'uuid-28', + 'uuid-30', + ] ); + } ); + } ); + + describe( 'getClientIdsWithDescendants', () => { + it( 'should return the ids for top-level blocks and their descendants of any depth (for nested blocks).', () => { + const state = { + blocks: { + byClientId: { + 'uuid-2': { clientId: 'uuid-2', name: 'core/image' }, + 'uuid-4': { clientId: 'uuid-4', name: 'core/paragraph' }, + 'uuid-6': { clientId: 'uuid-6', name: 'core/paragraph' }, + 'uuid-8': { clientId: 'uuid-8', name: 'core/block' }, + 'uuid-10': { clientId: 'uuid-10', name: 'core/columns' }, + 'uuid-12': { clientId: 'uuid-12', name: 'core/column' }, + 'uuid-14': { clientId: 'uuid-14', name: 'core/column' }, + 'uuid-16': { clientId: 'uuid-16', name: 'core/quote' }, + 'uuid-18': { clientId: 'uuid-18', name: 'core/block' }, + 'uuid-20': { clientId: 'uuid-20', name: 'core/gallery' }, + 'uuid-22': { clientId: 'uuid-22', name: 'core/block' }, + 'uuid-24': { clientId: 'uuid-24', name: 'core/columns' }, + 'uuid-26': { clientId: 'uuid-26', name: 'core/column' }, + 'uuid-28': { clientId: 'uuid-28', name: 'core/column' }, + 'uuid-30': { clientId: 'uuid-30', name: 'core/paragraph' }, + }, + attributes: { + 'uuid-2': {}, + 'uuid-4': {}, + 'uuid-6': {}, + 'uuid-8': {}, + 'uuid-10': {}, + 'uuid-12': {}, + 'uuid-14': {}, + 'uuid-16': {}, + 'uuid-18': {}, + 'uuid-20': {}, + 'uuid-22': {}, + 'uuid-24': {}, + 'uuid-26': {}, + 'uuid-28': {}, + 'uuid-30': {}, + }, + order: { + '': [ 'uuid-6', 'uuid-8', 'uuid-10', 'uuid-22' ], + 'uuid-2': [ ], + 'uuid-4': [ ], + 'uuid-6': [ ], + 'uuid-8': [ ], + 'uuid-10': [ 'uuid-12', 'uuid-14' ], + 'uuid-12': [ 'uuid-16' ], + 'uuid-14': [ 'uuid-18' ], + 'uuid-16': [ ], + 'uuid-18': [ 'uuid-24' ], + 'uuid-20': [ ], + 'uuid-22': [ ], + 'uuid-24': [ 'uuid-26', 'uuid-28' ], + 'uuid-26': [ ], + 'uuid-28': [ 'uuid-30' ], + }, + }, + }; + expect( getClientIdsWithDescendants( state ) ).toEqual( [ + 'uuid-6', + 'uuid-8', + 'uuid-10', + 'uuid-22', + 'uuid-12', + 'uuid-14', + 'uuid-16', + 'uuid-18', + 'uuid-24', + 'uuid-26', + 'uuid-28', + 'uuid-30', + ] ); + } ); + } ); + + describe( 'getBlockCount', () => { + it( 'should return the number of top-level blocks in the post', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getBlockCount( state ) ).toBe( 2 ); + } ); + + it( 'should return the number of blocks in a nested context', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/columns' }, + 456: { clientId: 456, name: 'core/paragraph' }, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + 456: {}, + 789: {}, + }, + order: { + '': [ 123 ], + 123: [ 456, 789 ], + }, + }, + }; + + expect( getBlockCount( state, '123' ) ).toBe( 2 ); + } ); + } ); + + describe( 'hasSelectedBlock', () => { + it( 'should return false if no selection', () => { + const state = { + blockSelection: { + start: null, + end: null, + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( false ); + } ); + + it( 'should return false if multi-selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( false ); + } ); + + it( 'should return true if singular selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + }, + }; + + expect( hasSelectedBlock( state ) ).toBe( true ); + } ); + } ); + + describe( 'getGlobalBlockCount', () => { + const state = { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/heading' }, + 456: { clientId: 456, name: 'core/paragraph' }, + 789: { clientId: 789, name: 'core/paragraph' }, + }, + attributes: { + 123: {}, + 456: {}, + 789: {}, + }, + order: { + '': [ 123, 456 ], + }, + }, + }; + + it( 'should return the global number of blocks in the post', () => { + expect( getGlobalBlockCount( state ) ).toBe( 2 ); + } ); + + it( 'should return the global number of blocks in the post of a given type', () => { + expect( getGlobalBlockCount( state, 'core/paragraph' ) ).toBe( 1 ); + } ); + + it( 'should return 0 if no blocks exist', () => { + const emptyState = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + }, + }; + expect( getGlobalBlockCount( emptyState ) ).toBe( 0 ); + expect( getGlobalBlockCount( emptyState, 'core/heading' ) ).toBe( 0 ); + } ); + } ); + + describe( 'getSelectedBlockClientId', () => { + it( 'should return null if no block is selected', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + expect( getSelectedBlockClientId( state ) ).toBe( null ); + } ); + + it( 'should return null if there is multi selection', () => { + const state = { + blockSelection: { start: 23, end: 123 }, + }; + + expect( getSelectedBlockClientId( state ) ).toBe( null ); + } ); + + it( 'should return the selected block ClientId', () => { + const state = { + blocks: { + byClientId: { + 23: { + name: 'fake block', + }, + }, + }, + blockSelection: { start: 23, end: 23 }, + }; + + expect( getSelectedBlockClientId( state ) ).toEqual( 23 ); + } ); + } ); + + describe( 'getSelectedBlock', () => { + it( 'should return null if no block is selected', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 23, 123 ], + 23: [], + 123: [], + }, + }, + blockSelection: { start: null, end: null }, + }; + + expect( getSelectedBlock( state ) ).toBe( null ); + } ); + + it( 'should return null if there is multi selection', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 23, 123 ], + 23: [], + 123: [], + }, + }, + blockSelection: { start: 23, end: 123 }, + }; + + expect( getSelectedBlock( state ) ).toBe( null ); + } ); + + it( 'should return the selected block', () => { + const state = { + blocks: { + byClientId: { + 23: { clientId: 23, name: 'core/heading' }, + 123: { clientId: 123, name: 'core/paragraph' }, + }, + attributes: { + 23: {}, + 123: {}, + }, + order: { + '': [ 23, 123 ], + 23: [], + 123: [], + }, + }, + blockSelection: { start: 23, end: 23 }, + }; + + expect( getSelectedBlock( state ) ).toEqual( { + clientId: 23, + name: 'core/heading', + attributes: {}, + innerBlocks: [], + } ); + } ); + } ); + + describe( 'getBlockRootClientId', () => { + it( 'should return null if the block does not exist', () => { + const state = { + blocks: { + order: {}, + }, + }; + + expect( getBlockRootClientId( state, 56 ) ).toBeNull(); + } ); + + it( 'should return root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getBlockRootClientId( state, 56 ) ).toBe( '123' ); + } ); + } ); + + describe( 'getBlockHierarchyRootClientId', () => { + it( 'should return the given block if the block has no parents', () => { + const state = { + blocks: { + order: {}, + }, + }; + + expect( getBlockHierarchyRootClientId( state, 56 ) ).toBe( 56 ); + } ); + + it( 'should return root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getBlockHierarchyRootClientId( state, 56 ) ).toBe( '123' ); + } ); + + it( 'should return the top level root ClientId relative the block ClientId', () => { + const state = { + blocks: { + order: { + '': [ '123', '23' ], + 123: [ '456', '56' ], + 56: [ '12' ], + }, + }, + }; + + expect( getBlockHierarchyRootClientId( state, '12' ) ).toBe( '123' ); + } ); + } ); + + describe( 'getMultiSelectedBlockClientIds', () => { + it( 'should return empty if there is no multi selection', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlockClientIds( state ) ).toEqual( [] ); + } ); + + it( 'should return selected block clientIds if there is multi selection', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + expect( getMultiSelectedBlockClientIds( state ) ).toEqual( [ 4, 3, 2 ] ); + } ); + + it( 'should return selected block clientIds if there is multi selection (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + 4: [ 9, 8, 7, 6 ], + }, + }, + blockSelection: { start: 7, end: 9 }, + }; + + expect( getMultiSelectedBlockClientIds( state ) ).toEqual( [ 9, 8, 7 ] ); + } ); + } ); + + describe( 'getMultiSelectedBlocks', () => { + it( 'should return the same reference on subsequent invocations of empty selection', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + }, + blockSelection: { start: null, end: null }, + }; + + expect( + getMultiSelectedBlocks( state ) + ).toBe( getMultiSelectedBlocks( state ) ); + } ); + } ); + + describe( 'getMultiSelectedBlocksStartClientId', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlocksStartClientId( state ) ).toBeNull(); + } ); + + it( 'returns multi selection start', () => { + const state = { + blockSelection: { start: 2, end: 4 }, + }; + + expect( getMultiSelectedBlocksStartClientId( state ) ).toBe( 2 ); + } ); + } ); + + describe( 'getMultiSelectedBlocksEndClientId', () => { + it( 'returns null if there is no multi selection', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + expect( getMultiSelectedBlocksEndClientId( state ) ).toBeNull(); + } ); + + it( 'returns multi selection end', () => { + const state = { + blockSelection: { start: 2, end: 4 }, + }; + + expect( getMultiSelectedBlocksEndClientId( state ) ).toBe( 4 ); + } ); + } ); + + describe( 'getBlockOrder', () => { + it( 'should return the ordered block ClientIds of top-level blocks by default', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getBlockOrder( state ) ).toEqual( [ 123, 23 ] ); + } ); + + it( 'should return the ordered block ClientIds at a specified rootClientId', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456 ], + }, + }, + }; + + expect( getBlockOrder( state, '123' ) ).toEqual( [ 456 ] ); + } ); + } ); + + describe( 'getBlockIndex', () => { + it( 'should return the block order', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getBlockIndex( state, 23 ) ).toBe( 1 ); + } ); + + it( 'should return the block order (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getBlockIndex( state, 56, '123' ) ).toBe( 1 ); + } ); + } ); + + describe( 'getPreviousBlockClientId', () => { + it( 'should return the previous block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 23 ) ).toEqual( 123 ); + } ); + + it( 'should return the previous block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 56, '123' ) ).toEqual( 456 ); + } ); + + it( 'should return null for the first block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 123 ) ).toBeNull(); + } ); + + it( 'should return null for the first block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getPreviousBlockClientId( state, 456, '123' ) ).toBeNull(); + } ); + } ); + + describe( 'getNextBlockClientId', () => { + it( 'should return the following block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 123 ) ).toEqual( 23 ); + } ); + + it( 'should return the following block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 456, '123' ) ).toEqual( 56 ); + } ); + + it( 'should return null for the last block', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 23 ) ).toBeNull(); + } ); + + it( 'should return null for the last block (nested context)', () => { + const state = { + blocks: { + order: { + '': [ 123, 23 ], + 123: [ 456, 56 ], + }, + }, + }; + + expect( getNextBlockClientId( state, 56, '123' ) ).toBeNull(); + } ); + } ); + + describe( 'isBlockSelected', () => { + it( 'should return true if the block is selected', () => { + const state = { + blockSelection: { start: 123, end: 123 }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( true ); + } ); + + it( 'should return false if a multi-selection range exists', () => { + const state = { + blockSelection: { start: 123, end: 124 }, + }; + + expect( isBlockSelected( state, 123 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: null, end: null }, + }; + + expect( isBlockSelected( state, 23 ) ).toBe( false ); + } ); + } ); + + describe( 'hasSelectedInnerBlock', () => { + it( 'should return false if the selected block is a child of the given ClientId', () => { + const state = { + blockSelection: { start: 5, end: 5 }, + blocks: { + order: { + 4: [ 3, 2, 1 ], + }, + }, + }; + + expect( hasSelectedInnerBlock( state, 4 ) ).toBe( false ); + } ); + + it( 'should return true if the selected block is a child of the given ClientId', () => { + const state = { + blockSelection: { start: 3, end: 3 }, + blocks: { + order: { + 4: [ 3, 2, 1 ], + }, + }, + }; + + expect( hasSelectedInnerBlock( state, 4 ) ).toBe( true ); + } ); + + it( 'should return true if a multi selection exists that contains children of the block with the given ClientId', () => { + const state = { + blocks: { + order: { + 6: [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + expect( hasSelectedInnerBlock( state, 6 ) ).toBe( true ); + } ); + + it( 'should return false if a multi selection exists bot does not contains children of the block with the given ClientId', () => { + const state = { + blocks: { + order: { + 3: [ 2, 1 ], + 6: [ 5, 4 ], + }, + }, + blockSelection: { start: 5, end: 4 }, + }; + expect( hasSelectedInnerBlock( state, 3 ) ).toBe( false ); + } ); + } ); + + describe( 'isBlockWithinSelection', () => { + it( 'should return true if the block is selected but not the last', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( true ); + } ); + + it( 'should return false if the block is the last selected', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 3 ) ).toBe( false ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + blockSelection: { start: 5, end: 3 }, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 2 ) ).toBe( false ); + } ); + + it( 'should return false if there is no selection', () => { + const state = { + blockSelection: {}, + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + }; + + expect( isBlockWithinSelection( state, 4 ) ).toBe( false ); + } ); + } ); + + describe( 'hasMultiSelection', () => { + it( 'should return false if no selection', () => { + const state = { + blockSelection: { + start: null, + end: null, + }, + }; + + expect( hasMultiSelection( state ) ).toBe( false ); + } ); + + it( 'should return false if singular selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + }, + }; + + expect( hasMultiSelection( state ) ).toBe( false ); + } ); + + it( 'should return true if multi-selection', () => { + const state = { + blockSelection: { + start: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1', + end: '9db792c6-a25a-495d-adbd-97d56a4c4189', + }, + }; + + expect( hasMultiSelection( state ) ).toBe( true ); + } ); + } ); + + describe( 'isBlockMultiSelected', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + it( 'should return true if the block is multi selected', () => { + expect( isBlockMultiSelected( state, 3 ) ).toBe( true ); + } ); + + it( 'should return false if the block is not multi selected', () => { + expect( isBlockMultiSelected( state, 5 ) ).toBe( false ); + } ); + } ); + + describe( 'isFirstMultiSelectedBlock', () => { + const state = { + blocks: { + order: { + '': [ 5, 4, 3, 2, 1 ], + }, + }, + blockSelection: { start: 2, end: 4 }, + }; + + it( 'should return true if the block is first in multi selection', () => { + expect( isFirstMultiSelectedBlock( state, 4 ) ).toBe( true ); + } ); + + it( 'should return false if the block is not first in multi selection', () => { + expect( isFirstMultiSelectedBlock( state, 3 ) ).toBe( false ); + } ); + } ); + + describe( 'getBlockMode', () => { + it( 'should return "visual" if unset', () => { + const state = { + blocksMode: {}, + }; + + expect( getBlockMode( state, 123 ) ).toEqual( 'visual' ); + } ); + + it( 'should return the block mode', () => { + const state = { + blocksMode: { + 123: 'html', + }, + }; + + expect( getBlockMode( state, 123 ) ).toEqual( 'html' ); + } ); + } ); + + describe( 'isTyping', () => { + it( 'should return the isTyping flag if the block is selected', () => { + const state = { + isTyping: true, + }; + + expect( isTyping( state ) ).toBe( true ); + } ); + + it( 'should return false if the block is not selected', () => { + const state = { + isTyping: false, + }; + + expect( isTyping( state ) ).toBe( false ); + } ); + } ); + + describe( 'isCaretWithinFormattedText', () => { + it( 'returns true if the isCaretWithinFormattedText state is also true', () => { + const state = { + isCaretWithinFormattedText: true, + }; + + expect( isCaretWithinFormattedText( state ) ).toBe( true ); + } ); + + it( 'returns false if the isCaretWithinFormattedText state is also false', () => { + const state = { + isCaretWithinFormattedText: false, + }; + + expect( isCaretWithinFormattedText( state ) ).toBe( false ); + } ); + } ); + + describe( 'isSelectionEnabled', () => { + it( 'should return true if selection is enable', () => { + const state = { + blockSelection: { + isEnabled: true, + }, + }; + + expect( isSelectionEnabled( state ) ).toBe( true ); + } ); + + it( 'should return false if selection is disabled', () => { + const state = { + blockSelection: { + isEnabled: false, + }, + }; + + expect( isSelectionEnabled( state ) ).toBe( false ); + } ); + } ); + + describe( 'getBlockInsertionPoint', () => { + it( 'should return the explicitly assigned insertion point', () => { + const state = { + blockSelection: { + start: 'clientId2', + end: 'clientId2', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [ 'clientId2' ], + clientId2: [], + }, + }, + insertionPoint: { + rootClientId: undefined, + index: 0, + }, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 0, + } ); + } ); + + it( 'should return an object for the selected block', () => { + const state = { + blockSelection: { + start: 'clientId1', + end: 'clientId1', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + }, + attributes: { + clientId1: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [], + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 1, + } ); + } ); + + it( 'should return an object for the nested selected block', () => { + const state = { + blockSelection: { + start: 'clientId2', + end: 'clientId2', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1' ], + clientId1: [ 'clientId2' ], + clientId2: [], + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: 'clientId1', + index: 1, + } ); + } ); + + it( 'should return an object for the last multi selected clientId', () => { + const state = { + blockSelection: { + start: 'clientId1', + end: 'clientId2', + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1', 'clientId2' ], + clientId1: [], + clientId2: [], + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 2, + } ); + } ); + + it( 'should return an object for the last block if no selection', () => { + const state = { + blockSelection: { + start: null, + end: null, + }, + blocks: { + byClientId: { + clientId1: { clientId: 'clientId1' }, + clientId2: { clientId: 'clientId2' }, + }, + attributes: { + clientId1: {}, + clientId2: {}, + }, + order: { + '': [ 'clientId1', 'clientId2' ], + clientId1: [], + clientId2: [], + }, + }, + insertionPoint: null, + }; + + expect( getBlockInsertionPoint( state ) ).toEqual( { + rootClientId: undefined, + index: 2, + } ); + } ); + } ); + + describe( 'isBlockInsertionPointVisible', () => { + it( 'should return false if no assigned insertion point', () => { + const state = { + insertionPoint: null, + }; + + expect( isBlockInsertionPointVisible( state ) ).toBe( false ); + } ); + + it( 'should return true if assigned insertion point', () => { + const state = { + insertionPoint: { + rootClientId: undefined, + index: 5, + }, + }; + + expect( isBlockInsertionPointVisible( state ) ).toBe( true ); + } ); + } ); + + describe( 'canInsertBlockType', () => { + it( 'should deny blocks that are not registered', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/invalid' ) ).toBe( false ); + } ); + + it( 'should deny blocks that are not allowed by the editor', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: { + allowedBlockTypes: [], + }, + }; + expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( false ); + } ); + + it( 'should allow blocks that are allowed by the editor', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: { + allowedBlockTypes: [ 'core/test-block-a' ], + }, + }; + expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( true ); + } ); + + it( 'should deny blocks when the editor has a template lock', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: { + templateLock: 'all', + }, + }; + expect( canInsertBlockType( state, 'core/test-block-a' ) ).toBe( false ); + } ); + + it( 'should deny blocks that restrict parent from being inserted into the root', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c' ) ).toBe( false ); + } ); + + it( 'should deny blocks that restrict parent from being inserted into a restricted parent', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) ).toBe( false ); + } ); + + it( 'should allow blocks that restrict parent to be inserted into an allowed parent', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-b' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: {}, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) ).toBe( true ); + } ); + + it( 'should deny restricted blocks from being inserted into a block that restricts allowedBlocks', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: { + block1: { + allowedBlocks: [ 'core/test-block-c' ], + }, + }, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-b', 'block1' ) ).toBe( false ); + } ); + + it( 'should allow allowed blocks to be inserted into a block that restricts allowedBlocks', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: { + block1: { + allowedBlocks: [ 'core/test-block-b' ], + }, + }, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-b', 'block1' ) ).toBe( true ); + } ); + + it( 'should prioritise parent over allowedBlocks', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-b' }, + }, + attributes: { + block1: {}, + }, + }, + blockListSettings: { + block1: { + allowedBlocks: [], + }, + }, + settings: {}, + }; + expect( canInsertBlockType( state, 'core/test-block-c', 'block1' ) ).toBe( true ); + } ); + } ); + + describe( 'getInserterItems', () => { + it( 'should properly list block type and reusable block items', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + }, + order: {}, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'block1', title: 'Reusable Block 1' }, + ], + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + }; + const items = getInserterItems( state ); + const testBlockAItem = items.find( ( item ) => item.id === 'core/test-block-a' ); + expect( testBlockAItem ).toEqual( { + id: 'core/test-block-a', + name: 'core/test-block-a', + initialAttributes: {}, + title: 'Test Block A', + icon: { + src: 'test', + }, + category: 'formatting', + keywords: [ 'testing' ], + isDisabled: false, + utility: 0, + frecency: 0, + hasChildBlocksWithInserterSupport: false, + } ); + const reusableBlockItem = items.find( ( item ) => item.id === 'core/block/1' ); + expect( reusableBlockItem ).toEqual( { + id: 'core/block/1', + name: 'core/block', + initialAttributes: { ref: 1 }, + title: 'Reusable Block 1', + icon: { + src: 'test', + }, + category: 'reusable', + keywords: [], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + } ); + + it( 'should not list a reusable block item if it is being inserted inside it self', () => { + const state = { + blocks: { + byClientId: { + block1ref: { + name: 'core/block', + clientId: 'block1ref', + }, + itselfBlock1: { name: 'core/test-block-a' }, + itselfBlock2: { name: 'core/test-block-b' }, + }, + attributes: { + block1ref: { + attributes: { + ref: 1, + }, + }, + itselfBlock1: {}, + itselfBlock2: {}, + }, + order: { + '': [ 'block1ref' ], + }, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'itselfBlock1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'itselfBlock2', title: 'Reusable Block 2' }, + ], + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + }; + const items = getInserterItems( state, 'itselfBlock1' ); + const reusableBlockItems = filter( items, [ 'name', 'core/block' ] ); + expect( reusableBlockItems ).toHaveLength( 1 ); + expect( reusableBlockItems[ 0 ] ).toEqual( { + id: 'core/block/2', + name: 'core/block', + initialAttributes: { ref: 2 }, + title: 'Reusable Block 2', + icon: { + src: 'test', + }, + category: 'reusable', + keywords: [], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + } ); + + it( 'should not list a reusable block item if it is being inserted inside a descendent', () => { + const state = { + blocks: { + byClientId: { + block2ref: { + name: 'core/block', + clientId: 'block1ref', + }, + referredBlock1: { name: 'core/test-block-a' }, + referredBlock2: { name: 'core/test-block-b' }, + childReferredBlock2: { name: 'core/test-block-a' }, + grandchildReferredBlock2: { name: 'core/test-block-b' }, + }, + attributes: { + block2ref: { + attributes: { + ref: 2, + }, + }, + referredBlock1: {}, + referredBlock2: {}, + childReferredBlock2: {}, + grandchildReferredBlock2: {}, + }, + order: { + '': [ 'block2ref' ], + referredBlock2: [ 'childReferredBlock2' ], + childReferredBlock2: [ 'grandchildReferredBlock2' ], + }, + }, + + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'referredBlock1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'referredBlock2', title: 'Reusable Block 2' }, + ], + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + }; + const items = getInserterItems( state, 'grandchildReferredBlock2' ); + const reusableBlockItems = filter( items, [ 'name', 'core/block' ] ); + expect( reusableBlockItems ).toHaveLength( 1 ); + expect( reusableBlockItems[ 0 ] ).toEqual( { + id: 'core/block/1', + name: 'core/block', + initialAttributes: { ref: 1 }, + title: 'Reusable Block 1', + icon: { + src: 'test', + }, + category: 'reusable', + keywords: [], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + } ); + it( 'should order items by descending utility and frecency', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + block2: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + block2: {}, + }, + order: {}, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'block1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'block2', title: 'Reusable Block 2' }, + ], + }, + preferences: { + insertUsage: { + 'core/block/1': { count: 10, time: 1000 }, + 'core/block/2': { count: 20, time: 1000 }, + }, + }, + blockListSettings: {}, + }; + const itemIDs = getInserterItems( state ).map( ( item ) => item.id ); + expect( itemIDs ).toEqual( [ + 'core/block/2', + 'core/block/1', + 'core/test-block-b', + 'core/test-freeform', + 'core/test-block-a', + ] ); + } ); + + it( 'should correctly cache the return values', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-a' }, + block2: { name: 'core/test-block-a' }, + block3: { name: 'core/test-block-a' }, + block4: { name: 'core/test-block-a' }, + }, + attributes: { + block1: {}, + block2: {}, + block3: {}, + block4: {}, + }, + order: { + '': [ 'block3', 'block4' ], + }, + }, + settings: { + __experimentalReusableBlocks: [ + { id: 1, isTemporary: false, clientId: 'block1', title: 'Reusable Block 1' }, + { id: 2, isTemporary: false, clientId: 'block2', title: 'Reusable Block 2' }, + ], + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + }; + + const stateSecondBlockRestricted = { + ...state, + blockListSettings: { + block4: { + allowedBlocks: [ 'core/test-block-b' ], + }, + }, + }; + + const firstBlockFirstCall = getInserterItems( state, 'block3' ); + const firstBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block3' ); + expect( firstBlockFirstCall ).toBe( firstBlockSecondCall ); + expect( firstBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ + 'core/test-block-b', + 'core/test-freeform', + 'core/test-block-a', + 'core/block/1', + 'core/block/2', + ] ); + + const secondBlockFirstCall = getInserterItems( state, 'block4' ); + const secondBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block4' ); + expect( secondBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ + 'core/test-block-b', + 'core/test-freeform', + 'core/test-block-a', + 'core/block/1', + 'core/block/2', + ] ); + expect( secondBlockSecondCall.map( ( item ) => item.id ) ).toEqual( [ + 'core/test-block-b', + ] ); + } ); + + it( 'should set isDisabled when a block with `multiple: false` has been used', () => { + const state = { + blocks: { + byClientId: { + block1: { clientId: 'block1', name: 'core/test-block-b' }, + }, + attributes: { + block1: { attribute: {} }, + }, + order: { + '': [ 'block1' ], + }, + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state ); + const testBlockBItem = items.find( ( item ) => item.id === 'core/test-block-b' ); + expect( testBlockBItem.isDisabled ).toBe( true ); + } ); + + it( 'should give common blocks a low utility', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state ); + const testBlockBItem = items.find( ( item ) => item.id === 'core/test-block-b' ); + expect( testBlockBItem.utility ).toBe( INSERTER_UTILITY_LOW ); + } ); + + it( 'should give used blocks a medium utility and set a frecency', () => { + const state = { + blocks: { + byClientId: {}, + attributes: {}, + order: {}, + }, + preferences: { + insertUsage: { + 'core/test-block-b': { count: 10, time: 1000 }, + }, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state ); + const reusableBlock2Item = items.find( ( item ) => item.id === 'core/test-block-b' ); + expect( reusableBlock2Item.utility ).toBe( INSERTER_UTILITY_MEDIUM ); + expect( reusableBlock2Item.frecency ).toBe( 2.5 ); + } ); + + it( 'should give contextual blocks a high utility', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-b' }, + }, + attributes: { + block1: { attribute: {} }, + }, + order: { + '': [ 'block1' ], + }, + }, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state, 'block1' ); + const testBlockCItem = items.find( ( item ) => item.id === 'core/test-block-c' ); + expect( testBlockCItem.utility ).toBe( INSERTER_UTILITY_HIGH ); + } ); + } ); + + describe( 'isValidTemplate', () => { + it( 'should return true if template is valid', () => { + const state = { + template: { isValid: true }, + }; + + expect( isValidTemplate( state ) ).toBe( true ); + } ); + + it( 'should return false if template is not valid', () => { + const state = { + template: { isValid: false }, + }; + + expect( isValidTemplate( state ) ).toBe( false ); + } ); + } ); + + describe( 'getTemplate', () => { + it( 'should return the template object', () => { + const template = []; + const state = { + settings: { template }, + }; + + expect( getTemplate( state ) ).toBe( template ); + } ); + } ); + + describe( 'getTemplateLock', () => { + it( 'should return the general template lock if no clientId was set', () => { + const state = { + settings: { templateLock: 'all' }, + }; + + expect( getTemplateLock( state ) ).toBe( 'all' ); + } ); + + it( 'should return null if the specified clientId was not found ', () => { + const state = { + settings: { templateLock: 'all' }, + blockListSettings: { + chicken: { + templateLock: 'insert', + }, + }, + }; + + expect( getTemplateLock( state, 'ribs' ) ).toBe( null ); + } ); + + it( 'should return null if template lock was not set on the specified block', () => { + const state = { + settings: { templateLock: 'all' }, + blockListSettings: { + chicken: { + test: 'tes1t', + }, + }, + }; + + expect( getTemplateLock( state, 'ribs' ) ).toBe( null ); + } ); + + it( 'should return the template lock for the specified clientId', () => { + const state = { + settings: { templateLock: 'all' }, + blockListSettings: { + chicken: { + templateLock: 'insert', + }, + }, + }; + + expect( getTemplateLock( state, 'chicken' ) ).toBe( 'insert' ); + } ); + } ); + + describe( 'getBlockListSettings', () => { + it( 'should return the settings of a block', () => { + const state = { + blockListSettings: { + chicken: { + setting1: false, + }, + ribs: { + setting2: true, + }, + }, + }; + + expect( getBlockListSettings( state, 'chicken' ) ).toEqual( { + setting1: false, + } ); + } ); + + it( 'should return undefined if settings for the block don’t exist', () => { + const state = { + blockListSettings: {}, + }; + + expect( getBlockListSettings( state, 'chicken' ) ).toBe( undefined ); + } ); + } ); +} ); diff --git a/packages/data/src/plugins/persistence/index.js b/packages/data/src/plugins/persistence/index.js index 4a286e65ca71e..5ce7f41a37c2f 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 } from 'lodash'; +import { flow, merge, isPlainObject, omit } from 'lodash'; /** * Internal dependencies @@ -115,7 +115,7 @@ export function createPersistenceInterface( options ) { * * @return {WPDataPlugin} Data plugin. */ -export default function( registry, pluginOptions ) { +const persistencePlugin = function( registry, pluginOptions ) { const persistence = createPersistenceInterface( pluginOptions ); /** @@ -201,4 +201,31 @@ export default function( registry, pluginOptions ) { return store; }, }; -} +}; + +/** + * Deprecated: Remove this function once WordPress 5.3 is released. + */ + +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 = { + preferences: { + insertUsage: coreEditorState.preferences.insertUsage, + }, + }; + + persistence.set( 'core/editor', { + ...coreEditorState, + preferences: omit( coreEditorState.preferences, [ 'insertUsage' ] ), + } ); + persistence.set( 'core/block-editor', blockEditorState ); + } +}; + +export default persistencePlugin; diff --git a/packages/e2e-tests/specs/blocks/preformatted.test.js b/packages/e2e-tests/specs/blocks/preformatted.test.js index 3397c9f75f68d..a5f9965af3c51 100644 --- a/packages/e2e-tests/specs/blocks/preformatted.test.js +++ b/packages/e2e-tests/specs/blocks/preformatted.test.js @@ -22,11 +22,14 @@ describe( 'Preformatted', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); await page.keyboard.press( 'Escape' ); + await page.waitForSelector( 'button[aria-label="More options"]' ); await page.click( 'button[aria-label="More options"]' ); await clickButton( 'Convert to Blocks' ); // Once it's edited, it should be saved as BR tags. await page.keyboard.type( '0' ); await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Escape' ); + await page.waitForSelector( 'button[aria-label="More options"]' ); await page.click( 'button[aria-label="More options"]' ); await clickButton( 'Edit as HTML' ); diff --git a/packages/e2e-tests/specs/plugins/container-blocks.test.js b/packages/e2e-tests/specs/plugins/container-blocks.test.js index 2d32224bb3503..50c4d80854e74 100644 --- a/packages/e2e-tests/specs/plugins/container-blocks.test.js +++ b/packages/e2e-tests/specs/plugins/container-blocks.test.js @@ -31,6 +31,7 @@ describe( 'InnerBlocks Template Sync', () => { `; await insertBlock( blockName ); await switchEditorModeTo( 'Code' ); + await page.waitForSelector( '.editor-post-text-editor' ); await page.$eval( '.editor-post-text-editor', ( element, _paragraph, _blockSlug ) => { const blockDelimiter = ``; element.value = element.value.replace( blockDelimiter, `${ _paragraph }${ blockDelimiter }` ); diff --git a/packages/edit-post/src/editor.js b/packages/edit-post/src/editor.js index 74e035fb2fd52..3e51956a0e074 100644 --- a/packages/edit-post/src/editor.js +++ b/packages/edit-post/src/editor.js @@ -1,9 +1,14 @@ +/** + * External dependencies + */ +import memize from 'memize'; + /** * WordPress dependencies */ import { withSelect } from '@wordpress/data'; import { EditorProvider, ErrorBoundary, PostLockedModal } from '@wordpress/editor'; -import { StrictMode } from '@wordpress/element'; +import { StrictMode, Component } from '@wordpress/element'; import { KeyboardShortcuts } from '@wordpress/components'; /** @@ -12,41 +17,61 @@ import { KeyboardShortcuts } from '@wordpress/components'; import preventEventDiscovery from './prevent-event-discovery'; import Layout from './components/layout'; -function Editor( { - settings, - hasFixedToolbar, - focusMode, - post, - initialEdits, - onError, - ...props -} ) { - if ( ! post ) { - return null; +class Editor extends Component { + constructor() { + super( ...arguments ); + + this.getEditorSettings = memize( this.getEditorSettings, { + maxSize: 1, + } ); + } + + getEditorSettings( settings, hasFixedToolbar, focusMode ) { + return { + ...settings, + hasFixedToolbar, + focusMode, + }; } - const editorSettings = { - ...settings, - hasFixedToolbar, - focusMode, - }; - - return ( - - - - - - - - - - ); + render() { + const { + settings, + hasFixedToolbar, + focusMode, + post, + initialEdits, + onError, + ...props + } = this.props; + + if ( ! post ) { + return null; + } + + const editorSettings = { + ...settings, + hasFixedToolbar, + focusMode, + }; + + return ( + + + + + + + + + + ); + } } export default withSelect( ( select, { postId, postType } ) => ( { diff --git a/packages/editor/package.json b/packages/editor/package.json index c82d754733f61..acbb00bec5532 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -24,6 +24,7 @@ "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", + "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", diff --git a/packages/editor/src/components/block-list/block.js b/packages/editor/src/components/block-list/block.js index fc79575258011..22b037c7e2e52 100644 --- a/packages/editor/src/components/block-list/block.js +++ b/packages/editor/src/components/block-list/block.js @@ -693,7 +693,6 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { removeBlock, mergeBlocks, replaceBlocks, - editPost, toggleSelection, } = dispatch( 'core/editor' ); @@ -749,8 +748,10 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { onReplace( blocks ) { replaceBlocks( [ ownProps.clientId ], blocks ); }, - onMetaChange( meta ) { - editPost( { meta } ); + onMetaChange( updatedMeta ) { + const { getEditorSettings } = select( 'core/editor' ); + const onChangeMeta = getEditorSettings().__experimentalMetaSource.onChange; + onChangeMeta( updatedMeta ); }, onShiftSelection() { if ( ! ownProps.isSelectionEnabled ) { diff --git a/packages/editor/src/components/post-text-editor/index.js b/packages/editor/src/components/post-text-editor/index.js index c08e59f35a400..13926f4b79e85 100644 --- a/packages/editor/src/components/post-text-editor/index.js +++ b/packages/editor/src/components/post-text-editor/index.js @@ -65,7 +65,6 @@ export class PostTextEditor extends Component { render() { const { value } = this.state; const { instanceId } = this.props; - return (