diff --git a/components/higher-order/with-notices/index.js b/components/higher-order/with-notices/index.js new file mode 100644 index 00000000000000..b9decbf572e505 --- /dev/null +++ b/components/higher-order/with-notices/index.js @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import uuid from 'uuid/v4'; + +/** + * WordPress dependencies + */ +import { Component, createHigherOrderComponent } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import NoticeList from '../../notice/list'; + +/** + * Override the default edit UI to include notices if supported. + * + * @param {function|Component} OriginalComponent Original component. + * @return {Component} Wrapped component. + */ +export default createHigherOrderComponent( ( OriginalComponent ) => { + return class WrappedBlockEdit extends Component { + constructor() { + super( ...arguments ); + + this.createNotice = this.createNotice.bind( this ); + this.createErrorNotice = this.createErrorNotice.bind( this ); + this.removeNotice = this.removeNotice.bind( this ); + this.removeAllNotices = this.removeAllNotices.bind( this ); + + this.state = { + noticeList: [], + }; + + this.noticeOperations = { + createNotice: this.createNotice, + createErrorNotice: this.createErrorNotice, + removeAllNotices: this.removeAllNotices, + removeNotice: this.removeNotice, + }; + } + + /** + * Function passed down as a prop that adds a new notice. + * + * @param {Object} notice Notice to add. + */ + createNotice( notice ) { + const noticeToAdd = notice.id ? notice : { ...notice, id: uuid() }; + this.setState( ( state ) => ( { + noticeList: [ ...state.noticeList, noticeToAdd ], + } ) ); + } + + /** + * Function passed as a prop that adds a new error notice. + * + * @param {string} msg Error message of the notice. + */ + createErrorNotice( msg ) { + this.createNotice( { status: 'error', content: msg } ); + } + + /** + * Removes a notice by id. + * + * @param {string} id Id of the notice to remove. + */ + removeNotice( id ) { + this.setState( ( state ) => ( { + noticeList: state.noticeList.filter( ( notice ) => notice.id !== id ), + } ) ); + } + + /** + * Removes all notices + */ + removeAllNotices() { + this.setState( { + noticeList: [], + } ); + } + + render() { + return ( + 0 && + } + { ...this.props } + /> + ); + } + }; +} ); diff --git a/components/index.js b/components/index.js index 3f76939bcfac26..0d20eb59de1869 100644 --- a/components/index.js +++ b/components/index.js @@ -66,6 +66,7 @@ export { default as withFocusOutside } from './higher-order/with-focus-outside'; export { default as withFocusReturn } from './higher-order/with-focus-return'; export { default as withGlobalEvents } from './higher-order/with-global-events'; export { default as withInstanceId } from './higher-order/with-instance-id'; +export { default as withNotices } from './higher-order/with-notices'; export { default as withSafeTimeout } from './higher-order/with-safe-timeout'; export { default as withSpokenMessages } from './higher-order/with-spoken-messages'; export { default as withState } from './higher-order/with-state'; diff --git a/components/notice/list.js b/components/notice/list.js index 9f0f1f1063f034..369c8819fe72ca 100644 --- a/components/notice/list.js +++ b/components/notice/list.js @@ -8,11 +8,21 @@ import { noop, omit } from 'lodash'; */ import Notice from './'; -function NoticeList( { notices, onRemove = noop, children } ) { +/** +* Renders a list of notices. +* +* @param {Object} $0 Props passed to the component. +* @param {Array} $0.notices Array of notices to render. +* @param {Function} $0.onRemove Function called when a notice should be removed / dismissed. +* @param {Object} $0.className Name of the class used by the component. +* @param {Object} $0.children Array of childs to be rendered inside the notice list. +* @return {Object} The rendered notices list. +*/ +function NoticeList( { notices, onRemove = noop, className = 'components-notice-list', children } ) { const removeNotice = ( id ) => () => onRemove( id ); return ( -
+
{ children } { [ ...notices ].reverse().map( ( notice ) => ( diff --git a/components/placeholder/index.js b/components/placeholder/index.js index 884c2e241b4be4..354730b720c993 100644 --- a/components/placeholder/index.js +++ b/components/placeholder/index.js @@ -10,11 +10,18 @@ import { isString } from 'lodash'; import './style.scss'; import Dashicon from '../dashicon'; -function Placeholder( { icon, children, label, instructions, className, ...additionalProps } ) { +/** +* Renders a placeholder. Normally used by blocks to render their empty state. +* +* @param {Object} props The component props. +* @return {Object} The rendered placeholder. +*/ +function Placeholder( { icon, children, label, instructions, className, notices, ...additionalProps } ) { const classes = classnames( 'components-placeholder', className ); return (
+ { notices }
{ isString( icon ) ? : icon } { label } diff --git a/core-blocks/gallery/edit.js b/core-blocks/gallery/edit.js index e0fada44181cbe..340ada695bc98b 100644 --- a/core-blocks/gallery/edit.js +++ b/core-blocks/gallery/edit.js @@ -17,6 +17,7 @@ import { SelectControl, ToggleControl, Toolbar, + withNotices, } from '@wordpress/components'; import { BlockControls, @@ -44,7 +45,7 @@ export function defaultColumnsNumber( attributes ) { return Math.min( 3, attributes.images.length ); } -export default class GalleryEdit extends Component { +class GalleryEdit extends Component { constructor() { super( ...arguments ); @@ -135,16 +136,17 @@ export default class GalleryEdit extends Component { addFiles( files ) { const currentImages = this.props.attributes.images || []; - const { setAttributes } = this.props; - editorMediaUpload( - files, - ( images ) => { + const { noticeOperations, setAttributes } = this.props; + editorMediaUpload( { + allowedType: 'image', + filesList: files, + onFileChange: ( images ) => { setAttributes( { images: currentImages.concat( images ), } ); }, - 'image', - ); + onError: noticeOperations.createErrorNotice, + } ); } componentWillReceiveProps( nextProps ) { @@ -158,7 +160,7 @@ export default class GalleryEdit extends Component { } render() { - const { attributes, isSelected, className } = this.props; + const { attributes, isSelected, className, noticeOperations, noticeUI } = this.props; const { images, columns = defaultColumnsNumber( attributes ), align, imageCrop, linkTo } = attributes; const dropZone = ( @@ -210,6 +212,8 @@ export default class GalleryEdit extends Component { accept="image/*" type="image" multiple + notices={ noticeUI } + onError={ noticeOperations.createErrorNotice } /> ); @@ -241,6 +245,7 @@ export default class GalleryEdit extends Component { /> + { noticeUI }
    { dropZone } { images.map( ( img, index ) => ( @@ -276,3 +281,5 @@ export default class GalleryEdit extends Component { ); } } + +export default withNotices( GalleryEdit ); diff --git a/core-blocks/gallery/index.js b/core-blocks/gallery/index.js index d791771fbff566..3b1072d593444a 100644 --- a/core-blocks/gallery/index.js +++ b/core-blocks/gallery/index.js @@ -131,11 +131,11 @@ export const settings = { }, transform( files, onChange ) { const block = createBlock( 'core/gallery' ); - editorMediaUpload( - files, - ( images ) => onChange( block.uid, { images } ), - 'image' - ); + editorMediaUpload( { + filesList: files, + onFileChange: ( images ) => onChange( block.uid, { images } ), + allowedType: 'image', + } ); return block; }, }, diff --git a/core-blocks/image/edit.js b/core-blocks/image/edit.js index 926926fd5b63fe..2ab94e4f285793 100644 --- a/core-blocks/image/edit.js +++ b/core-blocks/image/edit.js @@ -26,6 +26,7 @@ import { TextControl, TextareaControl, Toolbar, + withNotices, } from '@wordpress/components'; import { withSelect } from '@wordpress/data'; import { @@ -78,13 +79,13 @@ class ImageEdit extends Component { getBlobByURL( url ) .then( ( file ) => - editorMediaUpload( - [ file ], - ( [ image ] ) => { + editorMediaUpload( { + filesList: [ file ], + onFileChange: ( [ image ] ) => { setAttributes( { ...image } ); }, - 'image' - ) + allowedType: 'image', + } ) ); } } @@ -107,6 +108,15 @@ class ImageEdit extends Component { } onSelectImage( media ) { + if ( ! media ) { + this.props.setAttributes( { + url: undefined, + alt: undefined, + id: undefined, + caption: undefined, + } ); + return; + } this.props.setAttributes( { ...pick( media, [ 'alt', 'id', 'caption', 'url' ] ), width: undefined, @@ -168,7 +178,7 @@ class ImageEdit extends Component { } render() { - const { attributes, setAttributes, isLargeViewport, isSelected, className, maxWidth, toggleSelection } = this.props; + const { attributes, setAttributes, isLargeViewport, isSelected, className, maxWidth, noticeOperations, noticeUI, toggleSelection } = this.props; const { url, alt, caption, align, id, href, width, height } = attributes; const controls = ( @@ -211,6 +221,8 @@ class ImageEdit extends Component { } } className={ className } onSelect={ this.onSelectImage } + notices={ noticeUI } + onError={ noticeOperations.createErrorNotice } accept="image/*" type="image" /> @@ -306,6 +318,7 @@ class ImageEdit extends Component { return ( { controls } + { noticeUI }
    { ( sizes ) => { @@ -406,4 +419,5 @@ export default compose( [ }; } ), withViewportMatch( { isLargeViewport: 'medium' } ), + withNotices, ] )( ImageEdit ); diff --git a/editor/components/block-list/style.scss b/editor/components/block-list/style.scss index d1696ef9b0b275..6e6462b1ddfbfd 100644 --- a/editor/components/block-list/style.scss +++ b/editor/components/block-list/style.scss @@ -132,6 +132,26 @@ margin-bottom: $block-spacing; + /** + * Notices + */ + .components-placeholder .components-with-notices-ui { + margin: -10px 20px 12px 20px; + width: calc( 100% - 40px ); + } + .components-with-notices-ui { + margin: 0 0 12px 0; + width: 100%; + + .notice { + margin-left: 0px; + margin-right: 0px; + + p { + font-size: $default-font-size; + } + } + } /** * Warnings diff --git a/editor/components/media-placeholder/index.js b/editor/components/media-placeholder/index.js index 974b7f34cdbfea..de4b76c93907b4 100644 --- a/editor/components/media-placeholder/index.js +++ b/editor/components/media-placeholder/index.js @@ -63,9 +63,14 @@ class MediaPlaceholder extends Component { } onFilesUpload( files ) { - const { onSelect, type, multiple } = this.props; + const { onSelect, type, multiple, onError } = this.props; const setMedia = multiple ? onSelect : ( [ media ] ) => onSelect( media ); - editorMediaUpload( files, setMedia, type ); + editorMediaUpload( { + allowedType: type, + filesList: files, + onFileChange: setMedia, + onError, + } ); } render() { @@ -80,6 +85,7 @@ class MediaPlaceholder extends Component { onSelectUrl, onHTMLDrop = noop, multiple = false, + notices, } = this.props; return ( @@ -88,6 +94,7 @@ class MediaPlaceholder extends Component { label={ labels.title } instructions={ sprintf( __( 'Drag %s, upload a new one or select a file from your library.' ), labels.name ) } className={ classnames( 'editor-media-placeholder', className ) } + notices={ notices } > { + let errorMsg; + if ( sizeAboveLimit ) { + errorMsg = sprintf( + __( '%s exceeds the maximum upload size for this site.' ), + file.name + ); + } else if ( generalError ) { + errorMsg = sprintf( + __( 'Error while uploading file %s to the media library.' ), + file.name + ); + } + onError( errorMsg ); + }; + + mediaUpload( { + allowedType, + filesList, + onFileChange, + additionalData: { + post: postId, + }, + maxUploadFileSize, + onError: errorHandler, } ); } diff --git a/lib/client-assets.php b/lib/client-assets.php index 533d3a02484070..b8f8eb024c973d 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -965,6 +965,15 @@ function gutenberg_editor_scripts_and_styles( $hook ) { ); } + $max_upload_size = wp_max_upload_size(); + if ( ! $max_upload_size ) { + $max_upload_size = 0; + } + // Initialize media settings. + wp_add_inline_script( 'wp-editor', 'window._wpMediaSettings = ' . wp_json_encode( array( + 'maxUploadSize' => $max_upload_size, + ) ), 'before' ); + // Prepare Jed locale data. $locale_data = gutenberg_get_jed_locale_data( 'gutenberg' ); wp_add_inline_script( diff --git a/utils/mediaupload.js b/utils/mediaupload.js index be838b1d345e65..4f6d28284b2912 100644 --- a/utils/mediaupload.js +++ b/utils/mediaupload.js @@ -1,7 +1,7 @@ /** * External Dependencies */ -import { compact, forEach, get, startsWith } from 'lodash'; +import { compact, forEach, get, noop, startsWith } from 'lodash'; /** * Media Upload is used by audio, image, gallery and video blocks to handle uploading a media file @@ -9,12 +9,22 @@ import { compact, forEach, get, startsWith } from 'lodash'; * * TODO: future enhancement to add an upload indicator. * - * @param {Array} filesList List of files. - * @param {Function} onFileChange Function to be called each time a file or a temporary representation of the file is available. - * @param {string} allowedType The type of media that can be uploaded. - * @param {?Object} additionalData Additional data to include in the request. + * @param {Object} $0 Parameters object passed to the function. + * @param {string} $0.allowedType The type of media that can be uploaded. + * @param {?Object} $0.additionalData Additional data to include in the request. + * @param {Array} $0.filesList List of files. + * @param {?number} $0.maxUploadFileSize Maximum upload size in bytes allowed for the site. + * @param {Function} $0.onError Function called when an error happens. + * @param {Function} $0.onFileChange Function called each time a file or a temporary representation of the file is available. */ -export function mediaUpload( filesList, onFileChange, allowedType, additionalData = {} ) { +export function mediaUpload( { + allowedType, + additionalData = {}, + filesList, + maxUploadFileSize = get( window, [ '_wpMediaSettings', 'maxUploadSize' ], 0 ), + onError = noop, + onFileChange, +} ) { // Cast filesList to array const files = [ ...filesList ]; @@ -29,6 +39,12 @@ export function mediaUpload( filesList, onFileChange, allowedType, additionalDat 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 } ); + return; + } + // Set temporary URL to create placeholder media file, this is replaced // with final file from media gallery when upload is `done` below filesSet.push( { url: window.URL.createObjectURL( mediaFile ) } ); @@ -49,8 +65,8 @@ export function mediaUpload( filesList, onFileChange, allowedType, additionalDat }, () => { // Reset to empty on failure. - // TODO: Better failure messaging setAndUpdateFiles( idx, null ); + onError( { generalError: true, file: mediaFile } ); } ); } ); diff --git a/utils/test/mediaupload.js b/utils/test/mediaupload.js index 0b3629934aef12..898030d3a2ec55 100644 --- a/utils/test/mediaupload.js +++ b/utils/test/mediaupload.js @@ -5,16 +5,23 @@ */ import { mediaUpload } from '../mediaupload'; -// mediaUpload is passed the setAttributes function +// mediaUpload is passed the onImagesChange function // so we can stub that out have it pass the data to // console.error to check if proper thing is called -const onImagesChange = ( obj ) => console.error( obj ); +const onFileChange = ( obj ) => console.error( obj ); const invalidMediaObj = { url: 'https://cldup.com/uuUqE_dXzy.jpg', type: 'text/xml', }; +const validMediaObj = { + url: 'https://cldup.com/uuUqE_dXzy.jpg', + type: 'image/jpeg', + size: 1024, + name: 'test.jpeg', +}; + describe( 'mediaUpload', () => { const originalConsoleError = console.error; const originalGetUserSetting = window.getUserSetting; @@ -29,12 +36,24 @@ describe( 'mediaUpload', () => { } ); it( 'should do nothing on no files', () => { - mediaUpload( [ ], onImagesChange ); + mediaUpload( { filesList: [ ], onFileChange, allowedType: 'image' } ); expect( console.error ).not.toHaveBeenCalled(); } ); it( 'should do nothing on invalid image type', () => { - mediaUpload( [ invalidMediaObj ], onImagesChange ); + mediaUpload( { filesList: [ invalidMediaObj ], onFileChange, allowedType: 'image' } ); expect( console.error ).not.toHaveBeenCalled(); } ); + + it( 'should call error handler with the correct message if file size is greater than the maximum', () => { + const onError = jest.fn(); + mediaUpload( { + allowedType: 'image', + filesList: [ validMediaObj ], + onFileChange, + maxUploadFileSize: 512, + onError, + } ); + expect( onError.mock.calls ).toEqual( [ [ { sizeAboveLimit: true, file: validMediaObj } ] ] ); + } ); } );