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

Add reusable blocks #2659

Closed
wants to merge 38 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6e3431f
Add 'core/reusable-block' block type
noisysocks Aug 30, 2017
6721b27
Stub out Reusable Block UI, implement fetching and local editing
noisysocks Aug 31, 2017
7f6dd91
Implement reusable block persisting
noisysocks Sep 1, 2017
ade0f06
Implement ATTACH_REUSABLE_BLOCK
noisysocks Sep 1, 2017
16588b8
Implement DETACH_REUSABLE_BLOCK
noisysocks Sep 2, 2017
e9b4701
Fetch and persist reusable blocks using the WP API Backbone client
noisysocks Sep 4, 2017
23162e3
Clean up reusable block update actions
noisysocks Sep 4, 2017
fee015f
Rename persistReusableBlock -> saveReusableBlock
noisysocks Sep 4, 2017
51f8a38
Prefer 'id' instead of 'ref' when dealing with reusable blocks
noisysocks Sep 4, 2017
d5c0c49
Rename ATTACH/DETACH actions
noisysocks Sep 4, 2017
a6cd1dd
Add test fixture for core/reusable-block
noisysocks Sep 4, 2017
80ee61f
Write tests for reusable block client API
noisysocks Sep 4, 2017
9fdcb50
Write tests for ATTACH_BLOCK and DETACH_BLOCK effects
noisysocks Sep 4, 2017
67d0ec6
Style 'Convert to Reusable Block' button
noisysocks Sep 6, 2017
2eb4f96
Style reusable block dialogs and display them modally
noisysocks Sep 6, 2017
6e5876e
Add reusable blocks to the inserter
noisysocks Sep 8, 2017
256ddbb
Add FETCH_REUSABLE_BLOCKS and have the inserter dispatch it when shown
noisysocks Sep 8, 2017
2f89fea
*Always* ask to confirm editing a reusable block
noisysocks Sep 8, 2017
2822984
Give reusable blocks a dashed outline
noisysocks Sep 11, 2017
38887bb
Implement `render_callback` for reusable blocks
noisysocks Oct 1, 2017
735e2c0
Replace reusable block modals with inline edit panel
noisysocks Oct 2, 2017
3d3affb
Give the `<button>` in `<ButtonControl>` an `id`
noisysocks Oct 3, 2017
4448ea2
Use an explicit `isPrivate` flag to hide block types from the inserter
noisysocks Oct 3, 2017
f587eed
Remove useless `} else {`
noisysocks Oct 3, 2017
4b7f597
DRY up `do_blocks` and `gutenberg_render_block_core_reusable_block`
noisysocks Oct 4, 2017
9f0afb9
Combine FETCH_REUSABLE_BLOCK and FETCH_REUSABLE_BLOCKS
noisysocks Oct 4, 2017
baf43b3
Remove ADD_REUSABLE_BLOCKS action, add ability to track saving
noisysocks Oct 5, 2017
6cf3950
Add reusable block 'saving' and 'save failure' UI
noisysocks Oct 5, 2017
cd03ad3
Rename {ATTACH,DETACH}_BLOCK to MAKE_BLOCK_{STATIC,REUSABLE}
noisysocks Oct 5, 2017
49ec18c
Improve tests for actions, selectors and effects
noisysocks Oct 6, 2017
fcdf318
Add unit tests for FETCH_REUSABLE_BLOCKS and SAVE_REUSABLE_BLOCK
noisysocks Oct 6, 2017
e8c33c1
Clean up <ButtonControl>
noisysocks Oct 9, 2017
69e5705
Clarify how we're using pickBy
noisysocks Oct 9, 2017
7fdb1d8
Call `blockType.save`, not `blockType.create`
noisysocks Oct 9, 2017
003c900
Use convertBlockTo{Static,Reusable} instead of makeBlock{Static,Reusa…
noisysocks Oct 9, 2017
1254e97
Remove unnecessary `<Slot>`
noisysocks Oct 9, 2017
6faec68
Ensure jQuery is loaded before using it in an inline script
noisysocks Oct 9, 2017
5637827
Use animation and notices to indicate the reusable block save state
noisysocks Oct 12, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions blocks/api/categories.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const categories = [
{ slug: 'layout', title: __( 'Layout Blocks' ) },
{ slug: 'widgets', title: __( 'Widgets' ) },
{ slug: 'embed', title: __( 'Embed' ) },
{ slug: 'reusable-blocks', title: __( 'My Reusable Blocks' ) },
];

/**
Expand Down
21 changes: 21 additions & 0 deletions blocks/api/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
find,
} from 'lodash';

/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
Expand Down Expand Up @@ -116,3 +121,19 @@ export function switchToBlockType( block, name ) {
uid: index === firstSwitchedBlock ? block.uid : result.uid,
} ) );
}

/**
* Creates a new reusable block.
*
* @param {String} type The type of the block referenced by the reusable block
* @param {Object} attributes The attributes of the block referenced by the reusable block
* @return {Object} A reusable block object
*/
export function createReusableBlock( type, attributes ) {
return {
id: uuid(),
name: __( 'Untitled block' ),
type,
attributes,
};
}
2 changes: 1 addition & 1 deletion blocks/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import * as source from './source';

export { source };
export { createBlock, switchToBlockType } from './factory';
export { createBlock, switchToBlockType, createReusableBlock } from './factory';
export { default as parse, getSourcedAttributes } from './parser';
export { default as pasteHandler } from './paste';
export { default as serialize, getBlockDefaultClassname, getBlockContent } from './serializer';
Expand Down
16 changes: 15 additions & 1 deletion blocks/api/test/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { noop } from 'lodash';
/**
* Internal dependencies
*/
import { createBlock, switchToBlockType } from '../factory';
import { createBlock, switchToBlockType, createReusableBlock } from '../factory';
import { getBlockTypes, unregisterBlockType, setUnknownTypeHandlerName, registerBlockType } from '../registration';

describe( 'block factory', () => {
Expand Down Expand Up @@ -460,4 +460,18 @@ describe( 'block factory', () => {
} );
} );
} );

describe( 'createReusableBlock', () => {
it( 'should create a reusable block', () => {
const type = 'core/test-block';
const attributes = { name: 'Big Bird' };

expect( createReusableBlock( type, attributes ) ).toMatchObject( {
id: expect.stringMatching( /\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/ ),
name: 'Untitled block',
type,
attributes,
} );
} );
} );
} );
23 changes: 23 additions & 0 deletions blocks/inspector-controls/button-control/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* WordPress dependencies
*/
import { Button, withInstanceId } from '@wordpress/components';

/**
* Internal dependencies
*/
import BaseControl from './../base-control';
import './style.scss';

function ButtonControl( { instanceId, label, value, help, ...props } ) {
const id = 'inspector-button-control-' + instanceId;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the instanceId and the id useful in this component? Generally, we're using it to match labels and inputs but there's no input in this component.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch. <button> elements are labelable, but I wasn't passing id along to the <Button> component. I've fixed this in 224eb27f54a397b3c64f3793174fcb9c2e0cd252.

return (
<BaseControl id={ id } label={ label } help={ help } className="blocks-button-control">
<Button { ...props } id={ id } isLarge className={ 'blocks-button-control__button' }>
{ value }
</Button>
</BaseControl>
);
}

export default withInstanceId( ButtonControl );
3 changes: 3 additions & 0 deletions blocks/inspector-controls/button-control/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.blocks-button-control .blocks-button-control__button {
margin-bottom: 0.5em;
}
2 changes: 2 additions & 0 deletions blocks/inspector-controls/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Fill } from 'react-slot-fill';
* Internal dependencies
*/
import BaseControl from './base-control';
import ButtonControl from './button-control';
import CheckboxControl from './checkbox-control';
import RadioControl from './radio-control';
import RangeControl from './range-control';
Expand All @@ -24,6 +25,7 @@ export default function InspectorControls( { children } ) {
}

InspectorControls.BaseControl = BaseControl;
InspectorControls.ButtonControl = ButtonControl;
InspectorControls.CheckboxControl = CheckboxControl;
InspectorControls.RadioControl = RadioControl;
InspectorControls.RangeControl = RangeControl;
Expand Down
1 change: 1 addition & 0 deletions blocks/library/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ import './text-columns';
import './verse';
import './video';
import './audio';
import './reusable-block';
68 changes: 68 additions & 0 deletions blocks/library/reusable-block/edit-panel/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import './style.scss';

function ReusableBlockEditPanel( props ) {
const { isEditing, name, isSaving, onEdit, onDetach, onChangeName, onSave, onCancel } = props;

return (
<div className="reusable-block-edit-panel">
{ ! isEditing && ! isSaving && [
<span key="info" className="reusable-block-edit-panel__info">
<b>{ name }</b>
</span>,
<Button
key="edit"
isLarge
className="reusable-block-edit-panel__button"
onClick={ onEdit }>
{ __( 'Edit' ) }
</Button>,
<Button
key="detach"
isLarge
className="reusable-block-edit-panel__button"
onClick={ onDetach }>
{ __( 'Detach' ) }
</Button>,
] }
{ ( isEditing || isSaving ) && [
<input
key="name"
type="text"
disabled={ isSaving }
className="reusable-block-edit-panel__name"
value={ name }
onChange={ ( event ) => onChangeName( event.target.value ) } />,
<Button
key="save"
isPrimary
isLarge
isIndicatingProgress={ isSaving }
disabled={ ! name || isSaving }
className="reusable-block-edit-panel__button"
onClick={ onSave }>
{ __( 'Save' ) }
</Button>,
<Button
key="cancel"
isLarge
disabled={ isSaving }
className="reusable-block-edit-panel__button"
onClick={ onCancel }>
{ __( 'Cancel' ) }
</Button>,
] }
</div>
);
}

export default ReusableBlockEditPanel;

32 changes: 32 additions & 0 deletions blocks/library/reusable-block/edit-panel/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.reusable-block-edit-panel {
align-items: center;
background: $light-gray-100;
color: $dark-gray-500;
display: flex;
font-family: $default-font;
font-size: $default-font-size;
justify-content: flex-end;
margin: $block-padding (-$block-padding) (-$block-padding);
padding: 10px $block-padding;

.reusable-block-edit-panel__spinner {
margin: 0 5px;
}

.reusable-block-edit-panel__info {
margin-right: auto;
}

.reusable-block-edit-panel__name {
flex-grow: 1;
font-size: 14px;
height: 30px;
margin: 0 auto 0 0;
max-width: 230px;
}

// Needs specificity to override the margin-bottom set by .button
.wp-core-ui & .reusable-block-edit-panel__button {
margin: 0 0 0 5px;
}
}
166 changes: 166 additions & 0 deletions blocks/library/reusable-block/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* External dependencies
*/
import { pickBy, noop } from 'lodash';
import { connect } from 'react-redux';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { Placeholder, Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { getBlockType, registerBlockType } from '../../api';
import ReusableBlockEditPanel from './edit-panel';

class ReusableBlockEdit extends Component {
constructor() {
super( ...arguments );

this.startEditing = this.startEditing.bind( this );
this.stopEditing = this.stopEditing.bind( this );
this.setAttributes = this.setAttributes.bind( this );
this.setName = this.setName.bind( this );
this.updateReusableBlock = this.updateReusableBlock.bind( this );

this.state = {
isEditing: false,
name: null,
attributes: null,
};
}

componentDidMount() {
if ( ! this.props.reusableBlock ) {
this.props.fetchReusableBlock();
Copy link
Member

Choose a reason for hiding this comment

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

My first reaction to seeing this and related Redux actions: Could we simplify this by using the withAPIData higher-order component instead to keep the network logic local to the component? It doesn't seem to follow to me that the broader application state needs to be concerned with this data.

Copy link
Member Author

@noisysocks noisysocks Oct 9, 2017

Choose a reason for hiding this comment

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

The major problem with using withAPIData is that our 'Detach from Reusable Block' functionality needs to have the attributes of the reusable block that it is detaching. Currently, it gets this by accessing the store. If we moved the fetched reusable block to inside the edit component's local state, then we would not be able to access this from outside of the component.

Also: right now, if you have two instances of the same reusable block in the post, editing one will immediately update the other. We get this for free because the UI is derived from what's in the store. It's a small thing, but kind of nice.

}
}

startEditing() {
this.setState( { isEditing: true } );
}

stopEditing() {
this.setState( {
isEditing: false,
name: null,
attributes: null,
} );
}

setAttributes( attributes ) {
this.setState( ( prevState ) => ( {
attributes: { ...prevState.attributes, ...attributes },
} ) );
}

setName( name ) {
this.setState( { name } );
}

updateReusableBlock() {
const { name, attributes } = this.state;

// Use pickBy to include only changed (assigned) values in payload
const payload = pickBy( {
name,
attributes,
} );

this.props.updateReusableBlock( payload );
this.props.saveReusableBlock();
this.stopEditing();
}

render() {
const { focus, reusableBlock, convertBlockToStatic } = this.props;
const { isEditing, name, attributes } = this.state;

if ( ! reusableBlock ) {
return <Placeholder><Spinner /></Placeholder>;
}

const blockType = getBlockType( reusableBlock.type );
const BlockEdit = blockType.edit || blockType.save;

return [
// We fake the block being read-only by wrapping it with an element that has pointer-events: none
<div key="edit" style={ { pointerEvents: isEditing ? 'auto' : 'none' } }>
<BlockEdit
{ ...this.props }
focus={ isEditing ? focus : null }
attributes={ { ...reusableBlock.attributes, ...attributes } }
setAttributes={ isEditing ? this.setAttributes : noop } />
</div>,
focus && (
<ReusableBlockEditPanel
key="panel"
isEditing={ isEditing }
name={ name !== null ? name : reusableBlock.name }
isSaving={ reusableBlock.isSaving }
onEdit={ this.startEditing }
onDetach={ convertBlockToStatic }
onChangeName={ this.setName }
onSave={ this.updateReusableBlock }
onCancel={ this.stopEditing } />
),
];
}
}

/**
* TODO:
* These should use selectors and action creators that we gather from context.
* OR this entire component should move to `editor`, I can't decide.
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

We're in the process of merging the two modules (blocks and editor) which will resolve this issue #2795

Copy link
Member Author

Choose a reason for hiding this comment

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

Nice! I was dreading how awkward using context here would be! 😅

I'll leave the code as is until #2795 is merged.

Copy link
Contributor

Choose a reason for hiding this comment

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

#2795 is closed. It was too disruptive for now but the Reusable block shows precisely that our current separation is not great @aduth

const ConnectedReusableBlockEdit = connect(
( state, ownProps ) => ( {
reusableBlock: state.reusableBlocks[ ownProps.attributes.ref ],
} ),
( dispatch, ownProps ) => ( {
fetchReusableBlock() {
dispatch( {
type: 'FETCH_REUSABLE_BLOCKS',
id: ownProps.attributes.ref,
} );
},
updateReusableBlock( reusableBlock ) {
dispatch( {
type: 'UPDATE_REUSABLE_BLOCK',
id: ownProps.attributes.ref,
reusableBlock,
} );
},
saveReusableBlock() {
dispatch( {
type: 'SAVE_REUSABLE_BLOCK',
id: ownProps.attributes.ref,
} );
},
convertBlockToStatic() {
dispatch( {
type: 'CONVERT_BLOCK_TO_STATIC',
uid: ownProps.id,
} );
},
} )
)( ReusableBlockEdit );

registerBlockType( 'core/reusable-block', {
title: __( 'Reusable Block' ),
category: 'reusable-blocks',
isPrivate: true,

attributes: {
ref: {
type: 'string',
},
},

edit: ConnectedReusableBlockEdit,
save: () => null,
} );
Loading