Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reusable Blocks: Support importing and exporting reusable blocks #9788

Merged
merged 8 commits into from Sep 14, 2018
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/manifest.json
Expand Up @@ -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"
},
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -857,4 +863,4 @@
"markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-viewport.md",
"parent": "data"
}
]
]
8 changes: 7 additions & 1 deletion edit-post/components/header/more-menu/index.js
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -37,6 +37,12 @@ const MoreMenu = () => (
label={ __( 'Tools' ) }
filterName="editPost.MoreMenu.tools"
>
<MenuItem
role="menuitem"
href="edit.php?post_type=wp_block"
>
{ __( 'Manage All Reusable Blocks' ) }
</MenuItem>
<TipsToggle onToggle={ onClose } />
<KeyboardShortcutsHelpMenuItem onSelect={ onClose } />
</MenuGroup>
Expand Down
6 changes: 6 additions & 0 deletions gutenberg.php
Expand Up @@ -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(
'<a class="wp-list-reusable-blocks__export" href="#" data-id="%s" aria-label="%s">%s</a>',
$post->ID,
__( 'Export as JSON', 'gutenberg' ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this convention lifted from somewhere? Doesn't seem obvious why we'd need an aria-label which has the same text as the element itself.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, it's inspired by the way we add the "Classic editor" link in the post list actions

__( 'Export as JSON', 'gutenberg' )
);
return $actions;
}

Expand Down
38 changes: 38 additions & 0 deletions lib/client-assets.php
Expand Up @@ -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 );
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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' );
4 changes: 1 addition & 3 deletions packages/components/src/menu-item/index.js
Expand Up @@ -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,
} );
Expand All @@ -39,7 +39,6 @@ function MenuItem( { children, className, icon, onClick, shortcut, isSelected, r
<IconButton
className={ className }
icon={ icon }
onClick={ onClick }
aria-checked={ isSelected }
role={ role }
{ ...props }
Expand All @@ -53,7 +52,6 @@ function MenuItem( { children, className, icon, onClick, shortcut, isSelected, r
return (
<Button
className={ className }
onClick={ onClick }
aria-checked={ isSelected }
role={ role }
{ ...props }
Expand Down
1 change: 1 addition & 0 deletions packages/list-reusable-blocks/.npmrc
@@ -0,0 +1 @@
package-lock=false
5 changes: 5 additions & 0 deletions packages/list-reusable-blocks/README.md
@@ -0,0 +1,5 @@
# Reusable blocks listing page

Package used to add import/export links to the listing page of the reusable blocks.

<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>
34 changes: 34 additions & 0 deletions 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.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really stress-testing our policy of publishing all the things as modules, huh 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made it private :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I think about it. Making it private means it can't be included in Core as an external package and begs the question of whether it should be moved to Core or kept in the repo post-merge.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, I never said I was opposed to it being published to npm 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I know it was private even before the comment :)

"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"
}
}
@@ -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 (
<Dropdown
position="bottom right"
contentClassName="list-reusable-blocks-import-dropdown__content"
renderToggle={ ( { isOpen, onToggle } ) => (
<Button
type="button"
aria-expanded={ isOpen }
onClick={ onToggle }
isPrimary
>
{ __( 'Import from JSON' ) }
</Button>
) }
renderContent={ ( { onClose } ) => (
<ImportForm onUpload={ flow( onClose, onUpload ) } />
) }
/>
);
}

export default ImportDropdown;
@@ -0,0 +1,3 @@
.list-reusable-blocks-import-dropdown__content .components-popover__content {
padding: 10px;
}
101 changes: 101 additions & 0 deletions packages/list-reusable-blocks/src/components/import-form/index.js
@@ -0,0 +1,101 @@
/**
* 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.isMounted = true;
Copy link
Member

@aduth aduth Sep 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: Previous comment, this is actually a part of the React component API, though discouraged, which may explain errors around attempts to set it.

https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the antipattern but sometimes I'm pragmatic and don't want to introduce more complex solutions like a cancelable promise or something like that. Especially since it's a small JS file for a separate page here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, noting that I wasn't able to reproduce any of the errors you had, so I'd appreciate another check

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point was less about it being discouraged / deprecated, and more to the point that this is already a property defined on a default React component, and we're overriding it.

this.onChangeFile = this.onChangeFile.bind( this );
this.onSubmit = this.onSubmit.bind( this );
}

componentWillUnmount() {
this.isMounted = 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.isMounted ) {
return;
}

this.setState( { isLoading: false } );
onUpload( reusableBlock );
} )
.catch( ( error ) => {
if ( ! this.isMounted ) {
return;
}

this.setState( { isLoading: false, error: error.message } );
} );
}

render() {
const { instanceId } = this.props;
const { file, isLoading, error } = this.state;
const inputId = 'list-reusable-blocks-import-form-' + instanceId;
return (
<form
className="list-reusable-blocks-import-form"
onSubmit={ this.onSubmit }
>
{ error && (
<Notice status="error">
{ error }
</Notice>
) }
<label
htmlFor={ inputId }
className="list-reusable-blocks-import-form__label"
>
{ __( 'File' ) }
</label>
<input
id={ inputId }
type="file"
onChange={ this.onChangeFile }
/>
<Button
type="submit"
isBusy={ isLoading }
disabled={ ! file || isLoading }
isDefault
className="list-reusable-blocks-import-form__button"
>
{ __( 'Import' ) }
</Button>
</form>
);
}
}

export default withInstanceId( ImportForm );
@@ -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;
}
45 changes: 45 additions & 0 deletions 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', function() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistency: function() { (function keyword) here vs. ( event ) => { (arrow function) above.

const button = document.querySelector( '.page-title-action' );
if ( ! button ) {
return;
}

const showNotice = () => {
const notice = document.createElement( 'div' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we render a <Notice /> component?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did try it, the issue is that it's styling is very different from the regular notices in this kind of pages. We should reconsider if we rewrite the whole page in JS

notice.className = 'notice notice-success is-dismissible';
notice.innerHTML = `<p>${ __( 'Reusable block imported with success!' ) }</p>`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar: "successfully" might work better than "with success" here.


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( <ImportDropdown onUpload={ showNotice } />, container );
} );