diff --git a/docs/manifest.json b/docs/manifest.json index a99e8600b111d..cd91061dc4947 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -218,7 +218,7 @@ { "title": "Articles", "slug": "articles", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/outreach/articles.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/outreach/docs/articles.md", "parent": "outreach" }, { @@ -431,6 +431,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/library-export-default-webpack-plugin/README.md", "parent": "packages" }, + { + "title": "@wordpress/list-reusable-blocks", + "slug": "packages-list-reusable-blocks", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/list-reusable-blocks/README.md", + "parent": "packages" + }, { "title": "@wordpress/npm-package-json-lint-config", "slug": "packages-npm-package-json-lint-config", @@ -857,4 +863,4 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-viewport.md", "parent": "data" } -] +] \ No newline at end of file diff --git a/edit-post/components/header/more-menu/index.js b/edit-post/components/header/more-menu/index.js index 42ab8d1756e84..17ea090a1b710 100644 --- a/edit-post/components/header/more-menu/index.js +++ b/edit-post/components/header/more-menu/index.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __, _x } from '@wordpress/i18n'; -import { IconButton, Dropdown, MenuGroup } from '@wordpress/components'; +import { IconButton, Dropdown, MenuGroup, MenuItem } from '@wordpress/components'; import { Fragment } from '@wordpress/element'; /** @@ -37,6 +37,12 @@ const MoreMenu = () => ( label={ __( 'Tools' ) } filterName="editPost.MoreMenu.tools" > + + { __( 'Manage All Reusable Blocks' ) } + diff --git a/gutenberg.php b/gutenberg.php index 3ef79b7b377ea..342975164a606 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -260,6 +260,12 @@ function gutenberg_add_edit_link( $actions, $post ) { if ( 'wp_block' === $post->post_type ) { unset( $actions['edit'] ); unset( $actions['inline hide-if-no-js'] ); + $actions['export'] = sprintf( + '%s', + $post->ID, + __( 'Export as JSON', 'gutenberg' ), + __( 'Export as JSON', 'gutenberg' ) + ); return $actions; } diff --git a/lib/client-assets.php b/lib/client-assets.php index ccb15197caada..9eddc4fabe0d6 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -632,6 +632,22 @@ function gutenberg_register_scripts_and_styles() { true ); + wp_register_script( + 'wp-list-reusable-blocks', + gutenberg_url( 'build/list-reusable-blocks/index.js' ), + array( + 'lodash', + 'wp-api-fetch', + 'wp-components', + 'wp-compose', + 'wp-element', + 'wp-i18n', + 'wp-polyfill-ecmascript', + ), + filemtime( gutenberg_dir_path() . 'build/list-reusable-blocks/index.js' ), + true + ); + // Editor Styles. // This empty stylesheet is defined to ensure backwards compatibility. wp_register_style( 'wp-blocks', false ); @@ -718,6 +734,14 @@ function gutenberg_register_scripts_and_styles() { ); wp_style_add_data( 'wp-block-library-theme', 'rtl', 'replace' ); + wp_register_style( + 'wp-list-reusable-blocks', + gutenberg_url( 'build/list-reusable-blocks/style.css' ), + array( 'wp-components' ), + filemtime( gutenberg_dir_path() . 'build/list-reusable-blocks/style.css' ) + ); + wp_style_add_data( 'wp-list-reusable-block', 'rtl', 'replace' ); + if ( defined( 'GUTENBERG_LIVE_RELOAD' ) && GUTENBERG_LIVE_RELOAD ) { $live_reload_url = ( GUTENBERG_LIVE_RELOAD === true ) ? 'http://localhost:35729/livereload.js' : GUTENBERG_LIVE_RELOAD; @@ -1513,3 +1537,17 @@ function gutenberg_editor_scripts_and_styles( $hook ) { */ do_action( 'enqueue_block_editor_assets' ); } + +/** + * Enqueue the reusable blocks listing page's script + * + * @param string $hook Screen name. + */ +function wp_load_list_reusable_blocks( $hook ) { + $is_reusable_blocks_list_page = 'edit.php' === $hook && isset( $_GET['post_type'] ) && 'wp_block' === $_GET['post_type']; + if ( $is_reusable_blocks_list_page ) { + wp_enqueue_script( 'wp-list-reusable-blocks' ); + wp_enqueue_style( 'wp-list-reusable-blocks' ); + } +} +add_action( 'admin_enqueue_scripts', 'wp_load_list_reusable_blocks' ); diff --git a/package-lock.json b/package-lock.json index b325cc394e67d..26afa019866fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20576,7 +20576,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { diff --git a/packages/components/src/menu-item/index.js b/packages/components/src/menu-item/index.js index 27460ce0e0f1e..906f4e5df2bd4 100644 --- a/packages/components/src/menu-item/index.js +++ b/packages/components/src/menu-item/index.js @@ -21,7 +21,7 @@ import IconButton from '../icon-button'; * * @return {WPElement} More menu item. */ -function MenuItem( { children, className, icon, onClick, shortcut, isSelected, role = 'menuitem', ...props } ) { +function MenuItem( { children, className, icon, shortcut, isSelected, role = 'menuitem', ...props } ) { className = classnames( 'components-menu-item__button', className, { 'has-icon': icon, } ); @@ -39,7 +39,6 @@ function MenuItem( { children, className, icon, onClick, shortcut, isSelected, r

Code is Poetry.

diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json new file mode 100644 index 0000000000000..ec0318fd7908a --- /dev/null +++ b/packages/list-reusable-blocks/package.json @@ -0,0 +1,34 @@ +{ + "name": "@wordpress/list-reusable-blocks", + "version": "1.0.0", + "description": "Adding Export/Import support to the reusable blocks listing.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "templates", + "reusable blocks" + ], + "private": true, + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/list-reusable-blocks/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "dependencies": { + "@babel/runtime": "^7.0.0", + "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "lodash": "^4.17.10" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/list-reusable-blocks/src/components/import-dropdown/index.js b/packages/list-reusable-blocks/src/components/import-dropdown/index.js new file mode 100644 index 0000000000000..18d1ffe7b242d --- /dev/null +++ b/packages/list-reusable-blocks/src/components/import-dropdown/index.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { flow } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Dropdown, Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ImportForm from '../import-form'; + +function ImportDropdown( { onUpload } ) { + return ( + ( + + ) } + renderContent={ ( { onClose } ) => ( + + ) } + /> + ); +} + +export default ImportDropdown; diff --git a/packages/list-reusable-blocks/src/components/import-dropdown/style.scss b/packages/list-reusable-blocks/src/components/import-dropdown/style.scss new file mode 100644 index 0000000000000..8c2dc9c94bd71 --- /dev/null +++ b/packages/list-reusable-blocks/src/components/import-dropdown/style.scss @@ -0,0 +1,3 @@ +.list-reusable-blocks-import-dropdown__content .components-popover__content { + padding: 10px; +} diff --git a/packages/list-reusable-blocks/src/components/import-form/index.js b/packages/list-reusable-blocks/src/components/import-form/index.js new file mode 100644 index 0000000000000..b5169e376da4f --- /dev/null +++ b/packages/list-reusable-blocks/src/components/import-form/index.js @@ -0,0 +1,113 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { Button, Notice } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import importReusableBlock from '../../utils/import'; + +class ImportForm extends Component { + constructor() { + super( ...arguments ); + this.state = { + isLoading: false, + error: null, + file: null, + }; + + this.isStillMounted = true; + this.onChangeFile = this.onChangeFile.bind( this ); + this.onSubmit = this.onSubmit.bind( this ); + } + + componentWillUnmount() { + this.isStillMounted = false; + } + + onChangeFile( event ) { + this.setState( { file: event.target.files[ 0 ] } ); + } + + onSubmit( event ) { + event.preventDefault(); + const { file } = this.state; + const { onUpload } = this.props; + if ( ! file ) { + return; + } + this.setState( { isLoading: true } ); + importReusableBlock( file ) + .then( ( reusableBlock ) => { + if ( ! this.isStillMounted ) { + return; + } + + this.setState( { isLoading: false } ); + onUpload( reusableBlock ); + } ) + .catch( ( error ) => { + if ( ! this.isStillMounted ) { + return; + } + + let uiMessage; + switch ( error.message ) { + case 'Invalid JSON file': + uiMessage = __( 'Invalid JSON file' ); + break; + case 'Invalid Reusable Block JSON file': + uiMessage = __( 'Invalid Reusable Block JSON file' ); + break; + default: + uiMessage = __( 'Unknow error' ); + } + + this.setState( { isLoading: false, error: uiMessage } ); + } ); + } + + render() { + const { instanceId } = this.props; + const { file, isLoading, error } = this.state; + const inputId = 'list-reusable-blocks-import-form-' + instanceId; + return ( +
+ { error && ( + + { error } + + ) } + + + +
+ ); + } +} + +export default withInstanceId( ImportForm ); diff --git a/packages/list-reusable-blocks/src/components/import-form/style.scss b/packages/list-reusable-blocks/src/components/import-form/style.scss new file mode 100644 index 0000000000000..184eaeb3a1f2d --- /dev/null +++ b/packages/list-reusable-blocks/src/components/import-form/style.scss @@ -0,0 +1,13 @@ +.list-reusable-blocks-import-form__label { + display: block; + margin-bottom: 10px; +} + +.list-reusable-blocks-import-form__button { + margin-top: 20px; + float: right; +} + +.list-reusable-blocks-import-form .components-notice__content { + margin: 0; +} diff --git a/packages/list-reusable-blocks/src/index.js b/packages/list-reusable-blocks/src/index.js new file mode 100644 index 0000000000000..76be468bdbd2c --- /dev/null +++ b/packages/list-reusable-blocks/src/index.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { render } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import exportReusableBlock from './utils/export'; +import ImportDropdown from './components/import-dropdown'; + +// Setup Export Links +document.body.addEventListener( 'click', ( event ) => { + if ( ! event.target.classList.contains( 'wp-list-reusable-blocks__export' ) ) { + return; + } + event.preventDefault(); + exportReusableBlock( event.target.dataset.id ); +} ); + +// Setup Import Form +document.addEventListener( 'DOMContentLoaded', () => { + const button = document.querySelector( '.page-title-action' ); + if ( ! button ) { + return; + } + + const showNotice = () => { + const notice = document.createElement( 'div' ); + notice.className = 'notice notice-success is-dismissible'; + notice.innerHTML = `

${ __( 'Reusable block imported successfully!' ) }

`; + + const headerEnd = document.querySelector( '.wp-header-end' ); + if ( ! headerEnd ) { + return; + } + headerEnd.parentNode.insertBefore( notice, headerEnd ); + }; + + const container = document.createElement( 'div' ); + container.className = 'list-reusable-blocks__container'; + button.parentNode.insertBefore( container, button ); + render( , container ); +} ); diff --git a/packages/list-reusable-blocks/src/style.scss b/packages/list-reusable-blocks/src/style.scss new file mode 100644 index 0000000000000..50445821dec62 --- /dev/null +++ b/packages/list-reusable-blocks/src/style.scss @@ -0,0 +1,9 @@ +@import "./components/import-dropdown/style.scss"; +@import "./components/import-form/style.scss"; + +.list-reusable-blocks__container { + display: inline-flex; + padding: 9px 0 4px; // To match the H1 + align-items: center; + vertical-align: top; +} diff --git a/packages/list-reusable-blocks/src/utils/export.js b/packages/list-reusable-blocks/src/utils/export.js new file mode 100644 index 0000000000000..e3ca3b8b07788 --- /dev/null +++ b/packages/list-reusable-blocks/src/utils/export.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { pick, kebabCase } from 'lodash'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { download } from './file'; + +/** + * Export a reusable block as a JSON file. + * + * @param {number} id + */ +async function exportReusableBlock( id ) { + const postType = await apiFetch( { path: `/wp/v2/types/wp_block` } ); + const reusableBlock = await apiFetch( { path: `/wp/v2/${ postType.rest_base }/${ id }` } ); + const fileContent = JSON.stringify( { + __file: 'wp_block', + ...pick( reusableBlock, [ 'title', 'content' ] ), + }, null, 2 ); + const fileName = kebabCase( reusableBlock.title ) + '.json'; + + download( fileName, fileContent, 'application/json' ); +} + +export default exportReusableBlock; diff --git a/packages/list-reusable-blocks/src/utils/file.js b/packages/list-reusable-blocks/src/utils/file.js new file mode 100644 index 0000000000000..06875fd2009c4 --- /dev/null +++ b/packages/list-reusable-blocks/src/utils/file.js @@ -0,0 +1,30 @@ +/** + * Downloads a file. + * + * @param {string} fileName File Name. + * @param {string} content File Content. + * @param {string} contentType File mime type. + */ +export function download( fileName, content, contentType ) { + const a = document.createElement( 'a' ); + const file = new window.Blob( [ content ], { type: contentType } ); + a.href = URL.createObjectURL( file ); + a.download = fileName; + a.click(); +} + +/** + * Reads the textual content of the given file. + * + * @param {File} file File. + * @return {Promise} Content of the file. + */ +export function readTextFile( file ) { + const reader = new window.FileReader(); + return new Promise( ( resolve ) => { + reader.onload = function() { + resolve( reader.result ); + }; + reader.readAsText( file ); + } ); +} diff --git a/packages/list-reusable-blocks/src/utils/import.js b/packages/list-reusable-blocks/src/utils/import.js new file mode 100644 index 0000000000000..6bf0204895284 --- /dev/null +++ b/packages/list-reusable-blocks/src/utils/import.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { isString } from 'lodash'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { readTextFile } from './file'; + +/** + * Import a reusable block from a JSON file. + * + * @param {File} file File. + * @return {Promise} Promise returning the imported reusable block. + */ +async function importReusableBlock( file ) { + const fileContent = await readTextFile( file ); + let parsedContent; + try { + parsedContent = JSON.parse( fileContent ); + } catch ( e ) { + throw new Error( 'Invalid JSON file' ); + } + if ( + parsedContent.__file !== 'wp_block' || + ! parsedContent.title || + ! parsedContent.content || + ! isString( parsedContent.title ) || + ! isString( parsedContent.content ) + ) { + throw new Error( 'Invalid Reusable Block JSON file' ); + } + const postType = await apiFetch( { path: `/wp/v2/types/wp_block` } ); + const reusableBlock = await apiFetch( { + path: `/wp/v2/${ postType.rest_base }`, + data: { + title: parsedContent.title, + content: parsedContent.content, + }, + method: 'POST', + } ); + + return reusableBlock; +} + +export default importReusableBlock; diff --git a/test/e2e/assets/greeting-reusable-block.json b/test/e2e/assets/greeting-reusable-block.json new file mode 100644 index 0000000000000..86427882bbb6a --- /dev/null +++ b/test/e2e/assets/greeting-reusable-block.json @@ -0,0 +1,5 @@ +{ + "__file": "wp_block", + "title": "Greeting", + "content": "\n

Hello there

\n" +} \ No newline at end of file diff --git a/test/e2e/specs/manage-reusable-blocks.test.js b/test/e2e/specs/manage-reusable-blocks.test.js new file mode 100644 index 0000000000000..922969f67155f --- /dev/null +++ b/test/e2e/specs/manage-reusable-blocks.test.js @@ -0,0 +1,42 @@ +/** + * Node dependencies + */ +import path from 'path'; + +/** + * Internal dependencies + */ +import { visitAdmin } from '../support/utils'; + +describe( 'Managing reusable blocks', () => { + beforeAll( async () => { + await visitAdmin( 'edit.php', 'post_type=wp_block' ); + } ); + + it( 'Should import reusable blocoks', async () => { + // Import Reusable block + await page.waitForSelector( '.list-reusable-blocks__container' ); + const importButton = await page.$( '.list-reusable-blocks__container button' ); + await importButton.click(); + + // Select the file to upload + const testReusableBlockFile = path.join( __dirname, '..', 'assets', 'greeting-reusable-block.json' ); + const input = await page.$( '.list-reusable-blocks-import-form input' ); + await input.uploadFile( testReusableBlockFile ); + + // Submit the form + const button = await page.$( '.list-reusable-blocks-import-form__button' ); + await button.click(); + + // Wait for the success notice + await page.waitForSelector( '.notice-success' ); + const noticeContent = await page.$eval( '.notice-success', ( element ) => element.textContent ); + expect( noticeContent ).toEqual( 'Reusable block imported successfully!' ); + + // Refresh the page + await visitAdmin( 'edit.php', 'post_type=wp_block' ); + + // The reusable block has been imported + page.waitForXPath( 'div[@class="post_title"][contains(text(), "Greeting")]' ); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 2a926a952255a..fbd8a0feb0d45 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -103,6 +103,7 @@ const gutenbergPackages = [ 'i18n', 'is-shallow-equal', 'keycodes', + 'list-reusable-blocks', 'nux', 'plugins', 'redux-routine',