diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1283c8a618208..aa13f98b947ab 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -98,4 +98,5 @@ This list is manually curated to include valuable contributions by volunteers th | @nfmohit-wpmudev | | @noisysocks | @noisysocks | | @omarreiss | @omarreiss | -| @hedgefield | @hedgefield | \ No newline at end of file +| @hedgefield | @hedgefield | +| @hideokamoto | @hideokamoto | diff --git a/README.md b/README.md index 3eee7a9d54bfa..07620b84d75d5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Blocks are the unifying evolution of what is now covered, in different ways, by Imagine a custom “employee” block that a client can drag to an About page to automatically display a picture, name, and bio. A whole universe of plugins that all extend WordPress in the same way. Simplified menus and widgets. Users who can instantly understand and use WordPress -- and 90% of plugins. This will allow you to easily compose beautiful posts like this example. -Check out the FAQ for answers to the most common questions about the project. +Check out the FAQ for answers to the most common questions about the project. ## Compatibility diff --git a/blocks/api/registration.js b/blocks/api/registration.js index 77be97707861d..2e065e8541e39 100644 --- a/blocks/api/registration.js +++ b/blocks/api/registration.js @@ -149,15 +149,6 @@ export function registerBlockType( name, settings ) { return; } - if ( 'isPrivate' in settings ) { - deprecated( 'isPrivate', { - version: '3.1', - alternative: 'supports.inserter', - plugin: 'Gutenberg', - } ); - set( settings, [ 'supports', 'inserter' ], ! settings.isPrivate ); - } - if ( 'useOnce' in settings ) { deprecated( 'useOnce', { version: '3.3', diff --git a/blocks/api/serializer.js b/blocks/api/serializer.js index 7e2293d9619ef..cb44083495f11 100644 --- a/blocks/api/serializer.js +++ b/blocks/api/serializer.js @@ -164,12 +164,29 @@ export function getCommentAttributes( allAttributes, blockType ) { return attributes; } -export function serializeAttributes( attrs ) { - return JSON.stringify( attrs ) - .replace( /--/g, '\\u002d\\u002d' ) // don't break HTML comments - .replace( //g, '\\u003e' ) // ibid - .replace( /&/g, '\\u0026' ); // ibid +/** + * Given an attributes object, returns a string in the serialized attributes + * format prepared for post content. + * + * @param {Object} attributes Attributes object. + * + * @return {string} Serialized attributes. + */ +export function serializeAttributes( attributes ) { + return JSON.stringify( attributes ) + // Don't break HTML comments. + .replace( /--/g, '\\u002d\\u002d' ) + + // Don't break non-standard-compliant tools. + .replace( //g, '\\u003e' ) + .replace( /&/g, '\\u0026' ) + + // Bypass server stripslashes behavior which would unescape stringify's + // escaping of quotation mark. + // + // See: https://developer.wordpress.org/reference/functions/wp_kses_stripslashes/ + .replace( /\\"/g, '\\u0022' ); } /** diff --git a/blocks/api/test/parser.js b/blocks/api/test/parser.js index b883efbd17a87..1538fd2c8753b 100644 --- a/blocks/api/test/parser.js +++ b/blocks/api/test/parser.js @@ -16,6 +16,8 @@ import { getBlockTypes, setUnknownTypeHandlerName, } from '../registration'; +import { createBlock } from '../factory'; +import serialize from '../serializer'; describe( 'block parser', () => { const defaultBlockSettings = { @@ -571,5 +573,24 @@ describe( 'block parser', () => { 'core/test-block', 'core/void-block', ] ); } ); + + it( 'should parse with unicode escaped returned to original representation', () => { + registerBlockType( 'core/code', { + category: 'common', + title: 'Code Block', + attributes: { + content: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.content, + } ); + + const content = '$foo = "My \"escaped\" text.";'; + const block = createBlock( 'core/code', { content } ); + const serialized = serialize( block ); + const parsed = parse( serialized ); + expect( parsed[ 0 ].attributes.content ).toBe( content ); + } ); } ); } ); diff --git a/blocks/api/test/serializer.js b/blocks/api/test/serializer.js index f7c7c293b0889..8d14dfafa98d5 100644 --- a/blocks/api/test/serializer.js +++ b/blocks/api/test/serializer.js @@ -130,15 +130,22 @@ describe( 'block serializer', () => { it( 'should not break HTML comments', () => { expect( serializeAttributes( { a: '-- and --' } ) ).toBe( '{"a":"\\u002d\\u002d and \\u002d\\u002d"}' ); } ); + it( 'should not break standard-non-compliant tools for "<"', () => { expect( serializeAttributes( { a: '< and <' } ) ).toBe( '{"a":"\\u003c and \\u003c"}' ); } ); + it( 'should not break standard-non-compliant tools for ">"', () => { expect( serializeAttributes( { a: '> and >' } ) ).toBe( '{"a":"\\u003e and \\u003e"}' ); } ); + it( 'should not break standard-non-compliant tools for "&"', () => { expect( serializeAttributes( { a: '& and &' } ) ).toBe( '{"a":"\\u0026 and \\u0026"}' ); } ); + + it( 'should replace quotation marks', () => { + expect( serializeAttributes( { a: '" and "' } ) ).toBe( '{"a":"\\u0022 and \\u0022"}' ); + } ); } ); describe( 'getCommentDelimitedContent()', () => { diff --git a/blocks/deprecated-hooks.js b/blocks/deprecated-hooks.js new file mode 100644 index 0000000000000..80bb42a2a8d94 --- /dev/null +++ b/blocks/deprecated-hooks.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * WordPress dependencies + */ +import { addAction, addFilter } from '@wordpress/hooks'; +import deprecated from '@wordpress/deprecated'; + +const forwardDeprecatedHooks = ( oldHookName, ...args ) => { + const deprecatedHooks = [ + 'blocks.Autocomplete.completers', + 'blocks.BlockEdit', + 'blocks.BlockListBlock', + 'blocks.MediaUpload', + ]; + if ( includes( deprecatedHooks, oldHookName ) ) { + const newHookName = oldHookName.replace( 'blocks.', 'editor.' ); + deprecated( `${ oldHookName } filter`, { + version: '3.3', + alternative: newHookName, + } ); + addFilter( newHookName, ...args ); + } +}; + +addAction( 'hookAdded', 'core/editor/deprecated', forwardDeprecatedHooks ); diff --git a/blocks/index.js b/blocks/index.js index 2fed22406b3e9..98698dbaa1062 100644 --- a/blocks/index.js +++ b/blocks/index.js @@ -7,5 +7,6 @@ // // Blocks are inferred from the HTML source of a post through a parsing mechanism // and then stored as objects in state, from which it is then rendered for editing. +import './deprecated-hooks'; import './store'; export * from './api'; diff --git a/components/autocomplete/index.js b/components/autocomplete/index.js index b54216355a4d0..85e07f4e8d966 100644 --- a/components/autocomplete/index.js +++ b/components/autocomplete/index.js @@ -348,10 +348,22 @@ export class Autocomplete extends Component { /* * We support both synchronous and asynchronous retrieval of completer options * but internally treat all as async so we maintain a single, consistent code path. + * + * Because networks can be slow, and the internet is wonderfully unpredictable, + * we don't want two promises updating the state at once. This ensures that only + * the most recent promise will act on `optionsData`. This doesn't use the state + * because `setState` is batched, and so there's no guarantee that setting + * `activePromise` in the state would result in it actually being in `this.state` + * before the promise resolves and we check to see if this is the active promise or not. */ - Promise.resolve( + const promise = this.activePromise = Promise.resolve( typeof options === 'function' ? options( query ) : options ).then( ( optionsData ) => { + if ( promise !== this.activePromise ) { + // Another promise has become active since this one was asked to resolve, so do nothing, + // or else we might end triggering a race condition updating the state. + return; + } const keyedOptions = optionsData.map( ( optionData, optionIndex ) => ( { key: `${ completer.idx }-${ optionIndex }`, value: optionData, diff --git a/components/button/index.js b/components/button/index.js index 51ba14967f183..b36d6a4208107 100644 --- a/components/button/index.js +++ b/components/button/index.js @@ -24,6 +24,7 @@ export function Button( props, ref ) { isBusy, isDefault, isLink, + isDestructive, className, disabled, focus, @@ -39,6 +40,7 @@ export function Button( props, ref ) { 'is-toggled': isToggled, 'is-busy': isBusy, 'is-link': isLink, + 'is-destructive': isDestructive, } ); const tag = href !== undefined && ! disabled ? 'a' : 'button'; diff --git a/components/button/style.scss b/components/button/style.scss index 2d4d6df118d8a..63fc4b73c473f 100644 --- a/components/button/style.scss +++ b/components/button/style.scss @@ -54,7 +54,6 @@ background: #f7f7f7 !important; box-shadow: none !important; text-shadow: 0 1px 0 #fff !important; - cursor: default; -webkit-transform: none !important; transform: none !important; } @@ -98,7 +97,6 @@ border-color: color( theme( button ) shade( 20% ) ) !important; box-shadow: none !important; text-shadow: 0 -1px 0 rgba( 0, 0, 0, 0.1 ) !important; - cursor: default; } &.is-busy, @@ -124,7 +122,6 @@ border-radius: 0; background: none; outline: none; - cursor: pointer; text-align: left; /* Mimics the default link style in common.css */ color: #0073aa; @@ -145,18 +142,21 @@ } } + /* Link buttons that are red to indicate destructive behavior. */ + &.is-link.is-destructive { + color: $alert-red; + } + &:active { color: currentColor; } - &:disabled { + &:disabled, + &[disabled] { + cursor: default; opacity: 0.3; } - &:not( :disabled ):not( [aria-disabled="true"] ) { - cursor: pointer; - } - &:not( :disabled ):not( [aria-disabled="true"] ):focus { @include button-style__focus-active; } diff --git a/components/color-palette/index.js b/components/color-palette/index.js index 76265e33f620f..aa633859f762e 100644 --- a/components/color-palette/index.js +++ b/components/color-palette/index.js @@ -31,13 +31,21 @@ export default function ColorPalette( { colors, disableCustomColors = false, val return (
- +
); - }, + } ), save( { attributes, className } ) { const { url, title, hasParallax, dimRatio, align, contentAlign } = attributes; diff --git a/core-blocks/embed/index.js b/core-blocks/embed/index.js index b2c17bac4435b..1332db7164099 100644 --- a/core-blocks/embed/index.js +++ b/core-blocks/embed/index.js @@ -102,7 +102,7 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed', }; } - componentWillMount() { + componentDidMount() { this.doServerSideRender(); } @@ -209,6 +209,7 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed', } if ( ! html ) { + // translators: %s: type of embed e.g: "YouTube", "Twitter", etc. "Embed" is used when no specific type exists const label = sprintf( __( '%s URL' ), title ); return ( @@ -237,6 +238,7 @@ function getEmbedBlockSettings( { title, description, icon, category = 'embed', const parsedUrl = parse( url ); const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedUrl.host.replace( /^www\./, '' ) ); + // translators: %s: host providing embed content e.g: www.youtube.com const iframeTitle = sprintf( __( 'Embedded content from %s' ), parsedUrl.host ); const embedWrapper = 'wp-embed' === type ? (
pick( image, [ 'alt', 'caption', 'id', 'url' ] ) ), + images: images.map( ( image ) => pick( image, [ 'alt', 'caption', 'id', 'link', 'url' ] ) ), } ); } @@ -149,9 +149,9 @@ class GalleryEdit extends Component { } ); } - componentWillReceiveProps( nextProps ) { + componentDidUpdate( prevProps ) { // Deselect images when deselecting the block - if ( ! nextProps.isSelected && this.props.isSelected ) { + if ( ! this.props.isSelected && prevProps.isSelected ) { this.setState( { selectedImage: null, captionSelected: false, diff --git a/core-blocks/gallery/editor.scss b/core-blocks/gallery/editor.scss index 81304ff77a3a6..cbe95fcb30861 100644 --- a/core-blocks/gallery/editor.scss +++ b/core-blocks/gallery/editor.scss @@ -100,3 +100,14 @@ left: 50%; transform: translate( -50%, -50% ); } + +// IE11 doesn't support object-fit or flex very well, so we inline-block. +@media all and ( -ms-high-contrast: none ) { + *::-ms-backdrop, .blocks-gallery-item { + display: inline-block; + } + + *::-ms-backdrop, .blocks-gallery-item img { + width: 100%; + } +} \ No newline at end of file diff --git a/core-blocks/gallery/gallery-image.js b/core-blocks/gallery/gallery-image.js index 759d7068e31a1..6301bbb2fec8e 100644 --- a/core-blocks/gallery/gallery-image.js +++ b/core-blocks/gallery/gallery-image.js @@ -71,7 +71,8 @@ class GalleryImage extends Component { } } - componentWillReceiveProps( { isSelected, image, url } ) { + componentDidUpdate( prevProps ) { + const { isSelected, image, url } = this.props; if ( image && ! url ) { this.props.setAttributes( { url: image.source_url, @@ -81,7 +82,7 @@ class GalleryImage extends Component { // unselect the caption so when the user selects other image and comeback // the caption is not immediately selected - if ( this.state.captionSelected && ! isSelected && this.props.isSelected ) { + if ( this.state.captionSelected && ! isSelected && prevProps.isSelected ) { this.setState( { captionSelected: false, } ); diff --git a/core-blocks/gallery/style.scss b/core-blocks/gallery/style.scss index 9bdaaafc9105b..6d51a93880593 100644 --- a/core-blocks/gallery/style.scss +++ b/core-blocks/gallery/style.scss @@ -15,7 +15,6 @@ justify-content: center; position: relative; - figure { margin: 0; height: 100%; @@ -53,12 +52,6 @@ object-fit: cover; } - - // Alas, IE11+ doesn't support object-fit - _:-ms-lang(x), figure { - height: auto; - width: auto; - } } // Responsive fallback value, 2 columns diff --git a/core-blocks/heading/edit.js b/core-blocks/heading/edit.js index 60869aca35e3f..05b4fc9df600e 100644 --- a/core-blocks/heading/edit.js +++ b/core-blocks/heading/edit.js @@ -29,6 +29,7 @@ export default function HeadingEdit( { ( { icon: 'heading', + // translators: %s: heading level e.g: "1", "2", "3" title: sprintf( __( 'Heading %s' ), level ), isActive: 'H' + level === nodeName, onClick: () => setAttributes( { nodeName: 'H' + level } ), @@ -42,6 +43,7 @@ export default function HeadingEdit( { ( { icon: 'heading', + // translators: %s: heading level e.g: "1", "2", "3" title: sprintf( __( 'Heading %s' ), level ), isActive: 'H' + level === nodeName, onClick: () => setAttributes( { nodeName: 'H' + level } ), diff --git a/core-blocks/heading/test/__snapshots__/index.js.snap b/core-blocks/heading/test/__snapshots__/index.js.snap index 6a817645c35e2..a09f615febec2 100644 --- a/core-blocks/heading/test/__snapshots__/index.js.snap +++ b/core-blocks/heading/test/__snapshots__/index.js.snap @@ -13,7 +13,7 @@ exports[`core/heading block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-expanded="false" aria-label="Write heading…" - aria-multiline="false" + aria-multiline="true" class="editor-rich-text__tinymce" contenteditable="true" data-is-placeholder-visible="true" diff --git a/core-blocks/image/edit.js b/core-blocks/image/edit.js index 691f3f7abb533..49a718da13565 100644 --- a/core-blocks/image/edit.js +++ b/core-blocks/image/edit.js @@ -97,10 +97,8 @@ class ImageEdit extends Component { if ( ! prevID && prevUrl.indexOf( 'blob:' ) === 0 && id && url.indexOf( 'blob:' ) === -1 ) { revokeBlobURL( url ); } - } - componentWillReceiveProps( { isSelected } ) { - if ( ! isSelected && this.props.isSelected && this.state.captionFocused ) { + if ( ! this.props.isSelected && prevProps.isSelected && this.state.captionFocused ) { this.setState( { captionFocused: false, } ); @@ -108,7 +106,7 @@ class ImageEdit extends Component { } onSelectImage( media ) { - if ( ! media ) { + if ( ! media || ! media.url ) { this.props.setAttributes( { url: undefined, alt: undefined, @@ -318,7 +316,6 @@ class ImageEdit extends Component { return ( { controls } - { noticeUI }
{ ( sizes ) => { diff --git a/core-blocks/list/test/__snapshots__/index.js.snap b/core-blocks/list/test/__snapshots__/index.js.snap index 611b782538e96..d17def7ea765e 100644 --- a/core-blocks/list/test/__snapshots__/index.js.snap +++ b/core-blocks/list/test/__snapshots__/index.js.snap @@ -17,6 +17,7 @@ exports[`core/list block edit matches snapshot 1`] = ` class="editor-rich-text__tinymce" contenteditable="true" data-is-placeholder-visible="true" + role="textbox" />
    = 18 } /> -
    - { - setAttributes( { - content: nextContent, - } ); - } } - onSplit={ insertBlocksAfter ? - ( before, after, ...blocks ) => { - if ( after ) { - blocks.push( createBlock( name, { content: after } ) ); - } - - insertBlocksAfter( blocks ); - - if ( before ) { - setAttributes( { content: before } ); - } else { - onReplace( [] ); - } - } : - undefined - } - onMerge={ mergeBlocks } - onReplace={ this.onReplace } - onRemove={ () => onReplace( [] ) } - placeholder={ placeholder || __( 'Add text or type / to add content' ) } - /> -
    + { + setAttributes( { + content: nextContent, + } ); + } } + onSplit={ insertBlocksAfter ? + ( before, after, ...blocks ) => { + if ( after ) { + blocks.push( createBlock( name, { content: after } ) ); + } + + insertBlocksAfter( blocks ); + + if ( before ) { + setAttributes( { content: before } ); + } else { + onReplace( [] ); + } + } : + undefined + } + onMerge={ mergeBlocks } + onReplace={ this.onReplace } + onRemove={ () => onReplace( [] ) } + placeholder={ placeholder || __( 'Add text or type / to add content' ) } + /> ); } @@ -454,17 +452,10 @@ export const settings = { } }, - edit: compose( - withColors( ( getColor, setColor, { attributes } ) => { - return { - backgroundColor: getColor( attributes.backgroundColor, attributes.customBackgroundColor, 'background-color' ), - setBackgroundColor: setColor( 'backgroundColor', 'customBackgroundColor' ), - textColor: getColor( attributes.textColor, attributes.customTextColor, 'color' ), - setTextColor: setColor( 'textColor', 'customTextColor' ), - }; - } ), + edit: compose( [ + withColors( 'backgroundColor', { textColor: 'color' } ), FallbackStyles, - )( ParagraphBlock ), + ] )( ParagraphBlock ), save( { attributes } ) { const { diff --git a/core-blocks/paragraph/test/__snapshots__/index.js.snap b/core-blocks/paragraph/test/__snapshots__/index.js.snap index 95143b5865588..73dfd3da4df06 100644 --- a/core-blocks/paragraph/test/__snapshots__/index.js.snap +++ b/core-blocks/paragraph/test/__snapshots__/index.js.snap @@ -3,31 +3,29 @@ exports[`core/paragraph block edit matches snapshot 1`] = `
    -
    -
    +
    +
    -
    -
    +

    -

    - Add text or type / to add content -

    -
    + Add text or type / to add content +

    diff --git a/core-blocks/preformatted/test/__snapshots__/index.js.snap b/core-blocks/preformatted/test/__snapshots__/index.js.snap index ff76887661dcf..a0748236e6e0d 100644 --- a/core-blocks/preformatted/test/__snapshots__/index.js.snap +++ b/core-blocks/preformatted/test/__snapshots__/index.js.snap @@ -13,7 +13,7 @@ exports[`core/preformatted block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-expanded="false" aria-label="Write preformatted text…" - aria-multiline="false" + aria-multiline="true" class="editor-rich-text__tinymce" contenteditable="true" data-is-placeholder-visible="true" diff --git a/core-blocks/quote/index.js b/core-blocks/quote/index.js index 33ffc53117713..08bbaa286979b 100644 --- a/core-blocks/quote/index.js +++ b/core-blocks/quote/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray, get, isString } from 'lodash'; +import { castArray, get, isString, isEmpty } from 'lodash'; import classnames from 'classnames'; /** @@ -69,12 +69,15 @@ export const settings = { from: [ { type: 'block', + isMultiBlock: true, blocks: [ 'core/paragraph' ], - transform: ( { content } ) => { + transform: ( attributes ) => { + const items = attributes.map( ( { content } ) => content ); + const hasItems = ! items.every( isEmpty ); return createBlock( 'core/quote', { - value: [ - { children:

    { content }

    }, - ], + value: hasItems ? + items.map( ( content, index ) => ( { children:

    { content }

    } ) ) : + [], } ); }, }, diff --git a/core-blocks/table/test/__snapshots__/index.js.snap b/core-blocks/table/test/__snapshots__/index.js.snap index 8214a201bb281..e336bc498289e 100644 --- a/core-blocks/table/test/__snapshots__/index.js.snap +++ b/core-blocks/table/test/__snapshots__/index.js.snap @@ -12,7 +12,6 @@ exports[`core/embed block edit matches snapshot 1`] = ` diff --git a/core-blocks/test/helpers/index.js b/core-blocks/test/helpers/index.js index 53cc1187ffe05..7231a3fd0fe09 100644 --- a/core-blocks/test/helpers/index.js +++ b/core-blocks/test/helpers/index.js @@ -31,7 +31,6 @@ export const blockEditRender = ( name, settings ) => { attributes={ block.attributes } setAttributes={ noop } user={ {} } - createInnerBlockList={ noop } /> ); }; diff --git a/core-blocks/text-columns/test/__snapshots__/index.js.snap b/core-blocks/text-columns/test/__snapshots__/index.js.snap index 439500fc13b23..b618bdb565e07 100644 --- a/core-blocks/text-columns/test/__snapshots__/index.js.snap +++ b/core-blocks/text-columns/test/__snapshots__/index.js.snap @@ -19,7 +19,7 @@ exports[`core/text-columns block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-expanded="false" aria-label="New Column" - aria-multiline="false" + aria-multiline="true" class="editor-rich-text__tinymce" contenteditable="true" data-is-placeholder-visible="true" @@ -50,7 +50,7 @@ exports[`core/text-columns block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-expanded="false" aria-label="New Column" - aria-multiline="false" + aria-multiline="true" class="editor-rich-text__tinymce" contenteditable="true" data-is-placeholder-visible="true" diff --git a/core-blocks/verse/test/__snapshots__/index.js.snap b/core-blocks/verse/test/__snapshots__/index.js.snap index b33b596d591bc..dadd75177de95 100644 --- a/core-blocks/verse/test/__snapshots__/index.js.snap +++ b/core-blocks/verse/test/__snapshots__/index.js.snap @@ -13,7 +13,7 @@ exports[`core/verse block edit matches snapshot 1`] = ` aria-autocomplete="list" aria-expanded="false" aria-label="Write…" - aria-multiline="false" + aria-multiline="true" class="editor-rich-text__tinymce" contenteditable="true" data-is-placeholder-visible="true" diff --git a/core-blocks/video/edit.js b/core-blocks/video/edit.js index d7a2410272086..729c3b0813f27 100644 --- a/core-blocks/video/edit.js +++ b/core-blocks/video/edit.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { IconButton, Toolbar } from '@wordpress/components'; +import { IconButton, Toolbar, withNotices } from '@wordpress/components'; import { Component, Fragment } from '@wordpress/element'; import { MediaPlaceholder, @@ -15,7 +15,7 @@ import { */ import './editor.scss'; -export default class VideoEdit extends Component { +class VideoEdit extends Component { constructor() { super( ...arguments ); // edit component has its own src in the state so it can be edited @@ -27,18 +27,23 @@ export default class VideoEdit extends Component { render() { const { caption, src } = this.props.attributes; - const { setAttributes, isSelected, className } = this.props; + const { setAttributes, isSelected, className, noticeOperations, noticeUI } = this.props; const { editing } = this.state; const switchToEditing = () => { this.setState( { editing: true } ); }; const onSelectVideo = ( media ) => { - if ( media && media.url ) { - // sets the block's attribute and updates the edit component from the - // selected media, then switches off the editing UI - setAttributes( { src: media.url, id: media.id } ); - this.setState( { src: media.url, editing: false } ); + if ( ! media || ! media.url ) { + // in this case there was an error and we should continue in the editing state + // previous attributes should be removed because they may be temporary blob urls + setAttributes( { src: undefined, id: undefined } ); + switchToEditing(); + return; } + // sets the block's attribute and updates the edit component from the + // selected media, then switches off the editing UI + setAttributes( { src: media.url, id: media.id } ); + this.setState( { src: media.url, editing: false } ); }; const onSelectUrl = ( newSrc ) => { // set the block's src from the edit component's state, and switch off the editing UI @@ -62,6 +67,8 @@ export default class VideoEdit extends Component { accept="video/*" type="video" value={ this.props.attributes } + notices={ noticeUI } + onError={ noticeOperations.createErrorNotice } /> ); } @@ -96,3 +103,5 @@ export default class VideoEdit extends Component { /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ } } + +export default withNotices( VideoEdit ); diff --git a/docs/block-api.md b/docs/block-api.md index 3d78be7c0001e..72f105645035c 100644 --- a/docs/block-api.md +++ b/docs/block-api.md @@ -79,6 +79,8 @@ when they are applicable e.g.: in the inserter. icon: { // Specifying a background color to appear with the icon e.g.: in the inserter. background: '#7e70af', + // Specifying a color for the icon (optional: if not set, a readable color will be automatically defined) + foreground: '#fff', // Specifying a dashicon for the block src: 'book-alt', } , diff --git a/docs/data/core-blocks.md b/docs/data/core-blocks.md new file mode 100644 index 0000000000000..93fe60ab0c488 --- /dev/null +++ b/docs/data/core-blocks.md @@ -0,0 +1,82 @@ +# **core/blocks**: Block Types Data + +## Selectors + +### getBlockType + +Returns a block type by name. + +*Parameters* + + * state: Data state. + * name: Block type name. + +### getCategories + +Returns all the available categories. + +*Parameters* + + * state: Data state. + +*Returns* + +Categories list. + +### getDefaultBlockName + +Returns the name of the default block name. + +*Parameters* + + * state: Data state. + +*Returns* + +Default block name. + +### getFallbackBlockName + +Returns the name of the fallback block name. + +*Parameters* + + * state: Data state. + +*Returns* + +Fallback block name. + +## Actions + +### addBlockTypes + +Returns an action object used in signalling that block types have been added. + +*Parameters* + + * blockTypes: Block types received. + +### removeBlockTypes + +Returns an action object used to remove a registered block type. + +*Parameters* + + * names: Block name. + +### setDefaultBlockName + +Returns an action object used to set the default block name. + +*Parameters* + + * name: Block name. + +### setFallbackBlockName + +Returns an action object used to set the fallback block name. + +*Parameters* + + * name: Block name. \ No newline at end of file diff --git a/docs/data/core-edit-post.md b/docs/data/core-edit-post.md new file mode 100644 index 0000000000000..be5e8acfe1c08 --- /dev/null +++ b/docs/data/core-edit-post.md @@ -0,0 +1,245 @@ +# **core/edit-post**: The Editor's UI Data + +## Selectors + +### getEditorMode + +Returns the current editing mode. + +*Parameters* + + * state: Global application state. + +### isEditorSidebarOpened + +Returns true if the editor sidebar is opened. + +*Parameters* + + * state: Global application state + +*Returns* + +Whether the editor sidebar is opened. + +### isPluginSidebarOpened + +Returns true if the plugin sidebar is opened. + +*Parameters* + + * state: Global application state + +*Returns* + +Whether the plugin sidebar is opened. + +### getActiveGeneralSidebarName + +Returns the current active general sidebar name. + +*Parameters* + + * state: Global application state. + +*Returns* + +Active general sidebar name. + +### getPreferences + +Returns the preferences (these preferences are persisted locally). + +*Parameters* + + * state: Global application state. + +*Returns* + +Preferences Object. + +### getPreference + +*Parameters* + + * state: Global application state. + * preferenceKey: Preference Key. + * defaultValue: Default Value. + +*Returns* + +Preference Value. + +### isPublishSidebarOpened + +Returns true if the publish sidebar is opened. + +*Parameters* + + * state: Global application state + +*Returns* + +Whether the publish sidebar is open. + +### isEditorSidebarPanelOpened + +Returns true if the editor sidebar panel is open, or false otherwise. + +*Parameters* + + * state: Global application state. + * panel: Sidebar panel name. + +*Returns* + +Whether the sidebar panel is open. + +### isFeatureActive + +Returns whether the given feature is enabled or not. + +*Parameters* + + * state: Global application state. + * feature: Feature slug. + +*Returns* + +Is active. + +### isPluginItemPinned + +Returns true if the the plugin item is pinned to the header. +When the value is not set it defaults to true. + +*Parameters* + + * state: Global application state. + * pluginName: Plugin item name. + +*Returns* + +Whether the plugin item is pinned. + +### getMetaBoxes + +Returns the state of legacy meta boxes. + +*Parameters* + + * state: Global application state. + +*Returns* + +State of meta boxes. + +### getMetaBox + +Returns the state of legacy meta boxes. + +*Parameters* + + * state: Global application state. + * location: Location of the meta box. + +*Returns* + +State of meta box at specified location. + +### isSavingMetaBoxes + +Returns true if the the Meta Boxes are being saved. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the metaboxes are being saved. + +## Actions + +### openGeneralSidebar + +Returns an action object used in signalling that the user opened an editor sidebar. + +*Parameters* + + * name: Sidebar name to be opened. + +### closeGeneralSidebar + +Returns an action object signalling that the user closed the sidebar. + +### openPublishSidebar + +Returns an action object used in signalling that the user opened the publish +sidebar. + +### closePublishSidebar + +Returns an action object used in signalling that the user closed the +publish sidebar. + +### togglePublishSidebar + +Returns an action object used in signalling that the user toggles the publish sidebar. + +### toggleGeneralSidebarEditorPanel + +Returns an action object used in signalling that use toggled a panel in the editor. + +*Parameters* + + * panel: The panel to toggle. + +### toggleFeature + +Returns an action object used to toggle a feature flag. + +*Parameters* + + * feature: Feature name. + +### togglePinnedPluginItem + +Returns an action object used to toggle a plugin name flag. + +*Parameters* + + * pluginName: Plugin name. + +### initializeMetaBoxState + +Returns an action object used to check the state of meta boxes at a location. + +This should only be fired once to initialize meta box state. If a meta box +area is empty, this will set the store state to indicate that React should +not render the meta box area. + +Example: metaBoxes = { side: true, normal: false }. + +This indicates that the sidebar has a meta box but the normal area does not. + +*Parameters* + + * metaBoxes: Whether meta box locations are active. + +### requestMetaBoxUpdates + +Returns an action object used to request meta box update. + +### metaBoxUpdatesSuccess + +Returns an action object used signal a successfull meta box update. + +### setMetaBoxSavedData + +Returns an action object used to set the saved meta boxes data. +This is used to check if the meta boxes have been touched when leaving the editor. + +*Parameters* + + * dataPerLocation: Meta Boxes Data per location. \ No newline at end of file diff --git a/docs/data/core-editor.md b/docs/data/core-editor.md new file mode 100644 index 0000000000000..87fed16d687bf --- /dev/null +++ b/docs/data/core-editor.md @@ -0,0 +1,1382 @@ +# **core/editor**: The Editor's Data + +## Selectors + +### hasEditorUndo + +Returns true if any past editor history snapshots exist, or false otherwise. + +*Parameters* + + * state: Global application state. + +### hasEditorRedo + +Returns true if any future editor history snapshots exist, or false +otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether redo history exists. + +### isEditedPostNew + +Returns true if the currently edited post is yet to be saved, or false if +the post has been saved. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post is new. + +### isEditedPostDirty + +Returns true if there are unsaved values for the current edit session, or +false if the editing state matches the saved or new post. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether unsaved values exist. + +### isCleanNewPost + +Returns true if there are no unsaved values for the current edit session and if +the currently edited post is new (and has never been saved before). + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether new post and unsaved values exist. + +### getCurrentPost + +Returns the post currently being edited in its last known saved state, not +including unsaved edits. Returns an object containing relevant default post +values if the post has not yet been saved. + +*Parameters* + + * state: Global application state. + +*Returns* + +Post object. + +### getCurrentPostType + +Returns the post type of the post currently being edited. + +*Parameters* + + * state: Global application state. + +*Returns* + +Post type. + +### getCurrentPostId + +Returns the ID of the post currently being edited, or null if the post has +not yet been saved. + +*Parameters* + + * state: Global application state. + +*Returns* + +ID of current post. + +### getCurrentPostRevisionsCount + +Returns the number of revisions of the post currently being edited. + +*Parameters* + + * state: Global application state. + +*Returns* + +Number of revisions. + +### getCurrentPostLastRevisionId + +Returns the last revision ID of the post currently being edited, +or null if the post has no revisions. + +*Parameters* + + * state: Global application state. + +*Returns* + +ID of the last revision. + +### getPostEdits + +Returns any post values which have been changed in the editor but not yet +been saved. + +*Parameters* + + * state: Global application state. + +*Returns* + +Object of key value pairs comprising unsaved edits. + +### getEditedPostAttribute + +Returns a single attribute of the post being edited, preferring the unsaved +edit if one exists, but falling back to the attribute for the last known +saved state of the post. + +*Parameters* + + * state: Global application state. + * attributeName: Post attribute name. + +*Returns* + +Post attribute value. + +### getEditedPostVisibility + +Returns the current visibility of the post being edited, preferring the +unsaved value if different than the saved post. The return value is one of +"private", "password", or "public". + +*Parameters* + + * state: Global application state. + +*Returns* + +Post visibility. + +### isCurrentPostPending + +Returns true if post is pending review. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether current post is pending review. + +### isCurrentPostPublished + +Return true if the current post has already been published. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post has been published. + +### isCurrentPostScheduled + +Returns true if post is already scheduled. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether current post is scheduled to be posted. + +### isEditedPostPublishable + +Return true if the post being edited can be published. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post can been published. + +### isEditedPostSaveable + +Returns true if the post can be saved, or false otherwise. A post must +contain a title, an excerpt, or non-empty content to be valid for save. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post can be saved. + +### isEditedPostEmpty + +Returns true if the edited post has content. A post has content if it has at +least one block or otherwise has a non-empty content property assigned. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether post has content. + +### isEditedPostAutosaveable + +Returns true if the post can be autosaved, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post can be autosaved. + +### getAutosave + +Returns the current autosave, or null if one is not set (i.e. if the post +has yet to be autosaved, or has been saved or published since the last +autosave). + +*Parameters* + + * state: Editor state. + +*Returns* + +Current autosave, if exists. + +### hasAutosave + +Returns the true if there is an existing autosave, otherwise false. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether there is an existing autosave. + +### isEditedPostBeingScheduled + +Return true if the post being edited is being scheduled. Preferring the +unsaved status values. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post has been published. + +### getDocumentTitle + +Gets the document title to be used. + +*Parameters* + + * state: Global application state. + +*Returns* + +Document title. + +### getEditedPostPreviewLink + +Returns a URL to preview the post being edited. + +*Parameters* + + * state: Global application state. + +*Returns* + +Preview URL. + +### getBlockName + +Returns a block's name given its UID, or null if no block exists with the +UID. + +*Parameters* + + * state: Editor state. + * uid: Block unique ID. + +*Returns* + +Block name. + +### getBlockCount + +Returns the number of blocks currently present in the post. + +*Parameters* + + * state: Global application state. + * rootUID: Optional root UID 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* + +UID 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* + +UID 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. + +### getSelectedBlockUID + +Returns the currently selected block UID, or null if there is no selected +block. + +*Parameters* + + * state: Global application state. + +*Returns* + +Selected block UID. + +### getSelectedBlock + +Returns the currently selected block, or null if there is no selected block. + +*Parameters* + + * state: Global application state. + +*Returns* + +Selected block. + +### getBlockRootUID + +Given a block UID, 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: Global application state. + * uid: Block from which to find root UID. + +*Returns* + +Root UID, if exists + +### getAdjacentBlockUid + +Returns the UID of the block adjacent one at the given reference startUID and modifier +directionality. Defaults start UID to the selected block, and direction as +next block. Returns null if there is no adjacent block. + +*Parameters* + + * state: Global application state. + * startUID: Optional UID of block from which to search. + * modifier: Directionality multiplier (1 next, -1 previous). + +*Returns* + +Return the UID of the block, or null if none exists. + +### getPreviousBlockUid + +Returns the previous block's UID from the given reference startUID. Defaults start +UID to the selected block. Returns null if there is no previous block. + +*Parameters* + + * state: Global application state. + * startUID: Optional UID of block from which to search. + +*Returns* + +Adjacent block's UID, or null if none exists. + +### getNextBlockUid + +Returns the next block's UID from the given reference startUID. Defaults start UID +to the selected block. Returns null if there is no next block. + +*Parameters* + + * state: Global application state. + * startUID: Optional UID of block from which to search. + +*Returns* + +Adjacent block's UID, 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. + +### getFirstMultiSelectedBlockUid + +Returns the unique ID of the first block in the multi-selection set, or null +if there is no multi-selection. + +*Parameters* + + * state: Global application state. + +*Returns* + +First unique block ID in the multi-selection set. + +### getLastMultiSelectedBlockUid + +Returns the unique ID of the last block in the multi-selection set, or null +if there is no multi-selection. + +*Parameters* + + * state: Global application state. + +*Returns* + +Last unique block ID in the multi-selection set. + +### isFirstMultiSelectedBlock + +Returns true if a multi-selection exists, and the block corresponding to the +specified unique ID is the first block of the multi-selection set, or false +otherwise. + +*Parameters* + + * state: Global application state. + * uid: Block unique ID. + +*Returns* + +Whether block is first in mult-selection. + +### isBlockMultiSelected + +Returns true if the unique ID occurs within the block multi-selection, or +false otherwise. + +*Parameters* + + * state: Global application state. + * uid: Block unique ID. + +*Returns* + +Whether block is in multi-selection set. + +### getMultiSelectedBlocksStartUid + +Returns the unique ID of the block which begins the multi-selection set, or +null if there is no multi-selection. + +N.b.: This is not necessarily the first uid in the selection. See +getFirstMultiSelectedBlockUid(). + +*Parameters* + + * state: Global application state. + +*Returns* + +Unique ID of block beginning multi-selection. + +### getMultiSelectedBlocksEndUid + +Returns the unique ID of the block which ends the multi-selection set, or +null if there is no multi-selection. + +N.b.: This is not necessarily the last uid in the selection. See +getLastMultiSelectedBlockUid(). + +*Parameters* + + * state: Global application state. + +*Returns* + +Unique ID of block ending multi-selection. + +### getBlockOrder + +Returns an array containing all block unique IDs of the post being edited, +in the order they appear in the post. Optionally accepts a root UID of the +block list for which the order should be returned, defaulting to the top- +level block order. + +*Parameters* + + * state: Global application state. + * rootUID: Optional root UID of block list. + +*Returns* + +Ordered unique IDs of post blocks. + +### getBlockIndex + +Returns the index at which the block corresponding to the specified unique ID +occurs within the post block order, or `-1` if the block does not exist. + +*Parameters* + + * state: Global application state. + * uid: Block unique ID. + * rootUID: Optional root UID of block list. + +*Returns* + +Index at which block exists in order. + +### isBlockSelected + +Returns true if the block corresponding to the specified unique ID is +currently selected and no multi-selection exists, or false otherwise. + +*Parameters* + + * state: Global application state. + * uid: Block unique 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: Global application state. + * uid: Block unique ID. + +*Returns* + +Whether the block as an inner block selected + +### isBlockWithinSelection + +Returns true if the block corresponding to the specified unique 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: Global application state. + * uid: Block unique 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 + +Whether is selection disable or not. + +*Parameters* + + * state: Global application state. + +*Returns* + +True if multi is disable, false if not. + +### getBlockMode + +Returns thee block's editing mode. + +*Parameters* + + * state: Global application state. + * uid: Block unique 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. + +### getBlockInsertionPoint + +Returns the insertion point, the index at which the new inserted block would +be placed. Defaults to the last index. + +*Parameters* + + * state: Global application state. + +*Returns* + +Insertion point object with `rootUID`, `layout`, `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 + +*Parameters* + + * state: null + +*Returns* + +Block Template Lock + +### isSavingPost + +Returns true if the post is currently being saved, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether post is being saved. + +### didPostSaveRequestSucceed + +Returns true if a previous post save was attempted successfully, or false +otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post was saved successfully. + +### didPostSaveRequestFail + +Returns true if a previous post save was attempted but failed, or false +otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post save failed. + +### isAutosavingPost + +Returns true if the post is autosaving, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post is autosaving. + +### getSuggestedPostFormat + +Returns a suggested post format for the current post, inferred only if there +is a single block within the post and it is of a type known to match a +default post format. Returns null if the format cannot be determined. + +*Parameters* + + * state: Global application state. + +*Returns* + +Suggested post format. + +### getNotices + +Returns the user notices array. + +*Parameters* + + * state: Global application state. + +*Returns* + +List of notices. + +### getFrecentInserterItems + +Returns a list of items which the user is likely to want to insert. These +are ordered by 'frecency', which is a heuristic that combines block usage +frequency and recency. + +https://en.wikipedia.org/wiki/Frecency + +*Parameters* + + * state: Global application state. + * allowedBlockTypes: Allowed block types, or true/false to enable/disable all types. + * maximum: Number of items to return. + +*Returns* + +Items that appear in the 'Recent' tab. + +### isSavingSharedBlock + +Returns whether or not the shared block with the given ID is being saved. + +*Parameters* + + * state: Global application state. + * ref: The shared block's ID. + +*Returns* + +Whether or not the shared block is being saved. + +### isFetchingSharedBlock + +Returns true if the shared block with the given ID is being fetched, or +false otherwise. + +*Parameters* + + * state: Global application state. + * ref: The shared block's ID. + +*Returns* + +Whether the shared block is being fetched. + +### getSharedBlocks + +Returns an array of all shared blocks. + +*Parameters* + + * state: Global application state. + +*Returns* + +An array of all shared blocks. + +### getStateBeforeOptimisticTransaction + +Returns state object prior to a specified optimist transaction ID, or `null` +if the transaction corresponding to the given ID cannot be found. + +*Parameters* + + * state: Current global application state. + * transactionId: Optimist transaction ID. + +*Returns* + +Global application state prior to transaction. + +### isPublishingPost + +Returns true if the post is being published, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether post is being published. + +### getProvisionalBlockUID + +Returns the provisional block UID, or null if there is no provisional block. + +*Parameters* + + * state: Editor state. + +*Returns* + +Provisional block UID, if set. + +### isPermalinkEditable + +Returns whether the permalink is editable or not. + +*Parameters* + + * state: Editor state. + +*Returns* + +Whether or not the permalink is editable. + +### getPermalink + +Returns the permalink for the post. + +*Parameters* + + * state: Editor state. + +*Returns* + +The permalink. + +### getPermalinkParts + +Returns the permalink for a post, split into it's three parts: the prefix, the postName, and the suffix. + +*Parameters* + + * state: Editor state. + +*Returns* + +The prefix, postName, and suffix for the permalink. + +### inSomeHistory + +Returns true if an optimistic transaction is pending commit, for which the +before state satisfies the given predicate function. + +*Parameters* + + * state: Editor state. + * predicate: Function given state, returning true if match. + +*Returns* + +Whether predicate matches for some history. + +### getBlockListSettings + +Returns the Block List settings of a block if any. + +*Parameters* + + * state: Editor state. + * uid: Block UID. + +*Returns* + +Block settings of the block if set. + +### getSupportedBlocks + +eslint-disable-next-line no-unused-vars + +### getEditorSettings + +Returns the editor settings. + +*Parameters* + + * state: Editor state. + +*Returns* + +The editor settings object + +## Actions + +### setupEditor + +Returns an action object used in signalling that editor has initialized with +the specified post object and editor settings. + +*Parameters* + + * post: Post object. + * autosaveStatus: The Post's autosave status. + +### resetPost + +Returns an action object used in signalling that the latest version of the +post has been received, either by initialization or save. + +*Parameters* + + * post: Post object. + +### resetAutosave + +Returns an action object used in signalling that the latest autosave of the +post has been received, by initialization or autosave. + +*Parameters* + + * post: Autosave post object. + +### setupEditorState + +Returns an action object used to setup the editor state when first opening an editor. + +*Parameters* + + * post: Post object. + * blocks: Array of blocks. + * edits: Initial edited attributes object. + +### 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 UID has been updated. + +*Parameters* + + * uid: Block UID. + * attributes: Block attributes to be merged. + +### updateBlock + +Returns an action object used in signalling that the block with the +specified UID has been updated. + +*Parameters* + + * uid: Block UID. + * updates: Block attributes to be merged. + +### 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* + + * uids: Block UID(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* + + * uid: Block UID(s) to replace. + * block: Replacement block(s). + +### moveBlockToPosition + +Returns an action object signalling that an indexed block should be moved +to a new index. + +*Parameters* + + * uid: The UID of the block. + * fromRootUID: root UID source. + * toRootUID: root UID destination. + * layout: layout to move the block into. + * 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. + * rootUID: Optional root UID of block list to insert. + +### 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. + * rootUID: Optional root UID of block list to insert. + +### showInsertionPoint + +Returns an action object used in signalling that the insertion point should +be shown. + +### hideInsertionPoint + +Returns an action object hiding the insertion point. + +### setTemplateValidity + +Returns an action object resetting the template validity. + +*Parameters* + + * isValid: template validity flag. + +### checkTemplateValidity + +Returns an action object to check the template validity. + +### synchronizeTemplate + +Returns an action object synchronize the template with the list of blocks + +### savePost + +Returns an action object to save the post. + +*Parameters* + + * options: Options for the save. + * options.autosave: Perform an autosave if true. + +### mergeBlocks + +Returns an action object used in signalling that two blocks should be merged + +*Parameters* + + * blockAUid: UID of the first block to merge. + * blockBUid: UID of the second block to merge. + +### autosave + +Returns an action object used in signalling that the post should autosave. + +### redo + +Returns an action object used in signalling that undo history should +restore last popped state. + +### undo + +Returns an action object used in signalling that undo history should pop. + +### createUndoLevel + +Returns an action object used in signalling that undo history record should +be created. + +### removeBlocks + +Returns an action object used in signalling that the blocks +corresponding to the specified UID set are to be removed. + +*Parameters* + + * uids: Block UIDs. + * 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 UID is to be removed. + +*Parameters* + + * uid: Block UID. + * 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 (visual/html). + +*Parameters* + + * uid: Block UID. + +### 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. + +### createNotice + +Returns an action object used to create a notice. + +*Parameters* + + * status: The notice status. + * content: The notice content. + * options: The notice options. Available options: + `id` (string; default auto-generated) + `isDismissible` (boolean; default `true`). + +### removeNotice + +Returns an action object used to remove a notice. + +*Parameters* + + * id: The notice id. + +### fetchSharedBlocks + +Returns an action object used to fetch a single shared block or all shared +blocks from the REST API into the store. + +*Parameters* + + * id: If given, only a single shared block with this ID will + be fetched. + +### receiveSharedBlocks + +Returns an action object used in signalling that shared blocks have been +received. `results` is an array of objects containing: + - `sharedBlock` - Details about how the shared block is persisted. + - `parsedBlock` - The original block. + +*Parameters* + + * results: Shared blocks received. + +### saveSharedBlock + +Returns an action object used to save a shared block that's in the store to +the REST API. + +*Parameters* + + * id: The ID of the shared block to save. + +### deleteSharedBlock + +Returns an action object used to delete a shared block via the REST API. + +*Parameters* + + * id: The ID of the shared block to delete. + +### updateSharedBlockTitle + +Returns an action object used in signalling that a shared block's title is +to be updated. + +*Parameters* + + * id: The ID of the shared block to update. + * title: The new title. + +### convertBlockToStatic + +Returns an action object used to convert a shared block into a static block. + +*Parameters* + + * uid: The ID of the block to attach. + +### convertBlockToShared + +Returns an action object used to convert a static block into a shared block. + +*Parameters* + + * uid: The ID 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. + * rootUID: Optional root UID of block list 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* + + * id: UID of the block whose nested setting. + * 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 diff --git a/docs/data/core-nux.md b/docs/data/core-nux.md new file mode 100644 index 0000000000000..7da5192f596ad --- /dev/null +++ b/docs/data/core-nux.md @@ -0,0 +1,39 @@ +# **core/nux**: The NUX module Data + +## Selectors + +### isTipVisible + +Determines whether or not the given tip is showing. Tips are hidden if they +are disabled, have been dismissed, or are not the current tip in any +guide that they have been added to. + +*Parameters* + + * state: Global application state. + * id: The tip to query. + +## Actions + +### triggerGuide + +Returns an action object that, when dispatched, presents a guide that takes +the user through a series of tips step by step. + +*Parameters* + + * tipIds: Which tips to show in the guide. + +### dismissTip + +Returns an action object that, when dispatched, dismisses the given tip. A +dismissed tip will not show again. + +*Parameters* + + * id: The tip to dismiss. + +### disableTips + +Returns an action object that, when dispatched, prevents all tips from +showing again. \ No newline at end of file diff --git a/docs/data/core-viewport.md b/docs/data/core-viewport.md new file mode 100644 index 0000000000000..32e804dcda70e --- /dev/null +++ b/docs/data/core-viewport.md @@ -0,0 +1,25 @@ +# **core/viewport**: The viewport module Data + +## Selectors + +### isViewportMatch + +Returns true if the viewport matches the given query, or false otherwise. + +*Parameters* + + * state: Viewport state object. + * query: Query string. Includes operator and breakpoint name, + space separated. Operator defaults to >=. + +## Actions + +### setIsMatching + +Returns an action object used in signalling that viewport queries have been +updated. Values are specified as an object of breakpoint query keys where +value represents whether query matches. + +*Parameters* + + * values: Breakpoint query matches. \ No newline at end of file diff --git a/docs/data/core.md b/docs/data/core.md new file mode 100644 index 0000000000000..433923d409088 --- /dev/null +++ b/docs/data/core.md @@ -0,0 +1,164 @@ +# **core**: WordPress Core Data + +## Selectors + +### getTerms + +Returns all the available terms for the given taxonomy. + +*Parameters* + + * state: Data state. + * taxonomy: Taxonomy name. + +### getCategories + +Returns all the available categories. + +*Parameters* + + * state: Data state. + +*Returns* + +Categories list. + +### isRequestingTerms + +Returns true if a request is in progress for terms data of a given taxonomy, +or false otherwise. + +*Parameters* + + * state: Data state. + * taxonomy: Taxonomy name. + +*Returns* + +Whether a request is in progress for taxonomy's terms. + +### isRequestingCategories + +Returns true if a request is in progress for categories data, or false +otherwise. + +*Parameters* + + * state: Data state. + +*Returns* + +Whether a request is in progress for categories. + +### getAuthors + +Returns all available authors. + +*Parameters* + + * state: Data state. + +*Returns* + +Authors list. + +### getEntitiesByKind + +Returns whether the entities for the give kind are loaded. + +*Parameters* + + * state: Data state. + * kind: Entity kind. + +*Returns* + +Whether the entities are loaded + +### getEntity + +Returns the entity object given its kind and name. + +*Parameters* + + * state: Data state. + * kind: Entity kind. + * name: Entity name. + +*Returns* + +Entity + +### getEntityRecord + +Returns the Entity's record object by key. + +*Parameters* + + * state: State tree + * kind: Entity kind. + * name: Entity name. + * key: Record's key + +*Returns* + +Record. + +### getThemeSupports + +Return theme suports data in the index. + +*Parameters* + + * state: Data state. + +*Returns* + +Index data. + +## Actions + +### receiveTerms + +Returns an action object used in signalling that terms have been received +for a given taxonomy. + +*Parameters* + + * taxonomy: Taxonomy name. + * terms: Terms received. + +### receiveUserQuery + +Returns an action object used in signalling that authors have been received. + +*Parameters* + + * queryID: Query ID. + * users: Users received. + +### addEntities + +Returns an action object used in adding new entities. + +*Parameters* + + * entities: Entities received. + +### receiveEntityRecords + +Returns an action object used in signalling that entity records have been received. + +*Parameters* + + * kind: Kind of the received entity. + * name: Name of the received entity. + * records: Records received. + +### receiveThemeSupportsFromIndex + +Returns an action object used in signalling that the index has been received. + +*Parameters* + + * index: Index received. \ No newline at end of file diff --git a/docs/data/index.md b/docs/data/index.md new file mode 100644 index 0000000000000..acd84a70f4e42 --- /dev/null +++ b/docs/data/index.md @@ -0,0 +1,8 @@ +# Data Module Reference + + - [**core**: WordPress Core Data](./core.md) + - [**core/blocks**: Block Types Data](./core-blocks.md) + - [**core/editor**: The Editor's Data](./core-editor.md) + - [**core/edit-post**: The Editor's UI Data](./core-edit-post.md) + - [**core/viewport**: The viewport module Data](./core-viewport.md) + - [**core/nux**: The NUX module Data](./core-nux.md) \ No newline at end of file diff --git a/docs/data/tool/config.js b/docs/data/tool/config.js new file mode 100644 index 0000000000000..ef755ea4ebd11 --- /dev/null +++ b/docs/data/tool/config.js @@ -0,0 +1,44 @@ +/** + * Node dependencies + */ +const path = require( 'path' ); + +const root = path.resolve( __dirname, '../../../' ); + +module.exports = { + namespaces: { + core: { + title: 'WordPress Core Data', + // TODO: Figure out a way to generate docs for dynamic actions/selectors + selectors: [ path.resolve( root, 'packages/core-data/src/selectors.js' ) ], + actions: [ path.resolve( root, 'packages/core-data/src/actions.js' ) ], + }, + 'core/blocks': { + title: 'Block Types Data', + selectors: [ path.resolve( root, 'blocks/store/selectors.js' ) ], + actions: [ path.resolve( root, 'blocks/store/actions.js' ) ], + }, + 'core/editor': { + title: 'The Editor\'s Data', + selectors: [ path.resolve( root, 'editor/store/selectors.js' ) ], + actions: [ path.resolve( root, 'editor/store/actions.js' ) ], + }, + 'core/edit-post': { + title: 'The Editor\'s UI Data', + selectors: [ path.resolve( root, 'edit-post/store/selectors.js' ) ], + actions: [ path.resolve( root, 'edit-post/store/actions.js' ) ], + }, + 'core/viewport': { + title: 'The viewport module Data', + selectors: [ path.resolve( root, 'viewport/store/selectors.js' ) ], + actions: [ path.resolve( root, 'viewport/store/actions.js' ) ], + }, + 'core/nux': { + title: 'The NUX (New User Experience) module Data', + selectors: [ path.resolve( root, 'nux/store/selectors.js' ) ], + actions: [ path.resolve( root, 'nux/store/actions.js' ) ], + }, + }, + + output: path.resolve( __dirname, '../' ), +}; diff --git a/docs/data/tool/generator.js b/docs/data/tool/generator.js new file mode 100644 index 0000000000000..63869c67872e2 --- /dev/null +++ b/docs/data/tool/generator.js @@ -0,0 +1,94 @@ +/** + * Node dependencies + */ +const path = require( 'path' ); +const fs = require( 'fs' ); +const lodash = require( 'lodash' ); + +/** + * Generates the table of contents' markdown. + * + * @param {Object} parsedNamespaces Parsed Namespace Object + * + * @return {string} Markdown string + */ +function generateTableOfContent( parsedNamespaces ) { + return [ + '# Data Module Reference', + '', + Object.values( parsedNamespaces ).map( ( parsedNamespace ) => { + return ` - [**${ parsedNamespace.name }**: ${ parsedNamespace.title }](./${ lodash.kebabCase( parsedNamespace.name ) }.md)`; + } ).join( '\n' ), + ].join( '\n' ); +} + +/** + * Generates the table of contents' markdown. + * + * @param {Object} parsedFunc Parsed Function + * @param {boolean} generateDocsForReturn Whether to generate docs for the return value. + * + * @return {string} Markdown string + */ +function generateFunctionDocs( parsedFunc, generateDocsForReturn = true ) { + return [ + `### ${ parsedFunc.name }`, + parsedFunc.description ? [ + '', + parsedFunc.description, + ].join( '\n' ) : null, + parsedFunc.params.length ? [ + '', + '*Parameters*', + '', + parsedFunc.params.map( ( param ) => ( + ` * ${ param.name }: ${ param.description }` + ) ).join( '\n' ), + ].join( '\n' ) : null, + parsedFunc.return && generateDocsForReturn ? [ + '', + '*Returns*', + '', + parsedFunc.return.description, + ].join( '\n' ) : null, + ].filter( ( row ) => row !== null ).join( '\n' ); +} + +/** + * Generates the namespace selectors/actions markdown. + * + * @param {Object} parsedNamespace Parsed Namespace + * + * @return {string} Markdown string + */ +function generateNamespaceDocs( parsedNamespace ) { + return [ + `# **${ parsedNamespace.name }**: ${ parsedNamespace.title }`, + '', + '## Selectors ', + '', + ( parsedNamespace.selectors.map( generateFunctionDocs ) ).join( '\n\n' ), + '', + '## Actions', + '', + parsedNamespace.actions.map( + ( action ) => generateFunctionDocs( action, false ) + ).join( '\n\n' ), + ].join( '\n' ); +} + +module.exports = function( parsedNamespaces, rootFolder ) { + const tableOfContent = generateTableOfContent( parsedNamespaces ); + fs.writeFileSync( + path.join( rootFolder, 'index.md' ), + tableOfContent + ); + + Object.values( parsedNamespaces ).forEach( ( parsedNamespace ) => { + const namespaceDocs = generateNamespaceDocs( parsedNamespace ); + fs.writeFileSync( + path.join( rootFolder, lodash.kebabCase( parsedNamespace.name ) + '.md' ), + namespaceDocs + ); + } ); +}; diff --git a/docs/data/tool/index.js b/docs/data/tool/index.js new file mode 100644 index 0000000000000..7fd22fbb77024 --- /dev/null +++ b/docs/data/tool/index.js @@ -0,0 +1,9 @@ +/** + * Internal dependencies + */ +const config = require( './config' ); +const parser = require( './parser' ); +const generator = require( './generator' ); + +const parsedModules = parser( config.namespaces ); +generator( parsedModules, config.output ); diff --git a/docs/data/tool/parser.js b/docs/data/tool/parser.js new file mode 100644 index 0000000000000..ab719d8f4d3dd --- /dev/null +++ b/docs/data/tool/parser.js @@ -0,0 +1,61 @@ +/** + * Node dependencies + */ +const fs = require( 'fs' ); + +/** + * External dependencies + */ +const lodash = require( 'lodash' ); +const espree = require( 'espree' ); +const doctrine = require( 'doctrine' ); + +module.exports = function( config ) { + const result = {}; + Object.entries( config ).forEach( ( [ namespace, namespaceConfig ] ) => { + const namespaceResult = { + name: namespace, + title: namespaceConfig.title, + selectors: [], + actions: [], + }; + + [ 'selectors', 'actions' ].forEach( ( type ) => { + namespaceConfig[ type ].forEach( ( file ) => { + const code = fs.readFileSync( file, 'utf8' ); + const parsedCode = espree.parse( code, { + attachComment: true, + // This should ideally match our babel config, but espree doesn't support it. + ecmaVersion: 9, + sourceType: 'module', + } ); + + parsedCode.body.forEach( ( node ) => { + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration.type === 'FunctionDeclaration' && + node.leadingComments && + node.leadingComments.length !== 0 + ) { + const docBlock = doctrine.parse( + lodash.last( node.leadingComments ).value, + { unwrap: true, recoverable: true } + ); + const func = { + name: node.declaration.id.name, + description: docBlock.description, + params: docBlock.tags.filter( ( tag ) => tag.title === 'param' ), + return: docBlock.tags.find( ( tag ) => tag.title === 'return' ), + }; + + namespaceResult[ type ].push( func ); + } + } ); + } ); + } ); + + result[ namespace ] = namespaceResult; + } ); + + return result; +}; diff --git a/docs/extensibility.md b/docs/extensibility.md index 9e3a33ebda8ea..561eb7f9f1694 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -37,7 +37,7 @@ Learn more in the [Extending Blocks](../docs/extensibility/extending-blocks.md) Extending the editor UI can be accomplished with the `registerPlugin` API, allowing you to define all your plugin's UI elements in one place. -Refer to the [Plugins](https://github.com/WordPress/gutenberg/blob/master/plugins/README.md) and [Edit Post](https://github.com/WordPress/gutenberg/blob/master/edit-post/README.md) section for more information. +Refer to the [Plugins](https://github.com/WordPress/gutenberg/blob/master/packages/plugins/README.md) and [Edit Post](https://github.com/WordPress/gutenberg/blob/master/edit-post/README.md) section for more information. ## Meta Boxes diff --git a/docs/extensibility/autocomplete.md b/docs/extensibility/autocomplete.md index 914c35af5c229..ee1f2bc945852 100644 --- a/docs/extensibility/autocomplete.md +++ b/docs/extensibility/autocomplete.md @@ -1,13 +1,13 @@ Autocomplete ============ -Gutenberg provides a `blocks.Autocomplete.completers` filter for extending and overriding the list of autocompleters used by blocks. +Gutenberg provides a `editor.Autocomplete.completers` filter for extending and overriding the list of autocompleters used by blocks. The `Autocomplete` component found in `@wordpress/editor` applies this filter. The `@wordpress/components` package provides the foundational `Autocomplete` component that does not apply such a filter, but blocks should generally use the component provided by `@wordpress/editor`. ### Example -Here is an example of using the `blocks.Autocomplete.completers` filter to add an acronym completer. You can find full documentation for the autocompleter interface with the `Autocomplete` component in the `@wordpress/components` package. +Here is an example of using the `editor.Autocomplete.completers` filter to add an acronym completer. You can find full documentation for the autocompleter interface with the `Autocomplete` component in the `@wordpress/components` package. {% codetabs %} {% ES5 %} @@ -46,7 +46,7 @@ function appendAcronymCompleter( completers, blockName ) { // Adding the filter wp.hooks.addFilter( - 'blocks.Autocomplete.completers', + 'editor.Autocomplete.completers', 'my-plugin/autocompleters/acronyms', appendAcronymCompleter ); @@ -81,7 +81,7 @@ function appendAcronymCompleter( completers, blockName ) { // Adding the filter wp.hooks.addFilter( - 'blocks.Autocomplete.completers', + 'editor.Autocomplete.completers', 'my-plugin/autocompleters/acronym', appendAcronymCompleter ); diff --git a/docs/extensibility/extending-blocks.md b/docs/extensibility/extending-blocks.md index c0579171bf3af..8447aaed5cb7e 100644 --- a/docs/extensibility/extending-blocks.md +++ b/docs/extensibility/extending-blocks.md @@ -10,10 +10,6 @@ To modify the behavior of existing blocks, Gutenberg exposes the following Filte Used to filter the block settings. It receives the block settings and the name of the block the registered block as arguments. -#### `blocks.BlockEdit` - -Used to modify the block's `edit` component. It receives the original block `edit` component and returns a new wrapped component. - #### `blocks.getSaveElement` A filter that applies to the result of a block's `save` function. This filter is used to replace or extend the element, for example using `wp.element.cloneElement` to modify the element's props or replace its children, or returning an entirely new element. @@ -70,6 +66,75 @@ Used internally by the default block (paragraph) to exclude the attributes from Used to filters an individual transform result from block transformation. All of the original blocks are passed, since transformations are many-to-many, not one-to-one. +#### `editor.BlockEdit` + +Used to modify the block's `edit` component. It receives the original block `BlockEdit` component and returns a new wrapped component. + +_Example:_ + +```js +var el = wp.element.createElement; + +var withInspectorControls = wp.element.createHigherOrderComponent( function( BlockEdit ) { + return function( props ) { + return el( + wp.element.Fragment, + {}, + el( + BlockEdit, + props + ), + el( + wp.editor.InspectorControls, + {}, + el( + wp.components.PanelBody, + {}, + 'My custom control' + ) + ) + ); + }; +}, 'withInspectorControls' ); + +wp.hooks.addFilter( 'editor.BlockEdit', 'my-plugin/with-inspector-controls', withInspectorControls ); +``` + +#### `editor.BlockListBlock` + +Used to modify the block's wrapper component containing the block's `edit` component and all toolbars. It receives the original `BlockListBlock` component and returns a new wrapped component. + +_Example:_ + +```js +var el = wp.element.createElement; + +var withDataAlign = wp.element.createHigherOrderComponent( function( BlockListBlock ) { + return function( props ) { + var newProps = Object.assign( + {}, + props, + { + wrapperProps: Object.assign( + {}, + props.wrapperProps, + { + 'data-align': props.block.attributes.align + } + ) + } + ); + + return el( + BlockListBlock, + newProps + ); + }; +}, 'withAlign' ); + +wp.hooks.addFilter( 'editor.BlockListBlock', 'my-plugin/with-data-align', withDataAlign ); +``` + ## Removing Blocks ### Using a blacklist diff --git a/docs/reference/deprecated.md b/docs/reference/deprecated.md index 3d142ab420c87..d93eab0a96c84 100644 --- a/docs/reference/deprecated.md +++ b/docs/reference/deprecated.md @@ -1,16 +1,22 @@ Gutenberg's deprecation policy is intended to support backwards-compatibility for two minor releases, when possible. The current deprecations are listed below and are grouped by _the version at which they will be removed completely_. If your plugin depends on these behaviors, you must update to the recommended alternative before the noted version. ## 3.3.0 + - `useOnce: true` has been removed from the Block API. Please use `supports.multiple: false` instead. + - Serializing components using `componentWillMount` lifecycle method. Please use the constructor instead. + - `blocks.Autocomplete.completers` filter removed. Please use `editor.Autocomplete.completers` instead. + - `blocks.BlockEdit` filter removed. Please use `editor.BlockEdit` instead. + - `blocks.BlockListBlock` filter removed. Please use `editor.BlockListBlock` instead. + - `blocks.MediaUpload` filter removed. Please use `editor.MediaUpload` instead. ## 3.2.0 - `wp.data.withRehydratation` has been renamed to `wp.data.withRehydration`. - The `wp.editor.ImagePlaceholder` component is removed. Please use `wp.editor.MediaPlaceholder` instead. - `wp.utils.deprecated` function removed. Please use `wp.deprecated` instead. -- `getInserterItems`: the `allowedBlockTypes` argument was removed and the `parentUID` argument was added. -- `getFrecentInserterItems` selector removed. Please use `getInserterItems` instead. -- `getSupportedBlocks` selector removed. Please use `canInsertBlockType` instead. + - `getInserterItems`: the `allowedBlockTypes` argument was removed and the `parentUID` argument was added. + - `getFrecentInserterItems` selector removed. Please use `getInserterItems` instead. + - `getSupportedBlocks` selector removed. Please use `canInsertBlockType` instead. ## 3.1.0 @@ -18,22 +24,23 @@ Gutenberg's deprecation policy is intended to support backwards-compatibility fo - `wp.blocks.withEditorSettings` is removed. Please use the data module to access the editor settings `wp.data.select( "core/editor" ).getEditorSettings()`. - All DOM utils in `wp.utils.*` are removed. Please use `wp.dom.*` instead. - `isPrivate: true` has been removed from the Block API. Please use `supports.inserter: false` instead. - - `wp.utils.isExtraSmall` function removed. Please use `wp.viewport.isExtraSmall` instead. + - `wp.utils.isExtraSmall` function removed. Please use `wp.viewport` module instead. + - `getEditedPostExcerpt` selector removed (`core/editor`). Use `getEditedPostAttribute( 'excerpt' )` instead. ## 3.0.0 -- `wp.blocks.registerCoreBlocks` function removed. Please use `wp.coreBlocks.registerCoreBlocks` instead. -- Raw TinyMCE event handlers for `RichText` have been deprecated. Please use [documented props](https://wordpress.org/gutenberg/handbook/block-api/rich-text-api/), ancestor event handler, or onSetup access to the internal editor instance event hub instead. + - `wp.blocks.registerCoreBlocks` function removed. Please use `wp.coreBlocks.registerCoreBlocks` instead. + - Raw TinyMCE event handlers for `RichText` have been deprecated. Please use [documented props](https://wordpress.org/gutenberg/handbook/block-api/rich-text-api/), ancestor event handler, or onSetup access to the internal editor instance event hub instead. ## 2.8.0 -- `Original autocompleter interface in wp.components.Autocomplete` updated. Please use `latest autocompleter interface` instead. See: https://github.com/WordPress/gutenberg/blob/master/components/autocomplete/README.md. -- `getInserterItems`: the `allowedBlockTypes` argument is now mandatory. -- `getFrecentInserterItems`: the `allowedBlockTypes` argument is now mandatory. + - `Original autocompleter interface in wp.components.Autocomplete` updated. Please use `latest autocompleter interface` instead. See: https://github.com/WordPress/gutenberg/blob/master/components/autocomplete/README.md. + - `getInserterItems`: the `allowedBlockTypes` argument is now mandatory. + - `getFrecentInserterItems`: the `allowedBlockTypes` argument is now mandatory. ## 2.7.0 -- `wp.element.getWrapperDisplayName` function removed. Please use `wp.element.createHigherOrderComponent` instead. + - `wp.element.getWrapperDisplayName` function removed. Please use `wp.element.createHigherOrderComponent` instead. ## 2.6.0 diff --git a/edit-post/README.md b/edit-post/README.md index da5d35aecc41b..af78b310c4fd7 100644 --- a/edit-post/README.md +++ b/edit-post/README.md @@ -6,7 +6,7 @@ Refer to [the plugins module documentation](../plugins/) for more information. ## Plugin Components -The following components can be used with the `registerPlugin` ([see documentation](../plugins)) API. +The following components can be used with the `registerPlugin` ([see documentation](../packages/plugins)) API. They can be found in the global variable `wp.editPost` when defining `wp-edit-post` as a script dependency. ### `PluginSidebar` diff --git a/edit-post/assets/stylesheets/_animations.scss b/edit-post/assets/stylesheets/_animations.scss index b3068ec3bc342..0ebdbf228adef 100644 --- a/edit-post/assets/stylesheets/_animations.scss +++ b/edit-post/assets/stylesheets/_animations.scss @@ -30,3 +30,17 @@ animation: fade-in $speed ease-out; animation-fill-mode: forwards; } + +@keyframes editor_region_focus { + from { + box-shadow: inset 0 0 0 0 $blue-medium-400; + } + to { + box-shadow: inset 0 0 0 4px $blue-medium-400; + } +} + +@mixin region_focus( $speed: 0.2s ) { + animation: editor_region_focus $speed ease-out; + animation-fill-mode: forwards; +} \ No newline at end of file diff --git a/edit-post/assets/stylesheets/_variables.scss b/edit-post/assets/stylesheets/_variables.scss index a8e69e19f88c0..c8a14b37c19d0 100644 --- a/edit-post/assets/stylesheets/_variables.scss +++ b/edit-post/assets/stylesheets/_variables.scss @@ -52,6 +52,8 @@ $block-side-ui-clearance: 2px; // space between side UI and block $block-side-ui-padding: $block-side-ui-width + $block-side-ui-clearance; // total space used to accommodate side UI $block-parent-side-ui-clearance: $parent-block-padding - $block-padding; // space between side UI and block on top level blocks +$block-container-side-padding: $block-side-ui-width + $block-padding + 2 * $block-side-ui-clearance; + // Buttons & UI Widgets $button-style__radius-roundrect: 4px; $button-style__radius-round: 50%; diff --git a/edit-post/components/browser-url/index.js b/edit-post/components/browser-url/index.js index b3973dd88070d..7d50e89772540 100644 --- a/edit-post/components/browser-url/index.js +++ b/edit-post/components/browser-url/index.js @@ -16,6 +16,22 @@ export function getPostEditURL( postId ) { return addQueryArgs( 'post.php', { post: postId, action: 'edit' } ); } +/** + * Returns the Post's Trashedd URL. + * + * @param {number} postId Post ID. + * @param {string} postType Post Type. + * + * @return {string} Post trashed URL. + */ +export function getPostTrashedURL( postId, postType ) { + return addQueryArgs( 'edit.php', { + trashed: 1, + post_type: postType, + ids: postId, + } ); +} + export class BrowserURL extends Component { constructor() { super( ...arguments ); @@ -26,17 +42,29 @@ export class BrowserURL extends Component { } componentDidUpdate( prevProps ) { - const { postId, postStatus } = this.props; + const { postId, postStatus, postType } = this.props; const { historyId } = this.state; - if ( postId === prevProps.postId && postId === historyId ) { + + if ( postStatus === 'trash' ) { + this.setTrashURL( postId, postType ); return; } - if ( postStatus !== 'auto-draft' ) { + if ( ( postId !== prevProps.postId || postId !== historyId ) && postStatus !== 'auto-draft' ) { this.setBrowserURL( postId ); } } + /** + * Navigates the browser to the post trashed URL to show a notice about the trashed post. + * + * @param {number} postId Post ID. + * @param {string} postType Post Type. + */ + setTrashURL( postId, postType ) { + window.location.href = getPostTrashedURL( postId, postType ); + } + /** * Replaces the browser URL with a post editor link for the given post ID. * @@ -65,10 +93,11 @@ export class BrowserURL extends Component { export default withSelect( ( select ) => { const { getCurrentPost } = select( 'core/editor' ); - const { id, status } = getCurrentPost(); + const { id, status, type } = getCurrentPost(); return { postId: id, postStatus: status, + postType: type, }; } )( BrowserURL ); diff --git a/edit-post/components/browser-url/test/index.js b/edit-post/components/browser-url/test/index.js index 953c2dac92393..a669896f5ca03 100644 --- a/edit-post/components/browser-url/test/index.js +++ b/edit-post/components/browser-url/test/index.js @@ -6,7 +6,7 @@ import { shallow } from 'enzyme'; /** * Internal dependencies */ -import { getPostEditURL, BrowserURL } from '../'; +import { getPostEditURL, getPostTrashedURL, BrowserURL } from '../'; describe( 'getPostEditURL', () => { it( 'should generate relative path with post and action arguments', () => { @@ -16,6 +16,14 @@ describe( 'getPostEditURL', () => { } ); } ); +describe( 'getPostTrashedURL', () => { + it( 'should generate relative path with post and action arguments', () => { + const url = getPostTrashedURL( 1, 'page' ); + + expect( url ).toBe( 'edit.php?trashed=1&post_type=page&ids=1' ); + } ); +} ); + describe( 'BrowserURL', () => { let replaceStateSpy; diff --git a/edit-post/components/header/fixed-toolbar-toggle/index.js b/edit-post/components/header/fixed-toolbar-toggle/index.js index 5561dc7eb99d8..e20ee631c0b65 100644 --- a/edit-post/components/header/fixed-toolbar-toggle/index.js +++ b/edit-post/components/header/fixed-toolbar-toggle/index.js @@ -8,23 +8,18 @@ import { withSelect, withDispatch } from '@wordpress/data'; */ import { __ } from '@wordpress/i18n'; import { compose } from '@wordpress/element'; -import { MenuGroup, MenuItem, withInstanceId } from '@wordpress/components'; +import { MenuItem } from '@wordpress/components'; import { ifViewportMatches } from '@wordpress/viewport'; -function FeatureToggle( { onToggle, isActive } ) { +function FixedToolbarToggle( { onToggle, isActive } ) { return ( - - - { __( 'Fix Toolbar to Top' ) } - - + { __( 'Fix Toolbar to Top' ) } + ); } @@ -39,5 +34,4 @@ export default compose( [ }, } ) ), ifViewportMatches( 'medium' ), - withInstanceId, -] )( FeatureToggle ); +] )( FixedToolbarToggle ); diff --git a/edit-post/components/header/more-menu/index.js b/edit-post/components/header/more-menu/index.js index d880252e977c1..4799004916f73 100644 --- a/edit-post/components/header/more-menu/index.js +++ b/edit-post/components/header/more-menu/index.js @@ -11,6 +11,7 @@ import './style.scss'; import ModeSwitcher from '../mode-switcher'; import FixedToolbarToggle from '../fixed-toolbar-toggle'; import PluginMoreMenuGroup from '../plugins-more-menu-group'; +import TipsToggle from '../tips-toggle'; const MoreMenu = () => ( ( renderContent={ ( { onClose } ) => (
    - + + + + + +
    +
    + + + + + + +
    +
    +
    + +`; diff --git a/edit-post/components/header/more-menu/test/index.js b/edit-post/components/header/more-menu/test/index.js new file mode 100644 index 0000000000000..83f9de19ba706 --- /dev/null +++ b/edit-post/components/header/more-menu/test/index.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import MoreMenu from '../index'; + +describe( 'MoreMenu', () => { + it( 'should match snapshot', () => { + const wrapper = mount( + + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/edit-post/components/header/tips-toggle/index.js b/edit-post/components/header/tips-toggle/index.js new file mode 100644 index 0000000000000..27b131cb04333 --- /dev/null +++ b/edit-post/components/header/tips-toggle/index.js @@ -0,0 +1,40 @@ +/** + * WordPress Dependencies + */ +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * WordPress Dependencies + */ +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/element'; +import { MenuItem } from '@wordpress/components'; + +function TipsToggle( { onToggle, isActive } ) { + return ( + + { __( 'Show Tips' ) } + + ); +} + +export default compose( [ + withSelect( ( select ) => ( { + isActive: select( 'core/nux' ).areTipsEnabled(), + } ) ), + withDispatch( ( dispatch, ownProps ) => ( { + onToggle() { + const { disableTips, enableTips } = dispatch( 'core/nux' ); + if ( ownProps.isActive ) { + disableTips(); + } else { + enableTips(); + } + ownProps.onToggle(); + }, + } ) ), +] )( TipsToggle ); diff --git a/edit-post/components/visual-editor/style.scss b/edit-post/components/visual-editor/style.scss index 75e9a154646a8..709b39468a323 100644 --- a/edit-post/components/visual-editor/style.scss +++ b/edit-post/components/visual-editor/style.scss @@ -50,8 +50,8 @@ } &[data-align="full"] > .editor-block-contextual-toolbar, - &[data-align="wide"] > .editor-block-contextual-toolbar { // don't affect nested block toolbars - max-width: $content-width + 2; // 1px border left and right + &[data-align="wide"] > .editor-block-contextual-toolbar { // Don't affect nested block toolbars. + max-width: $content-width + $parent-block-padding; // Add 1px border left and right. margin-left: auto; margin-right: auto; } @@ -69,8 +69,8 @@ // The base width of the title should match that of blocks even if it isn't a block .editor-post-title { @include break-small() { - padding-left: $block-side-ui-padding; - padding-right: $block-side-ui-padding; + padding-left: $block-container-side-padding; + padding-right: $block-container-side-padding; } } .edit-post-visual-editor .editor-post-title__block { @@ -78,13 +78,13 @@ margin-right: auto; max-width: $content-width; - // stack borders + // Stack borders. > div { - margin-left: -1px; - margin-right: -1px; + margin-left: 0; + margin-right: 0; } - // include space for side UI on desktops + // Include space for side UI on desktops. @include break-small() { > div { margin-left: -$block-side-ui-width; diff --git a/edit-post/hooks/blocks/index.js b/edit-post/hooks/components/index.js similarity index 73% rename from edit-post/hooks/blocks/index.js rename to edit-post/hooks/components/index.js index 35b318203784e..20f16a7461def 100644 --- a/edit-post/hooks/blocks/index.js +++ b/edit-post/hooks/components/index.js @@ -11,7 +11,7 @@ import MediaUpload from './media-upload'; const replaceMediaUpload = () => MediaUpload; addFilter( - 'blocks.MediaUpload', - 'core/edit-post/blocks/media-upload/replaceMediaUpload', + 'editor.MediaUpload', + 'core/edit-post/components/media-upload/replace-media-upload', replaceMediaUpload ); diff --git a/edit-post/hooks/blocks/media-upload/index.js b/edit-post/hooks/components/media-upload/index.js similarity index 100% rename from edit-post/hooks/blocks/media-upload/index.js rename to edit-post/hooks/components/media-upload/index.js diff --git a/edit-post/hooks/index.js b/edit-post/hooks/index.js index 91d683b62ae75..ef9c274541dae 100644 --- a/edit-post/hooks/index.js +++ b/edit-post/hooks/index.js @@ -1,6 +1,6 @@ /** * Internal dependencies */ -import './blocks'; +import './components'; import './more-menu'; import './validate-multiple-use'; diff --git a/edit-post/hooks/validate-multiple-use/index.js b/edit-post/hooks/validate-multiple-use/index.js index 388c03bce94f9..db5b848e3e931 100644 --- a/edit-post/hooks/validate-multiple-use/index.js +++ b/edit-post/hooks/validate-multiple-use/index.js @@ -125,7 +125,7 @@ function getOutboundType( blockName ) { } addFilter( - 'blocks.BlockEdit', - 'core/validation/multiple', + 'editor.BlockEdit', + 'core/edit-post/validate-multiple-use/with-multiple-validation', withMultipleValidation ); diff --git a/edit-post/index.js b/edit-post/index.js index f211b4649ec1a..cc5692681050c 100644 --- a/edit-post/index.js +++ b/edit-post/index.js @@ -50,15 +50,6 @@ export function reinitializeEditor( postType, postId, target, settings, override * @return {Object} Editor interface. */ export function initializeEditor( id, postType, postId, settings, overridePost ) { - if ( 'production' !== process.env.NODE_ENV ) { - // Remove with 3.0 release. - window.console.info( - '`isSelected` usage is no longer mandatory with `BlockControls`, `InspectorControls` and `RichText`. ' + - 'It is now handled by the editor internally to ensure that controls are visible only when block is selected. ' + - 'See updated docs: https://github.com/WordPress/gutenberg/blob/master/blocks/README.md#components.' - ); - } - const target = document.getElementById( id ); const reboot = reinitializeEditor.bind( null, postType, postId, target, settings, overridePost ); diff --git a/edit-post/store/effects.js b/edit-post/store/effects.js index 2143abc2018ce..018a854b0bcb7 100644 --- a/edit-post/store/effects.js +++ b/edit-post/store/effects.js @@ -53,12 +53,24 @@ const effects = { }, {} ); store.dispatch( setMetaBoxSavedData( dataPerLocation ) ); - // Saving metaboxes when saving posts - subscribe( onChangeListener( select( 'core/editor' ).isSavingPost, ( isSavingPost ) => { - if ( ! isSavingPost ) { + let wasSavingPost = select( 'core/editor' ).isSavingPost(); + let wasAutosavingPost = select( 'core/editor' ).isAutosavingPost(); + // Save metaboxes when performing a full save on the post. + subscribe( () => { + const isSavingPost = select( 'core/editor' ).isSavingPost(); + const isAutosavingPost = select( 'core/editor' ).isAutosavingPost(); + + // Save metaboxes on save completion when past save wasn't an autosave. + const shouldTriggerMetaboxesSave = wasSavingPost && ! wasAutosavingPost && ! isSavingPost && ! isAutosavingPost; + + // Save current state for next inspection. + wasSavingPost = isSavingPost; + wasAutosavingPost = isAutosavingPost; + + if ( shouldTriggerMetaboxesSave ) { store.dispatch( requestMetaBoxUpdates() ); } - } ) ); + } ); }, REQUEST_META_BOX_UPDATES( action, store ) { const state = store.getState(); diff --git a/editor/components/autocomplete/README.md b/editor/components/autocomplete/README.md index bf3b564d3cb8e..46fff1bd72b35 100644 --- a/editor/components/autocomplete/README.md +++ b/editor/components/autocomplete/README.md @@ -1,6 +1,6 @@ Autocomplete ============ -This is an Autocomplete component for use in block UI. It is based on `Autocomplete` from `@wordpress/components` and takes the same props. In addition, it passes its autocompleters through a `blocks.Autocomplete.completers` filter to give developers an opportunity to override or extend them. +This is an Autocomplete component for use in block UI. It is based on `Autocomplete` from `@wordpress/components` and takes the same props. In addition, it passes its autocompleters through a `editor.Autocomplete.completers` filter to give developers an opportunity to override or extend them. The autocompleter interface is documented with the original `Autocomplete` component in `@wordpress/components`. diff --git a/editor/components/autocomplete/index.js b/editor/components/autocomplete/index.js index ea9f37ea63938..1a9c66f26597c 100644 --- a/editor/components/autocomplete/index.js +++ b/editor/components/autocomplete/index.js @@ -78,10 +78,9 @@ export function withFilteredAutocompleters( Autocomplete ) { let nextCompleters = completers; const lastFilteredCompletersProp = nextCompleters; - // Todo: Rename filter - if ( hasFilter( 'blocks.Autocomplete.completers' ) ) { + if ( hasFilter( 'editor.Autocomplete.completers' ) ) { nextCompleters = applyFilters( - 'blocks.Autocomplete.completers', + 'editor.Autocomplete.completers', // Provide copies so filters may directly modify them. nextCompleters && nextCompleters.map( clone ), blockName, diff --git a/editor/components/autocomplete/test/index.js b/editor/components/autocomplete/test/index.js index 91b9d442e9532..bd6e217a27da4 100644 --- a/editor/components/autocomplete/test/index.js +++ b/editor/components/autocomplete/test/index.js @@ -23,7 +23,7 @@ describe( 'Autocomplete', () => { let wrapper = null; afterEach( () => { - removeFilter( 'blocks.Autocomplete.completers', 'test/autocompleters-hook' ); + removeFilter( 'editor.Autocomplete.completers', 'test/autocompleters-hook' ); if ( wrapper ) { wrapper.unmount(); @@ -34,7 +34,7 @@ describe( 'Autocomplete', () => { it( 'filters supplied completers when next focused', () => { const completersFilter = jest.fn(); addFilter( - 'blocks.Autocomplete.completers', + 'editor.Autocomplete.completers', 'test/autocompleters-hook', completersFilter ); @@ -55,7 +55,7 @@ describe( 'Autocomplete', () => { const completersFilter = jest.fn(); addFilter( - 'blocks.Autocomplete.completers', + 'editor.Autocomplete.completers', 'test/autocompleters-hook', completersFilter ); @@ -70,7 +70,7 @@ describe( 'Autocomplete', () => { it( 'provides copies of completers to filter', () => { const completersFilter = jest.fn(); addFilter( - 'blocks.Autocomplete.completers', + 'editor.Autocomplete.completers', 'test/autocompleters-hook', completersFilter ); @@ -91,7 +91,7 @@ describe( 'Autocomplete', () => { const expectedFilteredCompleters = [ {}, {} ]; const completersFilter = jest.fn( () => expectedFilteredCompleters ); addFilter( - 'blocks.Autocomplete.completers', + 'editor.Autocomplete.completers', 'test/autocompleters-hook', completersFilter ); diff --git a/editor/components/block-edit/context.js b/editor/components/block-edit/context.js index a1f413b94ad58..ee64a062f4433 100644 --- a/editor/components/block-edit/context.js +++ b/editor/components/block-edit/context.js @@ -13,6 +13,7 @@ const { Consumer, Provider } = createContext( { isSelected: false, focusedElement: null, setFocusedElement: noop, + uid: null, } ); export { Provider as BlockEditContextProvider }; diff --git a/editor/components/block-edit/edit.js b/editor/components/block-edit/edit.js index 9b20a0d286b88..4deaa389581be 100644 --- a/editor/components/block-edit/edit.js +++ b/editor/components/block-edit/edit.js @@ -36,4 +36,4 @@ export const Edit = ( props ) => { ); }; -export default withFilters( 'blocks.BlockEdit' )( Edit ); +export default withFilters( 'editor.BlockEdit' )( Edit ); diff --git a/editor/components/block-edit/index.js b/editor/components/block-edit/index.js index a0ab798ef575b..f309e93d47a87 100644 --- a/editor/components/block-edit/index.js +++ b/editor/components/block-edit/index.js @@ -8,7 +8,7 @@ import { noop, get } from 'lodash'; */ import { withSelect } from '@wordpress/data'; import { Component, compose } from '@wordpress/element'; -import { withContext, withAPIData } from '@wordpress/components'; +import { withAPIData } from '@wordpress/components'; /** * Internal dependencies @@ -27,15 +27,9 @@ export class BlockEdit extends Component { } getChildContext() { - const { - id: uid, - user, - createInnerBlockList, - } = this.props; + const { user } = this.props; return { - uid, - BlockList: createInnerBlockList( uid ), canUserUseUnfilteredHTML: get( user.data, [ 'capabilities', 'unfiltered_html', @@ -52,18 +46,13 @@ export class BlockEdit extends Component { } ); } - static getDerivedStateFromProps( { name, isSelected }, prevState ) { - if ( - name === prevState.name && - isSelected === prevState.isSelected - ) { - return null; - } + static getDerivedStateFromProps( props ) { + const { id, name, isSelected } = props; return { - ...prevState, name, isSelected, + uid: id, }; } @@ -77,8 +66,6 @@ export class BlockEdit extends Component { } BlockEdit.childContextTypes = { - uid: noop, - BlockList: noop, canUserUseUnfilteredHTML: noop, }; @@ -89,5 +76,4 @@ export default compose( [ withAPIData( ( { postType } ) => ( { user: `/wp/v2/users/me?post_type=${ postType }&context=edit`, } ) ), - withContext( 'createInnerBlockList' )(), ] )( BlockEdit ); diff --git a/editor/components/block-icon/index.js b/editor/components/block-icon/index.js index cb8cc122ef875..00b65d48c9784 100644 --- a/editor/components/block-icon/index.js +++ b/editor/components/block-icon/index.js @@ -1,10 +1,16 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ +import './style.scss'; import { Dashicon } from '@wordpress/components'; import { createElement, Component } from '@wordpress/element'; -export default function BlockIcon( { icon } ) { +function renderIcon( icon ) { if ( 'string' === typeof icon ) { return ; } else if ( 'function' === typeof icon ) { @@ -17,3 +23,21 @@ export default function BlockIcon( { icon } ) { return icon || null; } + +export default function BlockIcon( { icon, showColors = false, className } ) { + const renderedIcon = renderIcon( icon && icon.src ? icon.src : icon ); + if ( showColors ) { + return ( +
    + { renderedIcon } +
    + ); + } + return renderedIcon; +} diff --git a/editor/components/block-icon/style.scss b/editor/components/block-icon/style.scss new file mode 100644 index 0000000000000..900a9f77cee9a --- /dev/null +++ b/editor/components/block-icon/style.scss @@ -0,0 +1,10 @@ +.editor-block-icon__colors { + background: $light-gray-200; + border-radius: 4px; + display: flex; + height: $icon-button-size-small; + align-items: center; + margin-left: -2px; + margin-right: 10px; + padding: 0 8px; +} diff --git a/editor/components/block-inspector/index.js b/editor/components/block-inspector/index.js index ce2f7f42903ff..8352078ac773f 100644 --- a/editor/components/block-inspector/index.js +++ b/editor/components/block-inspector/index.js @@ -20,7 +20,7 @@ import BlockIcon from '../block-icon'; import InspectorControls from '../inspector-controls'; import InspectorAdvancedControls from '../inspector-advanced-controls'; -const BlockInspector = ( { selectedBlock, count } ) => { +const BlockInspector = ( { selectedBlock, blockType, count } ) => { if ( count > 1 ) { return { __( 'Coming Soon' ) }; } @@ -29,13 +29,9 @@ const BlockInspector = ( { selectedBlock, count } ) => { return { __( 'No block selected.' ) }; } - const blockType = getBlockType( selectedBlock.name ); - return [
    -
    - -
    +
    { blockType.title }
    { blockType.description }
    @@ -60,8 +56,11 @@ const BlockInspector = ( { selectedBlock, count } ) => { export default withSelect( ( select ) => { const { getSelectedBlock, getSelectedBlockCount } = select( 'core/editor' ); + const selectedBlock = getSelectedBlock(); + const blockType = selectedBlock && getBlockType( selectedBlock.name ); return { - selectedBlock: getSelectedBlock(), + selectedBlock, + blockType, count: getSelectedBlockCount(), }; } diff --git a/editor/components/block-list/block-html.js b/editor/components/block-list/block-html.js index 177449a5aa978..8a33cfa9f8f66 100644 --- a/editor/components/block-list/block-html.js +++ b/editor/components/block-list/block-html.js @@ -22,10 +22,10 @@ class BlockHTML extends Component { }; } - componentWillReceiveProps( nextProps ) { - if ( ! isEqual( nextProps.block.attributes, this.props.block.attributes ) ) { + componentDidUpdate( prevProps ) { + if ( ! isEqual( this.props.block.attributes, prevProps.block.attributes ) ) { this.setState( { - html: getBlockContent( nextProps.block ), + html: getBlockContent( this.props.block ), } ); } } diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js index 88059e8212490..fb03d7b7d0c4a 100644 --- a/editor/components/block-list/block.js +++ b/editor/components/block-list/block.js @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import { get, reduce, size, castArray, first, last, noop } from 'lodash'; +import { get, reduce, size, castArray, first, last } from 'lodash'; import tinymce from 'tinymce'; /** @@ -50,7 +50,6 @@ import IgnoreNestedEvents from './ignore-nested-events'; import InserterWithShortcuts from '../inserter-with-shortcuts'; import Inserter from '../inserter'; import withHoverAreas from './with-hover-areas'; -import { createInnerBlockList } from '../../utils/block-list'; const { BACKSPACE, DELETE, ENTER } = keycodes; @@ -75,7 +74,6 @@ export class BlockListBlock extends Component { this.onDragStart = this.onDragStart.bind( this ); this.onDragEnd = this.onDragEnd.bind( this ); this.selectOnOpen = this.selectOnOpen.bind( this ); - this.createInnerBlockList = this.createInnerBlockList.bind( this ); this.hadTouchStart = false; this.state = { @@ -85,38 +83,17 @@ export class BlockListBlock extends Component { }; } - createInnerBlockList( uid ) { - return createInnerBlockList( uid ); - } - - /** - * Provides context for descendent components for use in block rendering. - * - * @return {Object} Child context. - */ - getChildContext() { - // Blocks may render their own BlockEdit, in which case we must provide - // a mechanism for them to create their own InnerBlockList. BlockEdit - // is defined in `@wordpress/blocks`, so to avoid a circular dependency - // we inject this function via context. - return { - createInnerBlockList: this.createInnerBlockList, - }; - } - componentDidMount() { if ( this.props.isSelected ) { this.focusTabbable(); } } - componentWillReceiveProps( newProps ) { - if ( newProps.isTypingWithinBlock || newProps.isSelected ) { + componentDidUpdate( prevProps ) { + if ( this.props.isTypingWithinBlock || this.props.isSelected ) { this.hideHoverEffects(); } - } - componentDidUpdate( prevProps ) { if ( this.props.isSelected && ! prevProps.isSelected ) { this.focusTabbable(); } @@ -511,6 +488,7 @@ export class BlockListBlock extends Component { rootUID={ rootUID } layout={ layout } canShowInserter={ canShowInBetweenInserter } + onInsert={ this.hideHoverEffects } /> ) } { }; } ); -BlockListBlock.childContextTypes = { - createInnerBlockList: noop, -}; - export default compose( applyWithSelect, applyWithDispatch, diff --git a/editor/components/block-list/insertion-point.js b/editor/components/block-list/insertion-point.js index f205d6812dd68..960f3f8793f0b 100644 --- a/editor/components/block-list/insertion-point.js +++ b/editor/components/block-list/insertion-point.js @@ -27,6 +27,7 @@ class BlockInsertionPoint extends Component { onFocusInserter( event ) { // We stop propagation of the focus event to avoid selecting the current block // While we're trying to insert a new block + // We also attach this to onMouseDown, due to a difference in behavior in Firefox and Safari, where buttons don't receive focus: https://gist.github.com/cvrebert/68659d0333a578d75372 event.stopPropagation(); this.setState( { @@ -45,6 +46,9 @@ class BlockInsertionPoint extends Component { props.insertDefaultBlock( { layout }, rootUID, index ); props.startTyping(); this.onBlurInserter(); + if ( props.onInsert ) { + this.props.onInsert(); + } } render() { @@ -62,6 +66,7 @@ class BlockInsertionPoint extends Component { onClick={ this.onClick } label={ __( 'Insert block' ) } onFocus={ this.onFocusInserter } + onMouseDown={ this.onFocusInserter } onBlur={ this.onBlurInserter } />
    diff --git a/editor/components/block-list/style.scss b/editor/components/block-list/style.scss index 0b2c228fc836c..0bcaac46b62a4 100644 --- a/editor/components/block-list/style.scss +++ b/editor/components/block-list/style.scss @@ -104,11 +104,11 @@ } .editor-block-list__layout { - // make room in the main content column for the side UI - // the side UI uses negative margins to position itself so as to not affect the block width + // Make room in the main content column for the side UI. + // The side UI uses negative margins to position itself so as to not affect the block width. @include break-small() { - padding-left: $block-side-ui-padding; - padding-right: $block-side-ui-padding; + padding-left: $block-container-side-padding; + padding-right: $block-container-side-padding; } // Don't add side padding for nested blocks, and compensate for block padding @@ -136,6 +136,9 @@ padding-left: $block-padding; padding-right: $block-padding; + // Break long strings of text without spaces so they don't overflow the block. + overflow-wrap: break-word; + @include break-small() { // The block mover needs to stay inside the block to allow clicks when hovering the block padding-left: $block-padding + $block-side-ui-padding - $border-width; @@ -221,14 +224,19 @@ transition: outline .1s linear; pointer-events: none; - // show wider padding for top level blocks - right: -$parent-block-padding; - left: -$parent-block-padding; top: -$block-padding; bottom: -$block-padding; + right: -$block-padding; + left: -$block-padding; + + // Show wider padding for top level blocks. + @include break-small() { + right: -$parent-block-padding; + left: -$parent-block-padding; + } } - // show smaller padding for child blocks + // Show smaller padding for child blocks. .editor-block-list__block-edit:before { right: -$block-padding; left: -$block-padding; @@ -346,16 +354,24 @@ // Alignments &[data-align="left"], &[data-align="right"] { - // Without z-index, won't be clickable as "above" adjacent content + // Without z-index, won't be clickable as "above" adjacent content. z-index: z-index( '.editor-block-list__block {core/image aligned left or right}' ); width: 100%; - // When images are floated, the block itself should collapse to zero height + // When images are floated, the block itself should collapse to zero height. margin-bottom: 0; + height: 0; - // Hide block outline when an image is floated - &:before { - content: none; + // Hide block outline when an image is floated. + .editor-block-list__block-edit { + &:before { + content: none; + } + } + + // The padding collapses, but the outline is still 1px to compensate for. + .editor-block-contextual-toolbar { + margin-bottom: 1px; } } @@ -432,6 +448,7 @@ > .editor-block-mover { display: none; } + @include break-wide() { > .editor-block-mover { display: block; @@ -454,11 +471,11 @@ > .editor-block-list__breadcrumb { right: -$border-width; } - - // compensate for main container padding, subtract border + + // Compensate for main container padding and subtract border. @include break-small() { - margin-left: -$block-side-ui-padding + $border-width; - margin-right: -$block-side-ui-padding + $border-width; + margin-left: -$block-side-ui-width - $block-padding - $block-side-ui-clearance - $border-width; + margin-right: -$block-side-ui-width - $block-padding - $block-side-ui-clearance - $border-width; } > .editor-block-list__block-edit { @@ -470,7 +487,7 @@ margin-right: -$block-side-ui-padding - $block-padding; } - // this explicitly sets the width of the block, to override the fit-content from the image block + // This explicitly sets the width of the block, to override the fit-content from the image block. figure { width: 100%; } @@ -579,12 +596,20 @@ display: block; } } +} + + +/** + * Mobile unified toolbar. + */ + +.editor-block-list__block { // Show side UI inline below the block on mobile. .editor-block-list__block-mobile-toolbar { display: flex; flex-direction: row; - margin-top: $item-spacing + $block-toolbar-height; // Make room for the height of the block toolbar above. + margin-top: $item-spacing + $block-toolbar-height; // Make room for the height of the block toolbar above. margin-right: -$block-padding; margin-bottom: -$block-padding - $border-width; margin-left: -$block-padding; @@ -640,6 +665,12 @@ float: left; } } + + // sth + &[data-align="full"] .editor-block-list__block-mobile-toolbar { + margin-left: 0; + margin-right: 0; + } } @@ -682,11 +713,11 @@ height: $block-padding * 2; // Matches the whole empty space between two blocks justify-content: center; - // Show a clickable plus + // Show a clickable plus. .editor-block-list__insertion-point-button { margin-top: -4px; border-radius: 50%; - color: $dark-gray-100; + color: $blue-medium-focus; background: $white; height: $block-padding * 2 + 8px; width: $block-padding * 2 + 8px; @@ -694,29 +725,32 @@ &:not(:disabled):not([aria-disabled="true"]):hover { box-shadow: none; } - } - // Show a line indicator when hovering, but this is unclickable - &:before { - position: absolute; - top: calc( 50% - #{ $border-width } ); - height: 2px; - left: 0; - right: 0; - background: $dark-gray-100; - content: ''; - } - - // Hide both the line and button until hovered + // Hide both the button until hovered. opacity: 0; transition: opacity 0.1s linear 0.1s; - &:hover, &.is-visible { + &:hover, + &.is-visible { opacity: 1; } } +// Don't show the sibling inserter before the selected block. +.edit-post-layout:not( .has-fixed-toolbar ) { + // The child selector is necessary for this to work properly in nested contexts. + .is-selected > .editor-block-list__insertion-point > .editor-block-list__insertion-point-inserter { + opacity: 0; + pointer-events: none; + + &.is-visible { + opacity: 1; + pointer-events: auto; + } + } +} + .editor-block-list__block { > .editor-block-list__insertion-point { position: absolute; @@ -771,18 +805,26 @@ left: $block-padding; right: $block-padding; + @include break-small() { + top: -$border-width; + } + // Position the contextual toolbar above the block. @include break-mobile() { - position: sticky; + position: relative; + top: auto; bottom: auto; left: auto; right: auto; margin-top: -$block-toolbar-height - $border-width; margin-bottom: $block-padding + $border-width; - } - @include break-small() { - top: -$border-width; + // IE11 does not support `position: sticky`. + @supports (position: sticky) { + position: sticky; + // Avoid appearance of double border when toolbar sticks at the top of the editor. + top: -$border-width; + } } // Floated items have special needs for the contextual toolbar position. @@ -853,7 +895,11 @@ z-index: z-index( '.editor-block-list__breadcrumb' ); // Position in the top right of the border - right: -$block-parent-side-ui-clearance; + right: -$block-side-ui-clearance; + + @include break-small() { + right: -$block-parent-side-ui-clearance; + } top: 0; // Nested @@ -871,7 +917,7 @@ padding: 4px 4px; background: theme( outlines ); color: $white; - + // Animate in .editor-block-list__block:hover & { @include fade_in( .1s ); diff --git a/editor/components/block-preview/index.js b/editor/components/block-preview/index.js index 104e885ce6744..a5aae1cbdc3d4 100644 --- a/editor/components/block-preview/index.js +++ b/editor/components/block-preview/index.js @@ -7,56 +7,37 @@ import { noop } from 'lodash'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Component } from '@wordpress/element'; import { createBlock } from '@wordpress/blocks'; /** * Internal dependencies */ import BlockEdit from '../block-edit'; -import { createInnerBlockList } from '../../utils/block-list'; import './style.scss'; /** * Block Preview Component: It renders a preview given a block name and attributes. * - * @param {Object} props Component props. - * @return {WPElement} Rendered element. + * @param {Object} props Component props. + * + * @return {WPElement} Rendered element. */ -class BlockPreview extends Component { - getChildContext() { - // Blocks may render their own BlockEdit, in which case we must provide - // a mechanism for them to create their own InnerBlockList. BlockEdit - // is defined in `@wordpress/blocks`, so to avoid a circular dependency - // we inject this function via context. - return { - createInnerBlockList, - }; - } - - render() { - const { name, attributes } = this.props; - - const block = createBlock( name, attributes ); - - return ( -
    -
    { __( 'Preview' ) }
    -
    - -
    +function BlockPreview( { name, attributes } ) { + const block = createBlock( name, attributes ); + + return ( +
    +
    { __( 'Preview' ) }
    +
    +
    - ); - } +
    + ); } -BlockPreview.childContextTypes = { - createInnerBlockList: noop, -}; - export default BlockPreview; diff --git a/editor/components/colors/with-colors-deprecated.js b/editor/components/colors/with-colors-deprecated.js new file mode 100644 index 0000000000000..e55dbd6e5b51c --- /dev/null +++ b/editor/components/colors/with-colors-deprecated.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import memoize from 'memize'; +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent, Component, compose } from '@wordpress/element'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { getColorValue, getColorClass, setColorValue } from './utils'; + +const DEFAULT_COLORS = []; + +/** + * Higher-order component, which handles color logic for class generation + * color value, retrieval and color attribute setting. + * + * @param {Function} mapGetSetColorToProps Function that receives getColor, setColor, and props, + * and returns additional props to pass to the component. + * + * @return {Function} Higher-order component. + */ +export default ( mapGetSetColorToProps ) => createHigherOrderComponent( + compose( [ + withSelect( + ( select ) => { + const settings = select( 'core/editor' ).getEditorSettings(); + return { + colors: get( settings, [ 'colors' ], DEFAULT_COLORS ), + }; + } ), + ( WrappedComponent ) => { + return class extends Component { + constructor() { + super( ...arguments ); + /** + * Even though we don't expect setAttributes or colors to change memoizing it is essential. + * If setAttributes or colors are not memoized, each time memoizedGetColor/memoizedSetColor are called: + * a new function reference is returned (even if arguments have not changed). + * This would make our memoized chain useless. + */ + this.memoizedGetColor = memoize( this.memoizedGetColor, { maxSize: 1 } ); + this.memoizedSetColor = memoize( this.memoizedSetColor, { maxSize: 1 } ); + } + + memoizedGetColor( colors ) { + return memoize( + ( colorName, customColorValue, colorContext ) => { + return { + name: colorName, + class: getColorClass( colorContext, colorName ), + value: getColorValue( colors, colorName, customColorValue ), + }; + } + ); + } + + memoizedSetColor( setAttributes, colors ) { + return memoize( + ( colorNameAttribute, customColorAttribute ) => { + return setColorValue( colors, colorNameAttribute, customColorAttribute, setAttributes ); + } + ); + } + + render() { + return ( + + ); + } + }; + }, + ] ), + 'withColors' +); diff --git a/editor/components/colors/with-colors.js b/editor/components/colors/with-colors.js index 6d51e4f02b0cc..d02c4f7978a7b 100644 --- a/editor/components/colors/with-colors.js +++ b/editor/components/colors/with-colors.js @@ -1,19 +1,20 @@ /** * External dependencies */ -import memoize from 'memize'; -import { get } from 'lodash'; +import { find, get, isFunction, isString, kebabCase, reduce, upperFirst } from 'lodash'; /** * WordPress dependencies */ import { createHigherOrderComponent, Component, compose } from '@wordpress/element'; import { withSelect } from '@wordpress/data'; +import { deprecated } from '@wordpress/utils'; /** * Internal dependencies */ -import { getColorValue, getColorClass, setColorValue } from './utils'; +import { getColorValue, getColorClass } from './utils'; +import withColorsDeprecated from './with-colors-deprecated'; const DEFAULT_COLORS = []; @@ -21,71 +22,114 @@ const DEFAULT_COLORS = []; * Higher-order component, which handles color logic for class generation * color value, retrieval and color attribute setting. * - * @param {Function} mapGetSetColorToProps Function that receives getColor, setColor, and props, - * and returns additional props to pass to the component. + * @param {...(object|string)} args The arguments can be strings or objects. If the argument is an object, + * it should contain the color attribute name as key and the color context as value. + * If the argument is a string the value should be the color attribute name, + * the color context is computed by applying a kebab case transform to the value. + * Color context represents the context/place where the color is going to be used. + * The class name of the color is generated using 'has' followed by the color name + * and ending with the color context all in kebab case e.g: has-green-background-color. + * * * @return {Function} Higher-order component. */ -export default ( mapGetSetColorToProps ) => createHigherOrderComponent( - compose( [ - withSelect( - ( select ) => { +export default ( ...args ) => { + if ( isFunction( args[ 0 ] ) ) { + deprecated( 'Using withColors( mapGetSetColorToProps ) ', { + version: '3.3', + alternative: 'withColors( colorAttributeName, { secondColorAttributeName: \'color-context\' }, ... )', + } ); + return withColorsDeprecated( args[ 0 ] ); + } + + const colorMap = reduce( args, ( colorObject, arg ) => { + return { + ...colorObject, + ...( isString( arg ) ? { [ arg ]: kebabCase( arg ) } : arg ), + }; + }, {} ); + + return createHigherOrderComponent( + compose( [ + withSelect( ( select ) => { const settings = select( 'core/editor' ).getEditorSettings(); return { colors: get( settings, [ 'colors' ], DEFAULT_COLORS ), }; } ), - ( WrappedComponent ) => { - return class extends Component { - constructor() { - super( ...arguments ); - /** - * Even though, we don't expect setAttributes or colors to change memoizing it is essential. - * If setAttributes or colors are not memoized, each time memoizedGetColor/memoizedSetColor are called: - * a new function reference is returned (even if arguments have not changed). - * This would make our memoized chain useless. - */ - this.memoizedGetColor = memoize( this.memoizedGetColor, { maxSize: 1 } ); - this.memoizedSetColor = memoize( this.memoizedSetColor, { maxSize: 1 } ); - } + ( WrappedComponent ) => { + return class extends Component { + constructor( props ) { + super( props ); + + this.setters = this.createSetters(); - memoizedGetColor( colors ) { - return memoize( - ( colorName, customColorValue, colorContext ) => { - return { - name: colorName, - class: getColorClass( colorContext, colorName ), - value: getColorValue( colors, colorName, customColorValue ), - }; - } - ); - } + this.state = {}; + } - memoizedSetColor( setAttributes, colors ) { - return memoize( - ( colorNameAttribute, customColorAttribute ) => { - return setColorValue( colors, colorNameAttribute, customColorAttribute, setAttributes ); - } - ); - } + createSetters() { + return reduce( colorMap, ( settersAccumulator, colorContext, colorAttributeName ) => { + const upperFirstColorAttributeName = upperFirst( colorAttributeName ); + const customColorAttributeName = `custom${ upperFirstColorAttributeName }`; + settersAccumulator[ `set${ upperFirstColorAttributeName }` ] = + this.createSetColor( colorAttributeName, customColorAttributeName ); + return settersAccumulator; + }, {} ); + } - render() { - return ( - - ); - } - }; - }, - ] ), - 'withColors' -); + createSetColor( colorAttributeName, customColorAttributeName ) { + return ( colorValue ) => { + const colorObject = find( this.props.colors, { color: colorValue } ); + this.props.setAttributes( { + [ colorAttributeName ]: colorObject && colorObject.name ? colorObject.name : undefined, + [ customColorAttributeName ]: colorObject && colorObject.name ? undefined : colorValue, + } ); + }; + } + + static getDerivedStateFromProps( { attributes, colors }, previousState ) { + return reduce( colorMap, ( newState, colorContext, colorAttributeName ) => { + const colorName = attributes[ colorAttributeName ]; + const colorValue = getColorValue( + colors, + colorName, + attributes[ `custom${ upperFirst( colorAttributeName ) }` ] + ); + const previousColorObject = previousState[ colorAttributeName ]; + const previousColorValue = get( previousColorObject, [ 'value' ] ); + /** + * The "and previousColorObject" condition checks that a previous color object was already computed. + * At the start previousColorObject and colorValue are both equal to undefined + * bus as previousColorObject does not exist we should compute the object. + */ + if ( previousColorValue === colorValue && previousColorObject ) { + newState[ colorAttributeName ] = previousColorObject; + } else { + newState[ colorAttributeName ] = { + name: colorName, + class: getColorClass( colorContext, colorName ), + value: colorValue, + }; + } + return newState; + }, {} ); + } + + render() { + return ( + + ); + } + }; + }, + ] ), + 'withColors' + ); +}; diff --git a/editor/components/default-block-appender/index.js b/editor/components/default-block-appender/index.js index 5a844824ba62b..292a4fc89ec29 100644 --- a/editor/components/default-block-appender/index.js +++ b/editor/components/default-block-appender/index.js @@ -59,7 +59,7 @@ export function DefaultBlockAppender( { - { __( 'Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more.' ) } + { __( 'Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kind of content: you can insert text, headings, images, lists, and lots more!' ) }
    diff --git a/editor/components/default-block-appender/style.scss b/editor/components/default-block-appender/style.scss index 68c229dfaa9cc..3a5c27b33580e 100644 --- a/editor/components/default-block-appender/style.scss +++ b/editor/components/default-block-appender/style.scss @@ -74,7 +74,7 @@ $empty-paragraph-height: $text-editor-font-size * 4; right: $item-spacing; // show on the right on mobile @include break-small { - left: -$icon-button-size - $block-side-ui-clearance - $block-parent-side-ui-clearance; + left: -$block-side-ui-width - $block-padding - $block-side-ui-clearance; right: auto; } @@ -85,6 +85,10 @@ $empty-paragraph-height: $text-editor-font-size * 4; .editor-inserter__toggle { transition: opacity 0.2s; border-radius: 50%; + width: $block-side-ui-width; + height: $block-side-ui-width; + top: 4px; + padding: 4px; // use opacity to work in various editor styles &:not( :hover ) { diff --git a/editor/components/default-block-appender/test/__snapshots__/index.js.snap b/editor/components/default-block-appender/test/__snapshots__/index.js.snap index 9fee0c9ce14b7..61f115611b1b0 100644 --- a/editor/components/default-block-appender/test/__snapshots__/index.js.snap +++ b/editor/components/default-block-appender/test/__snapshots__/index.js.snap @@ -42,7 +42,7 @@ exports[`DefaultBlockAppender should append a default block when input focused 1 - Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more. + Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kind of content: you can insert text, headings, images, lists, and lots more!
    @@ -72,7 +72,7 @@ exports[`DefaultBlockAppender should match snapshot 1`] = ` - Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more. + Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kind of content: you can insert text, headings, images, lists, and lots more!
    @@ -102,7 +102,7 @@ exports[`DefaultBlockAppender should optionally show without prompt 1`] = ` - Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more. + Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kind of content: you can insert text, headings, images, lists, and lots more! diff --git a/editor/components/document-title/index.js b/editor/components/document-title/index.js index 9677e5502b7c6..1f4934060773b 100644 --- a/editor/components/document-title/index.js +++ b/editor/components/document-title/index.js @@ -14,17 +14,13 @@ class DocumentTitle extends Component { this.originalDocumentTitle = document.title; } - setDocumentTitle( title ) { - document.title = title + ' | ' + this.originalDocumentTitle; - } - componentDidMount() { this.setDocumentTitle( this.props.title ); } - componentWillReceiveProps( nextProps ) { - if ( nextProps.title !== this.props.title ) { - this.setDocumentTitle( nextProps.title ); + componentDidUpdate( prevProps ) { + if ( prevProps.title !== this.props.title ) { + this.setDocumentTitle( this.props.title ); } } @@ -32,6 +28,10 @@ class DocumentTitle extends Component { document.title = this.originalDocumentTitle; } + setDocumentTitle( title ) { + document.title = title + ' | ' + this.originalDocumentTitle; + } + render() { return null; } diff --git a/editor/components/inner-blocks/README.md b/editor/components/inner-blocks/README.md index 331fffa6a4b8d..381791a2abb42 100644 --- a/editor/components/inner-blocks/README.md +++ b/editor/components/inner-blocks/README.md @@ -12,7 +12,8 @@ In a block's `edit` implementation, simply render `InnerBlocks`, optionally with Then, in the `save` implementation, render `InnerBlocks.Content`. This will be replaced automatically with the content of the nested blocks. ```jsx -import { registerBlockType, InnerBlocks } from '@wordpress/blocks'; +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/editor'; registerBlockType( 'my-plugin/my-block', { // ... diff --git a/editor/components/inner-blocks/index.js b/editor/components/inner-blocks/index.js index 04399b3dcbe61..8bc74b886c200 100644 --- a/editor/components/inner-blocks/index.js +++ b/editor/components/inner-blocks/index.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import { isEqual, pick } from 'lodash'; import classnames from 'classnames'; /** @@ -8,43 +9,100 @@ import classnames from 'classnames'; */ import { withContext } from '@wordpress/components'; import { withViewportMatch } from '@wordpress/viewport'; -import { compose } from '@wordpress/element'; -import { withSelect } from '@wordpress/data'; +import { Component, compose } from '@wordpress/element'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { synchronizeBlocksWithTemplate } from '@wordpress/blocks'; /** * Internal dependencies */ import './style.scss'; +import BlockList from '../block-list'; +import { withBlockEditContext } from '../block-edit/context'; -function InnerBlocks( { - BlockList, - layouts, - allowedBlocks, - template, - isSmallScreen, - isSelectedBlockInRoot, -} ) { - const classes = classnames( 'editor-inner-blocks', { - 'has-overlay': isSmallScreen && ! isSelectedBlockInRoot, - } ); - - return ( -
    - -
    - ); +class InnerBlocks extends Component { + componentWillReceiveProps( nextProps ) { + this.updateNestedSettings( { + supportedBlocks: nextProps.allowedBlocks, + } ); + } + + componentDidMount() { + this.updateNestedSettings( { + supportedBlocks: this.props.allowedBlocks, + } ); + this.insertTemplateBlocks( this.props.template ); + } + + insertTemplateBlocks( template ) { + const { block, insertBlocks } = this.props; + if ( template && ! block.innerBlocks.length ) { + // synchronizeBlocksWithTemplate( [], template ) parses the template structure, + // and returns/creates the necessary blocks to represent it. + insertBlocks( synchronizeBlocksWithTemplate( [], template ) ); + } + } + + updateNestedSettings( newSettings ) { + if ( ! isEqual( this.props.blockListSettings, newSettings ) ) { + this.props.updateNestedSettings( newSettings ); + } + } + + render() { + const { + uid, + layouts, + allowedBlocks, + template, + isSmallScreen, + isSelectedBlockInRoot, + } = this.props; + + const classes = classnames( 'editor-inner-blocks', { + 'has-overlay': isSmallScreen && ! isSelectedBlockInRoot, + } ); + + return ( +
    + +
    + ); + } } InnerBlocks = compose( [ - withContext( 'BlockList' )(), - withContext( 'uid' )(), + withBlockEditContext( ( context ) => pick( context, [ 'uid' ] ) ), withViewportMatch( { isSmallScreen: '< medium' } ), withSelect( ( select, ownProps ) => { - const { isBlockSelected, hasSelectedInnerBlock } = select( 'core/editor' ); + const { + isBlockSelected, + hasSelectedInnerBlock, + getBlock, + getBlockListSettings, + } = select( 'core/editor' ); const { uid } = ownProps; return { isSelectedBlockInRoot: isBlockSelected( uid ) || hasSelectedInnerBlock( uid ), + block: getBlock( uid ), + blockListSettings: getBlockListSettings( uid ), + }; + } ), + withDispatch( ( dispatch, ownProps ) => { + const { insertBlocks, updateBlockListSettings } = dispatch( 'core/editor' ); + const { uid } = ownProps; + + return { + insertBlocks( blocks ) { + dispatch( insertBlocks( blocks, undefined, uid ) ); + }, + updateNestedSettings( settings ) { + dispatch( updateBlockListSettings( uid, settings ) ); + }, }; } ), ] )( InnerBlocks ); diff --git a/editor/components/inserter-with-shortcuts/index.js b/editor/components/inserter-with-shortcuts/index.js index c9fa5db554e92..83c282ad594ab 100644 --- a/editor/components/inserter-with-shortcuts/index.js +++ b/editor/components/inserter-with-shortcuts/index.js @@ -23,9 +23,12 @@ function InserterWithShortcuts( { items, isLocked, onInsert } ) { return null; } - const itemsWithoutDefaultBlock = filter( items, ( item ) => - item.name !== getDefaultBlockName() || ! isEmpty( item.initialAttributes ) - ).slice( 0, 3 ); + const itemsWithoutDefaultBlock = filter( items, ( item ) => { + return ! item.isDisabled && ( + item.name !== getDefaultBlockName() || + ! isEmpty( item.initialAttributes ) + ); + } ).slice( 0, 3 ); return (
    @@ -34,6 +37,7 @@ function InserterWithShortcuts( { items, isLocked, onInsert } ) { key={ item.id } className="editor-inserter-with-shortcuts__block" onClick={ () => onInsert( item ) } + // translators: %s: block title/name to be added label={ sprintf( __( 'Add %s' ), item.title ) } icon={ ( diff --git a/editor/components/inserter/child-blocks.js b/editor/components/inserter/child-blocks.js index c2a2da56e0db0..80fed8f9e311d 100644 --- a/editor/components/inserter/child-blocks.js +++ b/editor/components/inserter/child-blocks.js @@ -17,17 +17,7 @@ function ChildBlocks( { rootBlockIcon, rootBlockTitle, items, ...props } ) {
    { ( rootBlockIcon || rootBlockTitle ) && (
    - { rootBlockIcon && ( -
    - -
    - ) } + { rootBlockTitle &&

    { rootBlockTitle }

    }
    ) } diff --git a/editor/components/inserter/style.scss b/editor/components/inserter/style.scss index bf097e2c71bfe..935da57bd754c 100644 --- a/editor/components/inserter/style.scss +++ b/editor/components/inserter/style.scss @@ -84,13 +84,17 @@ $block-inserter-search-height: 38px; @include break-medium { height: $block-inserter-content-height + $block-inserter-tabs-height; - box-shadow: inset 0 -5px 5px -4px rgba( $dark-gray-900, .1 ); } // Don't show the top border on the first panel, let the Search border be the border. .components-panel__body:first-child { border-top: none; } + + // Don't show the bottom border on the last panel, let the library itself show the border. + .components-panel__body:last-child { + border-bottom: none; + } } .editor-inserter__list { @@ -212,12 +216,3 @@ $block-inserter-search-height: 38px; font-size: 13px; } } - -.editor-inserter__parent-block-icon { - background: $light-gray-200; - border-radius: 4px; - display: flex; - margin-left: -2px; - margin-right: 10px; - padding: 0 8px; -} diff --git a/editor/components/media-placeholder/index.js b/editor/components/media-placeholder/index.js index de4b76c93907b..bf60240199359 100644 --- a/editor/components/media-placeholder/index.js +++ b/editor/components/media-placeholder/index.js @@ -92,6 +92,7 @@ class MediaPlaceholder extends Component { MediaUpload; addFilter( - 'blocks.MediaUpload', - 'core/edit-post/blocks/media-upload/replaceMediaUpload', + 'editor.MediaUpload', + 'core/edit-post/components/media-upload/replace-media-upload', replaceMediaUpload ); ``` diff --git a/editor/components/media-upload/index.js b/editor/components/media-upload/index.js index 9485a0c856618..38743329f818f 100644 --- a/editor/components/media-upload/index.js +++ b/editor/components/media-upload/index.js @@ -6,11 +6,11 @@ import { withFilters } from '@wordpress/components'; /** * This is a placeholder for the media upload component necessary to make it possible to provide * an integration with the core blocks that handle media files. By default it renders nothing but - * it provides a way to have it overridden with the `blocks.MediaUpload` filter. + * it provides a way to have it overridden with the `editor.MediaUpload` filter. * * @return {WPElement} Media upload element. */ const MediaUpload = () => null; // Todo: rename the filter -export default withFilters( 'blocks.MediaUpload' )( MediaUpload ); +export default withFilters( 'editor.MediaUpload' )( MediaUpload ); diff --git a/editor/components/post-excerpt/index.js b/editor/components/post-excerpt/index.js index b7c7c9ce088b8..9b42d9fa2477d 100644 --- a/editor/components/post-excerpt/index.js +++ b/editor/components/post-excerpt/index.js @@ -30,7 +30,7 @@ function PostExcerpt( { excerpt, onUpdateExcerpt } ) { export default compose( [ withSelect( ( select ) => { return { - excerpt: select( 'core/editor' ).getEditedPostExcerpt(), + excerpt: select( 'core/editor' ).getEditedPostAttribute( 'excerpt' ), }; } ), withDispatch( ( dispatch ) => ( { diff --git a/editor/components/post-featured-image/index.js b/editor/components/post-featured-image/index.js index 5a04a43467ad0..0557e48734589 100644 --- a/editor/components/post-featured-image/index.js +++ b/editor/components/post-featured-image/index.js @@ -20,7 +20,7 @@ import MediaUpload from '../media-upload'; // Used when labels from post type were not yet loaded or when they are not present. const DEFAULT_SET_FEATURE_IMAGE_LABEL = __( 'Set featured image' ); -const DEFAULT_REMOVE_FEATURE_IMAGE_LABEL = __( 'Remove featured image' ); +const DEFAULT_REMOVE_FEATURE_IMAGE_LABEL = __( 'Remove image' ); function PostFeaturedImage( { featuredImageId, onUpdateImage, onRemoveImage, media, postType } ) { const postLabel = get( postType, [ 'labels' ], {} ); @@ -30,12 +30,12 @@ function PostFeaturedImage( { featuredImageId, onUpdateImage, onRemoveImage, med
    { !! featuredImageId && ( - + ) } + /> } { ! featuredImageId && - ( - - ) } - /> +
    + ( + + ) } + /> +
    } { !! featuredImageId && - } diff --git a/editor/components/post-featured-image/style.scss b/editor/components/post-featured-image/style.scss index e357b139defc7..28a832d91b2c1 100644 --- a/editor/components/post-featured-image/style.scss +++ b/editor/components/post-featured-image/style.scss @@ -1,33 +1,45 @@ .editor-post-featured-image { - padding: 10px 0 0; + padding: 0; .spinner { margin: 0; } -} - -.editor-post-featured-image__toggle { - text-decoration: underline; - color: theme( secondary ); - &:focus { - box-shadow: none; - outline: none; + // Space consecutive buttons evenly. + .components-button + .components-button { + margin-top: 1em; + margin-right: 8px; } - &:hover { - color: theme( primary ); + // This keeps images at their intrinsic size (eg. a 50px + // image will never be wider than 50px). + .components-responsive-wrapper__content { + max-width: 100%; + width: auto; } } +.editor-post-featured-image__toggle, .editor-post-featured-image__preview { display: block; width: 100%; + padding: 0; + transition: all .1s ease-out; + box-shadow: 0 0 0 0 $blue-medium-500; } -.editor-post-featured-image__howto { - color: $dark-gray-300; - font-style: italic; - margin: 10px 0; +.editor-post-featured-image__preview:not(:disabled):not([aria-disabled="true"]):focus { + box-shadow: 0 0 0 4px $blue-medium-500; } +.editor-post-featured-image__toggle { + border: 1px dashed $light-gray-900; + background-color: $light-gray-300; + line-height: 20px; + padding: $item-spacing 0; + text-align: center; + + &:hover { + background-color: $light-gray-100; + } +} diff --git a/editor/components/post-permalink/index.js b/editor/components/post-permalink/index.js index b1ba0dec54c96..e81302a486cdc 100644 --- a/editor/components/post-permalink/index.js +++ b/editor/components/post-permalink/index.js @@ -56,11 +56,11 @@ class PostPermalink extends Component { } render() { - const { isNew, previewLink, isEditable, samplePermalink, isPublished } = this.props; + const { isNew, postLink, isEditable, samplePermalink, isPublished } = this.props; const { isCopied, isEditingPermalink } = this.state; const ariaLabel = isCopied ? __( 'Permalink copied' ) : __( 'Copy the permalink' ); - if ( isNew || ! previewLink ) { + if ( isNew || ! postLink ) { return null; } @@ -80,7 +80,7 @@ class PostPermalink extends Component { { ! isEditingPermalink &&
    My title plugin
    (No title)
    "`; +exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"
    Sidebar title plugin
    "`; diff --git a/test/e2e/specs/change-detection.test.js b/test/e2e/specs/change-detection.test.js index 944346629f60b..8db99f779d6f7 100644 --- a/test/e2e/specs/change-detection.test.js +++ b/test/e2e/specs/change-detection.test.js @@ -2,7 +2,13 @@ * Internal dependencies */ import '../support/bootstrap'; -import { newPost, newDesktopBrowserPage, pressWithModifier } from '../support/utils'; +import { + newPost, + newDesktopBrowserPage, + pressWithModifier, + ensureSidebarOpened, + publishPost, +} from '../support/utils'; describe( 'Change detection', () => { let handleInterceptedRequest, hadInterceptedSave; @@ -83,7 +89,47 @@ describe( 'Change detection', () => { page.waitForSelector( '.editor-post-saved-state.is-saved' ), ] ); - // Still dirty after an autosave. + // Autosave draft as same user should do full save, i.e. not dirty. + await assertIsDirty( false ); + } ); + + it( 'Should prompt to confirm unsaved changes for autosaved draft for non-content fields', async () => { + await page.type( '.editor-post-title__input', 'Hello World' ); + + // Toggle post as sticky (not persisted for autosave). + await ensureSidebarOpened(); + await page.click( '[id^="post-sticky-toggle-"]' ); + + // Force autosave to occur immediately. + await Promise.all( [ + page.evaluate( () => window.wp.data.dispatch( 'core/editor' ).autosave() ), + page.waitForSelector( '.editor-post-saved-state.is-autosaving' ), + page.waitForSelector( '.editor-post-saved-state.is-saved' ), + ] ); + + await assertIsDirty( true ); + } ); + + it( 'Should prompt to confirm unsaved changes for autosaved published post', async () => { + await page.type( '.editor-post-title__input', 'Hello World' ); + + await publishPost(); + + // Close publish panel. + await Promise.all( [ + page.waitForFunction( () => ! document.querySelector( '.editor-post-publish-panel' ) ), + page.click( '.editor-post-publish-panel__header button' ), + ] ); + + // Should be dirty after autosave change of published post. + await page.type( '.editor-post-title__input', '!' ); + + await Promise.all( [ + page.waitForSelector( '.editor-post-publish-button.is-busy' ), + page.waitForSelector( '.editor-post-publish-button:not( .is-busy )' ), + page.evaluate( () => window.wp.data.dispatch( 'core/editor' ).autosave() ), + ] ); + await assertIsDirty( true ); } ); diff --git a/test/e2e/specs/plugins-api.test.js b/test/e2e/specs/plugins-api.test.js index b3521a57f349c..b9ada2a231331 100644 --- a/test/e2e/specs/plugins-api.test.js +++ b/test/e2e/specs/plugins-api.test.js @@ -2,7 +2,12 @@ * Internal dependencies */ import '../support/bootstrap'; -import { newPost, newDesktopBrowserPage, toggleMoreMenuItem } from '../support/utils'; +import { + clickOnMoreMenuItem, + openDocumentSettingsSidebar, + newPost, + newDesktopBrowserPage, +} from '../support/utils'; import { activatePlugin, deactivatePlugin } from '../support/plugins'; describe( 'Using Plugins API', () => { @@ -17,17 +22,28 @@ describe( 'Using Plugins API', () => { await deactivatePlugin( 'gutenberg-test-plugin-plugins-api' ); } ); - it( 'Should open plugins sidebar using More Menu item and render content', async () => { - await toggleMoreMenuItem( 'My title plugin' ); + describe( 'Post Status Info', () => { + it( 'Should render post status info inside Document Setting sidebar', async () => { + await openDocumentSettingsSidebar(); - const pluginSidebarContent = await page.$eval( '.edit-post-sidebar', ( el ) => el.innerHTML ); - expect( pluginSidebarContent ).toMatchSnapshot(); + const pluginPostStatusInfoText = await page.$eval( '.edit-post-post-status .my-post-status-info-plugin', ( el ) => el.innerText ); + expect( pluginPostStatusInfoText ).toBe( 'My post status info' ); + } ); } ); - it( 'Should close plugins sidebar using More Menu item', async () => { - await toggleMoreMenuItem( 'My title plugin' ); + describe( 'Sidebar', () => { + it( 'Should open plugins sidebar using More Menu item and render content', async () => { + await clickOnMoreMenuItem( 'Sidebar title plugin' ); - const pluginSidebar = await page.$( '.edit-post-sidebar' ); - expect( pluginSidebar ).toBeNull(); + const pluginSidebarContent = await page.$eval( '.edit-post-sidebar', ( el ) => el.innerHTML ); + expect( pluginSidebarContent ).toMatchSnapshot(); + } ); + + it( 'Should close plugins sidebar using More Menu item', async () => { + await clickOnMoreMenuItem( 'Sidebar title plugin' ); + + const pluginSidebar = await page.$( '.edit-post-sidebar' ); + expect( pluginSidebar ).toBeNull(); + } ); } ); } ); diff --git a/test/e2e/specs/preview.test.js b/test/e2e/specs/preview.test.js new file mode 100644 index 0000000000000..76f92ca706965 --- /dev/null +++ b/test/e2e/specs/preview.test.js @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import { parse } from 'url'; + +/** + * Internal dependencies + */ +import '../support/bootstrap'; +import { + newPost, + newDesktopBrowserPage, + getUrl, + publishPost, +} from '../support/utils'; + +describe( 'Preview', () => { + beforeAll( async () => { + await newDesktopBrowserPage(); + } ); + + beforeEach( async () => { + await newPost(); + } ); + + it( 'Should open a preview window for a new post', async () => { + const editorPage = page; + + // Disabled until content present. + const isPreviewDisabled = await page.$$eval( + '.editor-post-preview:not( :disabled )', + ( enabledButtons ) => ! enabledButtons.length, + ); + expect( isPreviewDisabled ).toBe( true ); + + await editorPage.type( '.editor-post-title__input', 'Hello World' ); + + // Don't proceed with autosave until preview window page is resolved. + await editorPage.setRequestInterception( true ); + + let [ , previewPage ] = await Promise.all( [ + editorPage.click( '.editor-post-preview' ), + new Promise( ( resolve ) => { + browser.once( 'targetcreated', async ( target ) => { + resolve( await target.page() ); + } ); + } ), + ] ); + + // Interstitial screen while save in progress. + expect( previewPage.url() ).toBe( 'about:blank' ); + + // Release request intercept should allow redirect to occur after save. + await Promise.all( [ + previewPage.waitForNavigation(), + editorPage.setRequestInterception( false ), + ] ); + + // When autosave completes for a new post, the URL of the editor should + // update to include the ID. Use this to assert on preview URL. + const [ , postId ] = await ( await editorPage.waitForFunction( () => { + return window.location.search.match( /[\?&]post=(\d+)/ ); + } ) ).jsonValue(); + + let expectedPreviewURL = getUrl( '', `?p=${ postId }&preview=true` ); + expect( previewPage.url() ).toBe( expectedPreviewURL ); + + // Title in preview should match input. + let previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World' ); + + // Return to editor to change title. + await editorPage.bringToFront(); + await editorPage.type( '.editor-post-title__input', '!' ); + + // Second preview should reuse same popup frame, with interstitial. + await editorPage.setRequestInterception( true ); + await Promise.all( [ + editorPage.click( '.editor-post-preview' ), + // Note: `load` event is used since, while a `window.open` with + // `about:blank` is called, the target window doesn't actually + // navigate to `about:blank` (it is treated as noop). But when + // the `document.write` + `document.close` of the interstitial + // finishes, a `load` event is fired. + new Promise( ( resolve ) => previewPage.once( 'load', resolve ) ), + ] ); + await editorPage.setRequestInterception( false ); + + // Wait for preview to load. + await new Promise( ( resolve ) => { + previewPage.once( 'load', resolve ); + } ); + + // Title in preview should match updated input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World!' ); + + // Pressing preview without changes should bring same preview window to + // front and reload, but should not show interstitial. Intercept editor + // requests in case a save attempt occurs, to avoid race condition on + // the load event and title retrieval. + await editorPage.bringToFront(); + await editorPage.setRequestInterception( true ); + await editorPage.click( '.editor-post-preview' ); + await new Promise( ( resolve ) => previewPage.once( 'load', resolve ) ); + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World!' ); + await editorPage.setRequestInterception( false ); + + // Preview for published post (no unsaved changes) directs to canonical + // URL for post. + await editorPage.bringToFront(); + await publishPost(); + await Promise.all( [ + page.waitForFunction( () => ! document.querySelector( '.editor-post-preview' ) ), + page.click( '.editor-post-publish-panel__header button' ), + ] ); + expectedPreviewURL = await editorPage.$eval( '.notice-success a', ( node ) => node.href ); + // Note / Temporary: It's expected that Chrome should reuse the same + // tab with window name `wp-preview-##`, yet in this instance a new tab + // is unfortunately created. + previewPage = ( await Promise.all( [ + editorPage.click( '.editor-post-preview' ), + new Promise( ( resolve ) => { + browser.once( 'targetcreated', async ( target ) => { + resolve( await target.page() ); + } ); + } ), + ] ) )[ 1 ]; + expect( previewPage.url() ).toBe( expectedPreviewURL ); + + // Return to editor to change title. + await editorPage.bringToFront(); + await editorPage.type( '.editor-post-title__input', ' And more.' ); + + // Published preview should reuse same popup frame, with interstitial. + await editorPage.setRequestInterception( true ); + await Promise.all( [ + editorPage.click( '.editor-post-preview' ), + new Promise( ( resolve ) => previewPage.once( 'load', resolve ) ), + ] ); + await editorPage.setRequestInterception( false ); + + // Wait for preview to load. + await new Promise( ( resolve ) => { + previewPage.once( 'load', resolve ); + } ); + + // Title in preview should match updated input. + previewTitle = await previewPage.$eval( '.entry-title', ( node ) => node.textContent ); + expect( previewTitle ).toBe( 'Hello World! And more.' ); + + // Published preview URL should include ID and nonce parameters. + const { query } = parse( previewPage.url(), true ); + expect( query ).toHaveProperty( 'preview_id' ); + expect( query ).toHaveProperty( 'preview_nonce' ); + } ); +} ); diff --git a/test/e2e/specs/publishing.test.js b/test/e2e/specs/publishing.test.js index c48758942c5eb..3a5a219c6e955 100644 --- a/test/e2e/specs/publishing.test.js +++ b/test/e2e/specs/publishing.test.js @@ -5,6 +5,7 @@ import '../support/bootstrap'; import { newPost, newDesktopBrowserPage, + publishPost, } from '../support/utils'; describe( 'Publishing', () => { @@ -19,18 +20,7 @@ describe( 'Publishing', () => { it( 'Should publish a post and close the panel once we start editing again', async () => { await page.type( '.editor-post-title__input', 'E2E Test Post' ); - // Opens the publish panel - await page.click( '.editor-post-publish-panel__toggle' ); - - // Disable reason: Wait for a second ( wait for the animation ) - // eslint-disable-next-line no-restricted-syntax - await page.waitFor( 1000 ); - - // Publish the post - await page.click( '.editor-post-publish-button' ); - - // A success notice should show up - page.waitForSelector( '.notice-success' ); + await publishPost(); // The post publish panel is visible expect( await page.$( '.editor-post-publish-panel' ) ).not.toBeNull(); diff --git a/test/e2e/specs/sidebar-behaviour.test.js b/test/e2e/specs/sidebar-behaviour.test.js new file mode 100644 index 0000000000000..6ffbd046def18 --- /dev/null +++ b/test/e2e/specs/sidebar-behaviour.test.js @@ -0,0 +1,80 @@ +/** + * Internal dependencies + */ +import '../support/bootstrap'; +import { clearLocalStorage, newPost, newDesktopBrowserPage, setViewport } from '../support/utils'; + +const SIDEBAR_SELECTOR = '.edit-post-sidebar'; +const ACTIVE_SIDEBAR_TAB_SELECTOR = '.edit-post-sidebar__panel-tab.is-active'; +const ACTIVE_SIDEBAR_BUTTON_TEXT = 'Document'; + +describe( 'Publishing', () => { + beforeAll( async () => { + await newDesktopBrowserPage(); + } ); + + afterEach( async () => { + await clearLocalStorage(); + await page.goto( 'about:blank' ); + await setViewport( 'large' ); + } ); + + it( 'Should have sidebar visible at the start with document sidebar active on desktop', async () => { + await setViewport( 'large' ); + await newPost(); + const { nodesCount, content, height, width } = await page.$$eval( ACTIVE_SIDEBAR_TAB_SELECTOR, ( nodes ) => { + const firstNode = nodes[ 0 ]; + return { + nodesCount: nodes.length, + content: firstNode.innerText, + height: firstNode.offsetHeight, + width: firstNode.offsetWidth, + }; + } ); + + // should have only one active sidebar tab. + expect( nodesCount ).toBe( 1 ); + + // the active sidebar tab should be document. + expect( content ).toBe( ACTIVE_SIDEBAR_BUTTON_TEXT ); + + // the active sidebar tab should be visible + expect( width ).toBeGreaterThan( 10 ); + expect( height ).toBeGreaterThan( 10 ); + } ); + + it( 'Should have the sidebar closed by default on mobile', async () => { + await setViewport( 'small' ); + await newPost(); + const sidebar = await page.$( SIDEBAR_SELECTOR ); + expect( sidebar ).toBeNull(); + } ); + + it( 'Should close the sidebar when resizing from desktop to mobile', async () => { + await setViewport( 'large' ); + await newPost(); + + const sidebars = await page.$$( SIDEBAR_SELECTOR ); + expect( sidebars ).toHaveLength( 1 ); + + await setViewport( 'small' ); + + const sidebarsMobile = await page.$$( SIDEBAR_SELECTOR ); + // sidebar should be closed when resizing to mobile. + expect( sidebarsMobile ).toHaveLength( 0 ); + } ); + + it( 'Should reopen sidebar the sidebar when resizing from mobile to desktop if the sidebar was closed automatically', async () => { + await setViewport( 'large' ); + await newPost(); + await setViewport( 'small' ); + + const sidebarsMobile = await page.$$( SIDEBAR_SELECTOR ); + expect( sidebarsMobile ).toHaveLength( 0 ); + + await setViewport( 'large' ); + + const sidebarsDesktop = await page.$$( SIDEBAR_SELECTOR ); + expect( sidebarsDesktop ).toHaveLength( 1 ); + } ); +} ); diff --git a/test/e2e/specs/templates.test.js b/test/e2e/specs/templates.test.js index f0cc6e4d7e595..8b48b339adf36 100644 --- a/test/e2e/specs/templates.test.js +++ b/test/e2e/specs/templates.test.js @@ -2,7 +2,7 @@ * Internal dependencies */ import '../support/bootstrap'; -import { newPost, newDesktopBrowserPage, toggleMoreMenuItem } from '../support/utils'; +import { clickOnMoreMenuItem, newPost, newDesktopBrowserPage } from '../support/utils'; import { activatePlugin, deactivatePlugin } from '../support/plugins'; describe( 'Using a CPT with a predefined template', () => { @@ -19,7 +19,7 @@ describe( 'Using a CPT with a predefined template', () => { it( 'Should add a custom post types with a predefined template', async () => { //Switch to Code Editor to check HTML output - await toggleMoreMenuItem( 'Code Editor' ); + await clickOnMoreMenuItem( 'Code Editor' ); // Assert that the post already contains the template defined blocks const textEditorContent = await page.$eval( '.editor-post-text-editor', ( element ) => element.value ); diff --git a/test/e2e/support/utils.js b/test/e2e/support/utils.js index 97dd0e23ef2f0..99d7914e95c6e 100644 --- a/test/e2e/support/utils.js +++ b/test/e2e/support/utils.js @@ -26,7 +26,7 @@ const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; */ const REGEXP_ZWSP = /[\u200B\u200C\u200D\uFEFF]/; -function getUrl( WPPath, query = '' ) { +export function getUrl( WPPath, query = '' ) { const url = new URL( WP_BASE_URL ); url.pathname = join( url.pathname, WPPath ); @@ -90,7 +90,32 @@ export async function newDesktopBrowserPage() { fail( error ); } ); - await page.setViewport( { width: 1000, height: 700 } ); + await setViewport( 'large' ); +} + +export async function setViewport( type ) { + const allowedDimensions = { + large: { width: 960, height: 700 }, + small: { width: 600, height: 700 }, + }; + const currentDimmension = allowedDimensions[ type ]; + await page.setViewport( currentDimmension ); + await waitForPageDimensions( currentDimmension.width, currentDimmension.height ); +} + +/** + * Function that waits until the page viewport has the required dimensions. + * It is being used to address a problem where after using setViewport the execution may continue, + * without the new dimensions being applied. + * https://github.com/GoogleChrome/puppeteer/issues/1751 + * + * @param {number} width Width of the window. + * @param {height} height Height of the window. + */ +export async function waitForPageDimensions( width, height ) { + await page.mainFrame().waitForFunction( + `window.innerWidth === ${ width } && window.innerHeight === ${ height }` + ); } export async function switchToEditor( mode ) { @@ -112,6 +137,22 @@ export async function getHTMLFromCodeEditor() { return textEditorContent; } +/** + * Verifies that the edit post sidebar is opened, and if it is not, opens it. + * + * @return {Promise} Promise resolving once the edit post sidebar is opened. + */ +export async function ensureSidebarOpened() { + // This try/catch flow relies on the fact that `page.$eval` throws an error + // if the element matching the given selector does not exist. Thus, if an + // error is thrown, it can be inferred that the sidebar is not opened. + try { + return page.$eval( '.edit-post-sidebar', () => {} ); + } catch ( error ) { + return page.click( '.edit-post-header__settings [aria-label="Settings"]' ); + } +} + /** * Opens the inserter, searches for the given term, then selects the first * result that appears. @@ -149,12 +190,49 @@ export async function pressWithModifier( modifier, key ) { } /** - * Toggles More Menu item, searchers for the button with the text provided and clicks it. + * Clicks on More Menu item, searchers for the button with the text provided and clicks it. * * @param {string} buttonLabel The label to search the button for. */ -export async function toggleMoreMenuItem( buttonLabel ) { +export async function clickOnMoreMenuItem( buttonLabel ) { await page.click( '.edit-post-more-menu [aria-label="More"]' ); const itemButton = ( await page.$x( `//button[contains(text(), \'${ buttonLabel }\')]` ) )[ 0 ]; await itemButton.click( 'button' ); } + +/** + * Publishes the post, resolving once the request is complete (once a notice + * is displayed). + * + * @return {Promise} Promise resolving when publish is complete. + */ +export async function publishPost() { + // Opens the publish panel + await page.click( '.editor-post-publish-panel__toggle' ); + + // Disable reason: Wait for the animation to complete, since otherwise the + // click attempt may occur at the wrong point. + // eslint-disable-next-line no-restricted-syntax + await page.waitFor( 100 ); + + // Publish the post + await page.click( '.editor-post-publish-button' ); + + // A success notice should show up + return page.waitForSelector( '.notice-success' ); +} + +/** + * Clicks on the button in the header which opens Document Settings sidebar when it is closed. + */ +export async function openDocumentSettingsSidebar() { + const openButton = await page.$( '.edit-post-header__settings button[aria-label="Settings"][aria-expaned="false"]' ); + + if ( openButton ) { + await page.click( openButton ); + } +} + +export async function clearLocalStorage() { + await page.evaluate( () => window.localStorage.clear() ); +} diff --git a/test/e2e/test-plugins/hooks-api/index.js b/test/e2e/test-plugins/hooks-api/index.js index 34cff1d758ad3..eb45316565f38 100644 --- a/test/e2e/test-plugins/hooks-api/index.js +++ b/test/e2e/test-plugins/hooks-api/index.js @@ -53,7 +53,7 @@ } addFilter( - 'blocks.BlockEdit', + 'editor.BlockEdit', 'e2e/hooks-api/add-reset-block-button', addResetBlockButton, 100 diff --git a/test/e2e/test-plugins/plugins-api.php b/test/e2e/test-plugins/plugins-api.php index 55a99872afd5a..a78ed2d770786 100644 --- a/test/e2e/test-plugins/plugins-api.php +++ b/test/e2e/test-plugins/plugins-api.php @@ -6,17 +6,45 @@ * * @package gutenberg-test-plugin-plugins-api */ + wp_enqueue_script( - 'gutenberg-test-plugins-api', - plugins_url( 'plugins-api/index.js', __FILE__ ), + 'gutenberg-test-plugins-api-post-status-info', + plugins_url( 'plugins-api/post-status-info.js', __FILE__ ), + array( + 'wp-edit-post', + 'wp-element', + 'wp-i18n', + 'wp-plugins', + ), + filemtime( plugin_dir_path( __FILE__ ) . 'plugins-api/post-status-info.js' ), + true +); + +wp_enqueue_script( + 'gutenberg-test-plugins-api-publish-pane;', + plugins_url( 'plugins-api/publish-panel.js', __FILE__ ), + array( + 'wp-edit-post', + 'wp-element', + 'wp-i18n', + 'wp-plugins', + ), + filemtime( plugin_dir_path( __FILE__ ) . 'plugins-api/publish-panel.js' ), + true +); + +wp_enqueue_script( + 'gutenberg-test-plugins-api-sidebar', + plugins_url( 'plugins-api/sidebar.js', __FILE__ ), array( 'wp-components', 'wp-data', - 'wp-element', 'wp-edit-post', + 'wp-editor', + 'wp-element', 'wp-i18n', 'wp-plugins', ), - filemtime( plugin_dir_path( __FILE__ ) . 'plugins-api/index.js' ), + filemtime( plugin_dir_path( __FILE__ ) . 'plugins-api/sidebar.js' ), true ); diff --git a/test/e2e/test-plugins/plugins-api/index.js b/test/e2e/test-plugins/plugins-api/index.js deleted file mode 100644 index 9612320add12a..0000000000000 --- a/test/e2e/test-plugins/plugins-api/index.js +++ /dev/null @@ -1,89 +0,0 @@ -var Button = wp.components.Button; -var PanelBody = wp.components.PanelBody; -var PanelRow = wp.components.PanelRow; -var withDispatch = wp.data.withDispatch; -var withSelect = wp.data.withSelect; -var Fragment = wp.element.Fragment; -var compose = wp.element.compose; -var el = wp.element.createElement; -var __ = wp.i18n.__; -var registerPlugin = wp.plugins.registerPlugin; -var PluginSidebar = wp.editPost.PluginSidebar; -var PluginSidebarMoreMenuItem = wp.editPost.PluginSidebarMoreMenuItem; - -function SidebarContents( props ) { - var noTitle = el( - 'em', - {}, - __( '(No title)' ) - ); - return ( - el( - PanelBody, - {}, - el( - PanelRow, - {}, - props.title || noTitle - ), - el( - PanelRow, - {}, - el( - Button, - { isPrimary: true, onClick: props.onReset }, - __( 'Reset' ) - ) - ) - ) - ); -} - -var SidebarContentsWithDataHandling = compose( [ - withSelect( function( select ) { - return { - title: select( 'core/editor' ).getEditedPostAttribute( 'title' ), - }; - } ), - withDispatch( function( dispatch ) { - return { - onReset: function() { - dispatch( 'core/editor' ).editPost( { - title: '' - } ); - } - }; - } ) -] )( SidebarContents ); - -function MyTitlePlugin() { - return ( - el( - Fragment, - {}, - el( - PluginSidebar, - { - name: 'my-title-sidebar', - title: 'My title plugin' - }, - el( - SidebarContentsWithDataHandling, - {} - ) - ), - el( - PluginSidebarMoreMenuItem, - { - target: 'my-title-sidebar' - }, - __( 'My title plugin' ) - ) - ) - ); -} - -registerPlugin( 'my-title-plugin', { - icon: 'welcome-write-blog', - render: MyTitlePlugin -} ); diff --git a/test/e2e/test-plugins/plugins-api/post-status-info.js b/test/e2e/test-plugins/plugins-api/post-status-info.js new file mode 100644 index 0000000000000..0491bff0f2186 --- /dev/null +++ b/test/e2e/test-plugins/plugins-api/post-status-info.js @@ -0,0 +1,18 @@ +var el = wp.element.createElement; +var __ = wp.i18n.__; +var registerPlugin = wp.plugins.registerPlugin; +var PluginPostStatusInfo = wp.editPost.PluginPostStatusInfo; + +function MyPostStatusInfoPlugin() { + return el( + PluginPostStatusInfo, + { + className: 'my-post-status-info-plugin', + }, + __( 'My post status info' ) + ); +} + +registerPlugin( 'my-post-status-info-plugin', { + render: MyPostStatusInfoPlugin +} ); diff --git a/test/e2e/test-plugins/plugins-api/publish-panel.js b/test/e2e/test-plugins/plugins-api/publish-panel.js new file mode 100644 index 0000000000000..d80d0a1cb2d88 --- /dev/null +++ b/test/e2e/test-plugins/plugins-api/publish-panel.js @@ -0,0 +1,47 @@ +var el = wp.element.createElement; +var Fragment = wp.element.Fragment; +var __ = wp.i18n.__; +var registerPlugin = wp.plugins.registerPlugin; +var PluginPostPublishPanel = wp.editPost.PluginPostPublishPanel; +var PluginPrePublishPanel = wp.editPost.PluginPrePublishPanel; + +function PanelContent() { + return el( + 'p', + {}, + __( 'Here is the panel content!' ) + ); +} + +function MyPublishPanelPlugin() { + return el( + Fragment, + {}, + el( + PluginPrePublishPanel, + { + className: 'my-publish-panel-plugin__pre', + title: __( 'My pre publish panel' ) + }, + el( + PanelContent, + {} + ) + ), + el( + PluginPostPublishPanel, + { + className: 'my-publish-panel-plugin__post', + title: __( 'My post publish panel' ) + }, + el( + PanelContent, + {} + ) + ) + ); +} + +registerPlugin( 'my-publish-panel-plugin', { + render: MyPublishPanelPlugin +} ); diff --git a/test/e2e/test-plugins/plugins-api/sidebar.js b/test/e2e/test-plugins/plugins-api/sidebar.js new file mode 100644 index 0000000000000..4f443ed3ecf7b --- /dev/null +++ b/test/e2e/test-plugins/plugins-api/sidebar.js @@ -0,0 +1,106 @@ +var Button = wp.components.Button; +var PanelBody = wp.components.PanelBody; +var PanelRow = wp.components.PanelRow; +var withDispatch = wp.data.withDispatch; +var withSelect = wp.data.withSelect; +var PlainText = wp.editor.PlainText; +var Fragment = wp.element.Fragment; +var compose = wp.element.compose; +var el = wp.element.createElement; +var __ = wp.i18n.__; +var registerPlugin = wp.plugins.registerPlugin; +var PluginSidebar = wp.editPost.PluginSidebar; +var PluginSidebarMoreMenuItem = wp.editPost.PluginSidebarMoreMenuItem; + +function SidebarContents( props ) { + return el( + PanelBody, + {}, + el( + PanelRow, + {}, + el( + 'label', + { + 'htmlFor': 'title-plain-text' + }, + __( 'Title:' ), + ), + el( + PlainText, + { + id: 'title-plain-text', + onChange: props.updateTitle, + placeholder: __( '(no title)' ), + value: props.title + } + ) + ), + el( + PanelRow, + {}, + el( + Button, + { + isPrimary: true, + onClick: props.resetTitle + }, + __( 'Reset' ) + ) + ) + ); +} + +var SidebarContentsWithDataHandling = compose( [ + withSelect( function( select ) { + return { + title: select( 'core/editor' ).getEditedPostAttribute( 'title' ), + }; + } ), + withDispatch( function( dispatch ) { + function editPost( title ) { + dispatch( 'core/editor' ).editPost( { + title: title + } ); + } + + return { + updateTitle: function( title ) { + editPost( title ); + }, + resetTitle: function() { + editPost( '' ); + } + }; + } ) +] )( SidebarContents ); + +function MySidebarPlugin() { + return el( + Fragment, + {}, + el( + PluginSidebar, + { + name: 'title-sidebar', + title: __( 'Sidebar title plugin' ) + }, + el( + SidebarContentsWithDataHandling, + {} + ) + ), + el( + PluginSidebarMoreMenuItem, + { + target: 'title-sidebar' + }, + __( 'Sidebar title plugin' ) + ) + ); +} + +registerPlugin( 'my-sidebar-plugin', { + icon: 'text', + render: MySidebarPlugin +} ); diff --git a/test/unit/jest.config.json b/test/unit/jest.config.json index 26da40931049f..b95f7e0037a9a 100644 --- a/test/unit/jest.config.json +++ b/test/unit/jest.config.json @@ -1,12 +1,12 @@ { "rootDir": "../../", "collectCoverageFrom": [ - "(blocks|components|editor|utils|edit-post|viewport|plugins|core-blocks|nux)/**/*.js", + "(blocks|components|editor|utils|edit-post|viewport|core-blocks|nux)/**/*.js", "packages/**/*.js" ], "moduleNameMapper": { - "@wordpress\\/(blocks|components|editor|utils|edit-post|viewport|plugins|core-data|core-blocks|nux)$": "$1", - "@wordpress\\/(api-request|blob|core-data|data|date|dom|deprecated|element|postcss-themes)$": "packages/$1/src" + "@wordpress\\/(blocks|components|editor|utils|edit-post|viewport|core-data|core-blocks|nux)$": "$1", + "@wordpress\\/(api-request|blob|core-data|data|date|dom|deprecated|element|plugins|postcss-themes)$": "packages/$1/src" }, "preset": "@wordpress/jest-preset-default", "setupFiles": [ diff --git a/utils/deprecated.js b/utils/deprecated.js index 44447e6a6a940..f30a82d3d9a39 100644 --- a/utils/deprecated.js +++ b/utils/deprecated.js @@ -2,7 +2,6 @@ * WordPress dependencies */ import * as blob from '@wordpress/blob'; -import * as dom from '@wordpress/dom'; import originalDeprecated from '@wordpress/deprecated'; const wrapFunction = ( source, sourceName, version ) => @@ -21,32 +20,6 @@ export const createBlobURL = wrapBlobFunction( 'createBlobURL' ); export const getBlobByURL = wrapBlobFunction( 'getBlobByURL' ); export const revokeBlobURL = wrapBlobFunction( 'revokeBlobURL' ); -// dom -const wrapDomFunction = wrapFunction( dom, 'dom', '3.1' ); -export const computeCaretRect = wrapDomFunction( 'computeCaretRect' ); -export const documentHasSelection = wrapDomFunction( 'documentHasSelection' ); -export const focus = { - focusable: { - find: wrapFunction( dom.focus.focusable, 'dom.focus.focusable', '3.1' )( 'find' ), - }, - tabbable: { - find: wrapFunction( dom.focus.tabbable, 'dom.focus.tabbable', '3.1' )( 'find' ), - isTabbableIndex: wrapFunction( dom.focus.tabbable, 'dom.focus.tabbable', '3.1' )( 'isTabbableIndex' ), - }, -}; -export const getRectangleFromRange = wrapDomFunction( 'getRectangleFromRange' ); -export const getScrollContainer = wrapDomFunction( 'getScrollContainer' ); -export const insertAfter = wrapDomFunction( 'insertAfter' ); -export const isHorizontalEdge = wrapDomFunction( 'isHorizontalEdge' ); -export const isTextField = wrapDomFunction( 'isTextField' ); -export const isVerticalEdge = wrapDomFunction( 'isVerticalEdge' ); -export const placeCaretAtHorizontalEdge = wrapDomFunction( 'placeCaretAtHorizontalEdge' ); -export const placeCaretAtVerticalEdge = wrapDomFunction( 'placeCaretAtVerticalEdge' ); -export const remove = wrapDomFunction( 'remove' ); -export const replace = wrapDomFunction( 'replace' ); -export const replaceTag = wrapDomFunction( 'replaceTag' ); -export const unwrap = wrapDomFunction( 'unwrap' ); - // deprecated export function deprecated( ...params ) { originalDeprecated( 'wp.utils.deprecated', { @@ -57,14 +30,3 @@ export function deprecated( ...params ) { return originalDeprecated( ...params ); } - -// viewport -export function isExtraSmall() { - originalDeprecated( 'wp.utils.isExtraSmall', { - version: '3.1', - alternative: 'wp.viewport.isExtraSmall', - plugin: 'Gutenberg', - } ); - - return window && window.innerWidth < 782; -} diff --git a/utils/mediaupload.js b/utils/mediaupload.js index c42a4996a1c4a..d0c0b7fde029a 100644 --- a/utils/mediaupload.js +++ b/utils/mediaupload.js @@ -1,13 +1,42 @@ /** * External Dependencies */ -import { compact, forEach, get, noop, startsWith } from 'lodash'; +import { compact, flatMap, forEach, get, has, includes, map, noop, startsWith } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; /** * WordPress dependencies */ import apiRequest from '@wordpress/api-request'; +/** + * Browsers may use unexpected mime types, and they differ from browser to browser. + * This function computes a flexible array of mime types from the mime type structured provided by the server. + * Converts { jpg|jpeg|jpe: "image/jpeg" } into [ "image/jpeg", "image/jpg", "image/jpeg", "image/jpe" ] + * The computation of this array instead of directly using the object, + * solves the problem in chrome where mp3 files have audio/mp3 as mime type instead of audio/mpeg. + * https://bugs.chromium.org/p/chromium/issues/detail?id=227004 + * + * @param {?Object} wpMimeTypesObject Mime type object received from the server. + * Extensions are keys separated by '|' and values are mime types associated with an extension. + * + * @return {?Array} An array of mime types or the parameter passed if it was "falsy". + */ +export function getMimeTypesArray( wpMimeTypesObject ) { + if ( ! wpMimeTypesObject ) { + return wpMimeTypesObject; + } + return flatMap( wpMimeTypesObject, ( mime, extensionsString ) => { + const [ type ] = mime.split( '/' ); + const extensions = extensionsString.split( '|' ); + return [ mime, ...map( extensions, ( extension ) => `${ type }/${ extension }` ) ]; + } ); +} + /** * Media Upload is used by audio, image, gallery and video blocks to handle uploading a media file * when a file upload button is activated. @@ -38,15 +67,42 @@ export function mediaUpload( { filesSet[ idx ] = value; onFileChange( compact( filesSet ) ); }; + + // Allowed type specified by consumer const isAllowedType = ( fileType ) => startsWith( fileType, `${ allowedType }/` ); + + // Allowed types for the current WP_User + const allowedMimeTypesForUser = getMimeTypesArray( get( window, [ '_wpMediaSettings', 'allowedMimeTypes' ] ) ); + const isAllowedMimeTypeForUser = ( fileType ) => { + return includes( allowedMimeTypesForUser, fileType ); + }; + files.forEach( ( mediaFile, idx ) => { if ( ! isAllowedType( mediaFile.type ) ) { return; } + // verify if user is allowed to upload this mime type + if ( allowedMimeTypesForUser && ! isAllowedMimeTypeForUser( mediaFile.type ) ) { + onError( { + code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', + message: __( 'Sorry, this file type is not permitted for security reasons.' ), + file: mediaFile, + } ); + return; + } + // verify if file is greater than the maximum file upload size allowed for the site. if ( maxUploadFileSize && mediaFile.size > maxUploadFileSize ) { - onError( { sizeAboveLimit: true, file: mediaFile } ); + onError( { + code: 'SIZE_ABOVE_LIMIT', + message: sprintf( + // translators: %s: file name + __( '%s exceeds the maximum upload size for this site.' ), + mediaFile.name + ), + file: mediaFile, + } ); return; } @@ -66,10 +122,24 @@ export function mediaUpload( { }; setAndUpdateFiles( idx, mediaObject ); }, - () => { + ( response ) => { // Reset to empty on failure. setAndUpdateFiles( idx, null ); - onError( { generalError: true, file: mediaFile } ); + let message; + if ( has( response, [ 'responseJSON', 'message' ] ) ) { + message = get( response, [ 'responseJSON', 'message' ] ); + } else { + message = sprintf( + // translators: %s: file name + __( 'Error while uploading file %s to the media library.' ), + mediaFile.name + ); + } + onError( { + code: 'GENERAL', + message, + file: mediaFile, + } ); } ); } ); diff --git a/utils/test/mediaupload.js b/utils/test/mediaupload.js index 898030d3a2ec5..35dca2906e249 100644 --- a/utils/test/mediaupload.js +++ b/utils/test/mediaupload.js @@ -3,7 +3,7 @@ /** * Internal dependencies */ -import { mediaUpload } from '../mediaupload'; +import { mediaUpload, getMimeTypesArray } from '../mediaupload'; // mediaUpload is passed the onImagesChange function // so we can stub that out have it pass the data to @@ -45,7 +45,7 @@ describe( 'mediaUpload', () => { expect( console.error ).not.toHaveBeenCalled(); } ); - it( 'should call error handler with the correct message if file size is greater than the maximum', () => { + it( 'should call error handler with the correct error object if file size is greater than the maximum', () => { const onError = jest.fn(); mediaUpload( { allowedType: 'image', @@ -54,6 +54,75 @@ describe( 'mediaUpload', () => { maxUploadFileSize: 512, onError, } ); - expect( onError.mock.calls ).toEqual( [ [ { sizeAboveLimit: true, file: validMediaObj } ] ] ); + expect( onError ).toBeCalledWith( { + code: 'SIZE_ABOVE_LIMIT', + file: validMediaObj, + message: `${ validMediaObj.name } exceeds the maximum upload size for this site.`, + } ); + } ); + + it( 'should call error handler with the correct error object if file type is not allowed for user', () => { + const onError = jest.fn(); + global._wpMediaSettings = { + allowedMimeTypes: { aac: 'audio/aac' }, + }; + mediaUpload( { + allowedType: 'image', + filesList: [ validMediaObj ], + onFileChange, + onError, + } ); + expect( onError ).toBeCalledWith( { + code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER', + file: validMediaObj, + message: 'Sorry, this file type is not permitted for security reasons.', + } ); + } ); +} ); + +describe( 'getMimeTypesArray', () => { + it( 'should return the parameter passed if it is "falsy" e.g: undefined or null', () => { + expect( getMimeTypesArray( null ) ).toEqual( null ); + expect( getMimeTypesArray( undefined ) ).toEqual( undefined ); + } ); + + it( 'should return an empty array if an empty object is passed', () => { + expect( getMimeTypesArray( {} ) ).toEqual( [] ); + } ); + + it( 'should return the type plus a new mime type with type and subtype with the extension if a type is passed', () => { + expect( + getMimeTypesArray( { ext: 'chicken' } ) + ).toEqual( + [ 'chicken', 'chicken/ext' ] + ); + } ); + + it( 'should return the mime type passed and a new mime type with type and the extension as subtype', () => { + expect( + getMimeTypesArray( { ext: 'chicken/ribs' } ) + ).toEqual( + [ 'chicken/ribs', 'chicken/ext' ] + ); + } ); + + it( 'should return the mime type passed and an additional mime type per extension supported', () => { + expect( + getMimeTypesArray( { 'jpg|jpeg|jpe': 'image/jpeg' } ) + ).toEqual( + [ 'image/jpeg', 'image/jpg', 'image/jpeg', 'image/jpe' ] + ); + } ); + + it( 'should handle multiple mime types', () => { + expect( + getMimeTypesArray( { 'ext|aaa': 'chicken/ribs', aaa: 'bbb' } ) + ).toEqual( [ + 'chicken/ribs', + 'chicken/ext', + 'chicken/aaa', + 'bbb', + 'bbb/aaa', + ] ); } ); } ); diff --git a/webpack.config.js b/webpack.config.js index 2ceb56aa51a08..a653970a0adf3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -142,21 +142,21 @@ const entryPointNames = [ 'editor', 'utils', 'viewport', - 'plugins', 'edit-post', 'core-blocks', 'nux', ]; const gutenbergPackages = [ + 'api-request', 'blob', + 'core-data', 'data', 'date', 'deprecated', 'dom', 'element', - 'api-request', - 'core-data', + 'plugins', ]; const wordPressPackages = [