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

Block Library: Implement Template Part block editing. #18925

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 61 additions & 5 deletions lib/template-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function create_auto_draft_for_template_part_block( $block ) {
* @return string Path to the canvas file to include.
*/
function gutenberg_find_template( $template_file ) {
global $_wp_current_template_content, $_wp_current_template_hierarchy;
global $_wp_current_template_id, $_wp_current_template_content, $_wp_current_template_hierarchy;

// Bail if no relevant template hierarchy was determined, or if the template file
// was overridden another way.
Expand Down Expand Up @@ -183,10 +183,6 @@ function gutenberg_find_template( $template_file ) {
$current_template_post = get_post(
wp_insert_post( $current_template_post )
);

foreach ( parse_blocks( $current_template_post->post_content ) as $block ) {
create_auto_draft_for_template_part_block( $block );
}
} else {
$current_template_post = new WP_Post(
(object) $current_template_post
Expand All @@ -195,6 +191,12 @@ function gutenberg_find_template( $template_file ) {
}

if ( $current_template_post ) {
if ( is_admin() ) {
foreach ( parse_blocks( $current_template_post->post_content ) as $block ) {
create_auto_draft_for_template_part_block( $block );
}
}
$_wp_current_template_id = $current_template_post->ID;
$_wp_current_template_content = $current_template_post->post_content;
}

Expand Down Expand Up @@ -262,3 +264,57 @@ function gutenberg_viewport_meta_tag() {
function gutenberg_strip_php_suffix( $template_file ) {
return preg_replace( '/\.php$/', '', $template_file );
}

/**
* Extends default editor settings to enable template and template part editing.
*
* @param array $settings Default editor settings.
*
* @return array Filtered editor settings.
*/
function gutenberg_template_loader_filter_block_editor_settings( $settings ) {
global $wp_query, $_wp_current_template_id;
// Run template resolution manually to trigger our override filters.
$tag_templates = array(
'is_embed' => 'get_embed_template',
'is_404' => 'get_404_template',
'is_search' => 'get_search_template',
'is_front_page' => 'get_front_page_template',
'is_home' => 'get_home_template',
'is_privacy_policy' => 'get_privacy_policy_template',
'is_post_type_archive' => 'get_post_type_archive_template',
'is_tax' => 'get_taxonomy_template',
'is_attachment' => 'get_attachment_template',
'is_single' => 'get_single_template',
'is_page' => 'get_page_template',
'is_singular' => 'get_singular_template',
'is_category' => 'get_category_template',
'is_tag' => 'get_tag_template',
'is_author' => 'get_author_template',
'is_date' => 'get_date_template',
'is_archive' => 'get_archive_template',
);
$template = false;
// Loop through each of the template conditionals, and find the appropriate template file.
$post = get_post();
$wp_query->parse_query( 'p=' . $post->ID . '&preview=true' );
foreach ( $tag_templates as $tag => $template_getter ) {
if ( call_user_func( $tag ) ) {
$template = call_user_func( $template_getter );
}
if ( $template ) {
if ( 'is_attachment' === $tag ) {
remove_filter( 'the_content', 'prepend_attachment' );
}
break;
}
}
if ( ! $template ) {
$template = get_index_template();
}
$template = apply_filters( 'template_include', $template );
$settings['templateId'] = $_wp_current_template_id;
$settings['editingMode'] = 'post-content';
return $settings;
}
add_filter( 'block_editor_settings', 'gutenberg_template_loader_filter_block_editor_settings' );
1 change: 1 addition & 0 deletions packages/base-styles/_z-index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ $z-layers: (
".wp-block-cover.has-background-dim::before": 1, // Overlay area inside block cover need to be higher than the video background.
".wp-block-cover__video-background": 0, // Video background inside cover block.
".wp-block-site-title__save-button": 1,
".wp-block-template-part__save-button": 1,

// Active pill button
".components-button.is-button {:focus or .is-primary}": 1,
Expand Down
26 changes: 24 additions & 2 deletions packages/block-editor/src/components/inner-blocks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class InnerBlocks extends Component {
}

componentDidMount() {
const { templateLock, block } = this.props;
const { block, templateLock, value, replaceInnerBlocks } = this.props;
const { innerBlocks } = block;
// Only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists directly on the block.
if ( innerBlocks.length === 0 || templateLock === 'all' ) {
Expand All @@ -48,10 +48,22 @@ class InnerBlocks extends Component {
templateInProcess: false,
} );
}

// Set controlled blocks value from parent, if any.
if ( value ) {
replaceInnerBlocks( value );
}
}

componentDidUpdate( prevProps ) {
const { template, block, templateLock } = this.props;
const {
block,
templateLock,
template,
isLastBlockChangePersistent,
onInput,
onChange,
} = this.props;
const { innerBlocks } = block;

this.updateNestedSettings();
Expand All @@ -62,6 +74,14 @@ class InnerBlocks extends Component {
this.synchronizeBlocksWithTemplate();
}
}

// Sync with controlled blocks value from parent, if possible.
if ( prevProps.block.innerBlocks !== innerBlocks ) {
const resetFunc = isLastBlockChangePersistent ? onInput : onChange;
if ( resetFunc ) {
resetFunc( innerBlocks );
}
}
}

/**
Expand Down Expand Up @@ -139,6 +159,7 @@ InnerBlocks = compose( [
getBlockRootClientId,
getTemplateLock,
isNavigationMode,
isLastBlockChangePersistent,
} = select( 'core/block-editor' );
const { clientId, isSmallScreen } = ownProps;
const block = getBlock( clientId );
Expand All @@ -150,6 +171,7 @@ InnerBlocks = compose( [
hasOverlay: block.name !== 'core/template' && ! isBlockSelected( clientId ) && ! hasSelectedInnerBlock( clientId, true ),
parentLock: getTemplateLock( rootClientId ),
enableClickThrough: isNavigationMode() || isSmallScreen,
isLastBlockChangePersistent: isLastBlockChangePersistent(),
};
} ),
withDispatch( ( dispatch, ownProps ) => {
Expand Down
1 change: 1 addition & 0 deletions packages/block-library/src/editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
@import "./subhead/editor.scss";
@import "./table/editor.scss";
@import "./tag-cloud/editor.scss";
@import "./template-part/editor.scss";
@import "./text-columns/editor.scss";
@import "./verse/editor.scss";
@import "./video/editor.scss";
Expand Down
119 changes: 117 additions & 2 deletions packages/block-library/src/template-part/edit.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,118 @@
export default function TemplatePartEdit() {
return 'Template Part Placeholder';
/**
* WordPress dependencies
*/
import {
useEntityProp,
__experimentalUseEntitySaving,
EntityProvider,
} from '@wordpress/core-data';
import { useMemo, useCallback } from '@wordpress/element';
import { parse } from '@wordpress/blocks';
import { serializeBlocks } from '@wordpress/editor';
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';

const saveProps = [ 'content', 'status' ];
function TemplatePart() {
const [ content, _setContent ] = useEntityProp(
'postType',
'wp_template_part',
'content'
);
const [ status, setStatus ] = useEntityProp(
'postType',
'wp_template_part',
'status'
);
const initialBlocks = useMemo( () => {
if ( status !== 'publish' ) {
// Publish if still an auto-draft.
setStatus( 'publish' );
}
if ( typeof content !== 'function' ) {
const parsedContent = parse( content );
return parsedContent.length ? parsedContent : undefined;
}
}, [] );
const [ blocks = initialBlocks, setBlocks ] = useEntityProp(
'postType',
'wp_template_part',
'blocks'
);
const [ isDirty, isSaving, save ] = __experimentalUseEntitySaving(
'postType',
'wp_template_part',
saveProps
);
const saveContent = useCallback( () => {
_setContent( content( { blocks } ) );
Copy link
Member

Choose a reason for hiding this comment

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

In the above useMemo, we test whether content is a function. Here, we have no such safety checks. Do we run any risk that this isn't a function?

Aside: I'm not really clear why this is a function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

useMemo runs on the first render. InnerBlocks will set it to a function after that.

Aside: I'm not really clear why this is a function.

It mirrors how the editor store works. It uses a function that computes the eventual value to make the entity dirty in the store without having to run a potentially expensive computation.

Copy link
Member

Choose a reason for hiding this comment

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

useMemo runs on the first render. InnerBlocks will set it to a function after that.

Is this behavior documented somewhere? Could we at least include an inline comment to explain this?

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'm refactoring this into a custom hook with docs, because it will be used often, useSyncedEntityInnerBlocks.

save();
}, [ content, blocks ] );
const setContent = useCallback( () => {
_setContent( ( { blocks: blocksForSerialization = [] } ) =>
serializeBlocks( blocksForSerialization )
Copy link
Member

Choose a reason for hiding this comment

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

Is there not an equivalent selector that grants us access to the serialization of these blocks, rather than exposing the internal utility functions? Would it be possible to implement such a selector, or enhancement to an existing selector?

I'm not entirely clear what it is about the editor's serialization that we want to reuse here, vs. the raw @wordpress/blocks implementation, but it would be good if we can avoid exposing these internals, limit the use to either the standard public interface of a store (selectors) or the base implementation of wp.blocks.serialize.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is no selector that just serializes the inner blocks of a given block.

We could extend getEditedPostContent, but the name wouldn't really do it justice and the editor store doesn't really work at the blocks level.

We need this util from the editor, for the backwards compatibility reasons explained in the file:

https://github.com/WordPress/gutenberg/blob/master/packages/editor/src/store/utils/serialize-blocks.js

I think this utility could be moved to @wordpress/blocks as legacySerializeBlocks or something. Or maybe the backwards compatibility concerns are not an issue anymore? What do you suggest?

Copy link
Member

Choose a reason for hiding this comment

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

I think those behaviors might still be necessary for the post editor, but I don't know that they would need to extend to templates. The main purpose for the post editor is:

  • Avoid saving a post which consists of just an empty paragraph (more a user-facing thing, since technically a new paragraph block is non-empty content, but for the purpose of a post is considered non-empty, to avoid accumulating drafts)
  • Retain existing wpautop formatting applied to posts. This is relevant for content published before the block editor, or otherwise processed using the_content filter on the front-end.

I don't think either of these are really relevant for the template parts though?

To me, it seems like maybe we can keep that utility for default usage, then just use the block module's default serialize for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perfect, we can just use serialize here then. I'll update it.

);
}, [] );
return (
<>
<Button
isPrimary
className="wp-block-template-part__save-button"
disabled={ ! isDirty || ! content }
isBusy={ isSaving }
onClick={ saveContent }
>
{ __( 'Save' ) }
</Button>
<InnerBlocks value={ blocks } onChange={ setBlocks } onInput={ setContent } />
Copy link
Member

Choose a reason for hiding this comment

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

These new props value and onChange (aside: also not documented) seem contrary to the purpose of InnerBlocks as an abstracted, managed rendering of blocks. For something like this where we want full control over the rendering of blocks, is there any reason not to render a custom BlockEditorProvider instead?

https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/provider/README.md

Or, alternatively, based on what we're doing above with serializing blocks and how it's proposed to expose the internal state utilities, maybe this is something which should be baked into InnerBlocks. Is the idea that we need some way to customize the "save" or "change" behaviors, in this case to sync to the entity?

For that, I might be okay with an onChange, but more as a true side-effect, where we're not intending to fully control the value of InnerBlocks, but rather sync any changes.

Then again, that seems like something we could also achieve without a callback, and instead using the getBlocks selector to track changes to the inner blocks of this block.

To me, this is a very similar problem to what was previously discussed in #5596 around having a callback to setAttributes, where instead the model we encourage is where one should detect the changes using the existing selectors functionality (even easier now today with hooks than it was at the time of #5596).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

is there any reason not to render a custom BlockEditorProvider instead?

We don't want a split block list. We want the child blocks to still save to the parent through the block's save component where it makes sense, and we want the UX of a single block list.

This change provides a way to provide an initial value and then sync with the changes of InnerBlocks so that other code can use the child blocks to do things like save to other entities.

We could achieve something similar with actions and selectors, albeit in a much more verbose and error-prone way. We would probably end up implementing a custom hook for it and the logic for checking if the last check was persistent might get messy. This approach would also be less performant.

I don't think this is comparable to a callback for setAttributes. In that scenario people needed a way to react to changes in the store, and that's what subscriptions are for. In this scenario we want to control/sync with the state of a component.

What are the issues you see this creating in the future?

Copy link
Member

Choose a reason for hiding this comment

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

This change provides a way to provide an initial value and then sync with the changes of InnerBlocks so that other code can use the child blocks to do things like save to other entities.

I think the issue for me is that we've now introduced (to me at least) some confusion around exactly what InnerBlocks is. Is it a controlled input? Or is it abstracted to maintain the value internally? Where before it was clearly the latter, with these changes it seems like the answer is now "both". A concern stems from the need to document that clearly, and to maintain that in perpetuity.

For me, this sort of controlled rendering was was BlockEditor should do. I understand the usability need that we need blocks within this get treated as part of the same "tree". It reminds me of similar work in #14715 with the reusable block, and where we want to draw this line of separate vs. integrated editors. Is there value in the reusability of block-editor if these limitations prevent us from using it in the editor? Is the purpose of these InnerBlocks revisions to serve as an enhanced version of a block editor? In the end, maybe this is the right change, since we could have a use case for each, but it is worth having the clear picture for how all these fit together.

Maybe if we compare it to React's distinction of controlled vs. uncontrolled inputs, it's fine to consider support for both use-cases. I think there's a bit of a difference in how what would become the equivalent of an "uncontrolled" InnerBlocks has some inherited behavior (and an inherited default value of an inner block) whereas, without a defaultValue, any other input would always be empty.

We could achieve something similar with actions and selectors, albeit in a much more verbose and error-prone way. We would probably end up implementing a custom hook for it and the logic for checking if the last check was persistent might get messy. This approach would also be less performant.

To a lesser degree, I still think we'll have some concern about how difficult it is to manage this value from a block. For example, with this implementation, are we serializing blocks on every keypress within a template? That seems like it could become a performance concern for non-trivial templates. Whether that's something we could improve, it would be fair to acknowledge that others may encounter similar pain points.

I'm not sure it's something we need to act on now, but it gets me me wondering whether there are alternative / additional abstractions we could imagine to help encapsulate this behavior of mapping the blocks data to a custom entity serialization. As before I was talking of this sort of InnerBlocks being an enhanced version of a BlockEditorProvider, I wonder if there's some parallel of how Editor is an enhanced version of BlockEditor responsible for managing how a block editor saves to a post.

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 think the issue for me is that we've now introduced (to me at least) some confusion around exactly what InnerBlocks is. Is it a controlled input? Or is it abstracted to maintain the value internally? Where before it was clearly the latter, with these changes it seems like the answer is now "both". A concern stems from the need to document that clearly, and to maintain that in perpetuity.

That's fair. I agree we should avoid it if possible.

For me, this sort of controlled rendering was was BlockEditor should do. I understand the usability need that we need blocks within this get treated as part of the same "tree". It reminds me of similar work in #14715 with the reusable block, and where we want to draw this line of separate vs. integrated editors. Is there value in the reusability of block-editor if these limitations prevent us from using it in the editor? Is the purpose of these InnerBlocks revisions to serve as an enhanced version of a block editor? In the end, maybe this is the right change, since we could have a use case for each, but it is worth having the clear picture for how all these fit together.

BlockEditor is for building entire new editors. InnerBlocks is for managing subtrees in editors.

Maybe if we compare it to React's distinction of controlled vs. uncontrolled inputs, it's fine to consider support for both use-cases. I think there's a bit of a difference in how what would become the equivalent of an "uncontrolled" InnerBlocks has some inherited behavior (and an inherited default value of an inner block) whereas, without a defaultValue, any other input would always be empty.

Yes, that's what I based it on.

To a lesser degree, I still think we'll have some concern about how difficult it is to manage this value from a block. For example, with this implementation, are we serializing blocks on every keypress within a template? That seems like it could become a performance concern for non-trivial templates. Whether that's something we could improve, it would be fair to acknowledge that others may encounter similar pain points.

We're not serializing on every keypress, but I also see this becoming a common pattern and it would be good to deal with all the pain points in one place.

I'm not sure it's something we need to act on now, but it gets me me wondering whether there are alternative / additional abstractions we could imagine to help encapsulate this behavior of mapping the blocks data to a custom entity serialization. As before I was talking of this sort of InnerBlocks being an enhanced version of a BlockEditorProvider, I wonder if there's some parallel of how Editor is an enhanced version of BlockEditor responsible for managing how a block editor saves to a post.

We could have an enhanced InnerBlocks that lets you turn on the syncing with a prop, but that would make block-editor depend on core-data. I think a useSyncedEntityInnerBlocks hook will work here. I've thought a lot about this and have much better ideas for how to deal with all this in a better way, specially with the new changes to master. I'll close this PR and open a new one soon.

</>
);
}

export default function TemplatePartEdit( {
attributes: { postId: _postId, slug, theme },
setAttributes,
} ) {
const postId = useSelect(
( select ) => {
if ( _postId ) {
// This is already a custom template part,
// use its CPT post.
return (
select( 'core' ).getEntityRecord(
'postType',
'wp_template_part',
_postId
) && _postId
);
}

// This is not a custom template part,
// load the auto-draft created from the
// relevant file.
const posts = select( 'core' ).getEntityRecords(
'postType',
'wp_template_part',
{
status: 'auto-draft',
slug,
meta: { theme },
}
);
if ( posts && posts[ 0 ].id ) {
// Set the post ID so that edits
// persist.
setAttributes( { postId: posts[ 0 ].id } );
Copy link
Member

Choose a reason for hiding this comment

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

I would not expect this mapping function to have a side effect.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What's the problem with it?

Copy link
Member

Choose a reason for hiding this comment

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

What's the problem with it?

One of the major goals of a getter / setter pairing of useSelect / useDispatch is to provide clear signals to a developer where and how mutations are expected to occur. There would be less confidence in this holding true if a developer is expected to need to account for potential side effects occurring within useSelect. In more practical terms, a potential refactor of this component could result in these side effects being triggered more or less often than originally expected; maybe if memoization was altered or removed (as a developer should otherwise assume that a mapSelect callback will be a pure function).

For me, it's largely a matter of maintenance overhead and potential for bugs introduced as part of said maintenance. There's otherwise nothing technically incorrect about this implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

maybe if memoization was altered or removed

Good point, I'll change it.

return posts[ 0 ].id;
}
},
[ _postId, slug, theme ]
);
return postId ? (
<EntityProvider kind="postType" type="wp_template_part" id={ postId }>
<TemplatePart />
</EntityProvider>
) : null;
}
6 changes: 6 additions & 0 deletions packages/block-library/src/template-part/editor.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.wp-block-template-part__save-button {
position: absolute;
right: 0;
top: 0;
z-index: z-index(".wp-block-template-part__save-button");
}
19 changes: 12 additions & 7 deletions packages/core-data/src/entity-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,41 +122,46 @@ export function useEntityProp( kind, type, prop ) {
export function __experimentalUseEntitySaving( kind, type, props ) {
const id = useEntityId( kind, type );

const [ isDirty, isSaving, edits ] = useSelect(
const [ isDirty, isSaving, _select ] = useSelect(
( select ) => {
const { getEntityRecordNonTransientEdits, isSavingEntityRecord } = select(
'core'
);
const _edits = getEntityRecordNonTransientEdits( kind, type, id );
const editKeys = Object.keys( _edits );
const editKeys = Object.keys(
getEntityRecordNonTransientEdits( kind, type, id )
);
return [
props ?
editKeys.some( ( key ) =>
typeof props === 'string' ? key === props : props.includes( key )
) :
editKeys.length > 0,
isSavingEntityRecord( kind, type, id ),
_edits,
select,
];
},
[ kind, type, id, props ]
);

const { saveEntityRecord } = useDispatch( 'core' );
const save = useCallback( () => {
let filteredEdits = edits;
let filteredEdits = _select( 'core' ).getEntityRecordNonTransientEdits(
kind,
type,
id
);
if ( typeof props === 'string' ) {
filteredEdits = { [ props ]: filteredEdits[ props ] };
} else if ( props ) {
filteredEdits = filteredEdits.reduce( ( acc, key ) => {
filteredEdits = Object.keys( filteredEdits ).reduce( ( acc, key ) => {
if ( props.includes( key ) ) {
acc[ key ] = filteredEdits[ key ];
}
return acc;
}, {} );
}
saveEntityRecord( kind, type, { id, ...filteredEdits } );
}, [ kind, type, id, props, edits ] );
}, [ kind, type, id, props, _select ] );

return [ isDirty, isSaving, save ];
}
2 changes: 2 additions & 0 deletions packages/editor/src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ import mediaUpload from './media-upload';

export { mediaUpload };
export { cleanForSlug } from './url.js';

export { default as serializeBlocks } from '../store/utils/serialize-blocks';