diff --git a/lib/template-loader.php b/lib/template-loader.php index f69957dc824a8..84ab186fe2ed1 100644 --- a/lib/template-loader.php +++ b/lib/template-loader.php @@ -262,3 +262,25 @@ 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 ) { + if ( ! post_type_exists( 'wp_template' ) || ! post_type_exists( 'wp_template_part' ) ) { + return $settings; + } + + // Create template part auto-drafts for the edited post. + foreach ( parse_blocks( get_post()->post_content ) as $block ) { + create_auto_draft_for_template_part_block( $block ); + } + + // TODO: Set editing mode and current template ID for editing modes support. + return $settings; +} +add_filter( 'block_editor_settings', 'gutenberg_template_loader_filter_block_editor_settings' ); diff --git a/lib/template-parts.php b/lib/template-parts.php index e14b584ead3ad..62b892e812909 100644 --- a/lib/template-parts.php +++ b/lib/template-parts.php @@ -44,12 +44,14 @@ function gutenberg_register_template_part_post_type() { 'show_in_menu' => 'themes.php', 'show_in_admin_bar' => false, 'show_in_rest' => true, + 'rest_base' => 'template-parts', 'map_meta_cap' => true, 'supports' => array( 'title', 'slug', 'editor', 'revisions', + 'custom-fields', ), ); diff --git a/package-lock.json b/package-lock.json index e457d881cbbc9..ce1a819918986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8503,6 +8503,7 @@ "requires": { "@babel/runtime": "^7.4.4", "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/blocks": "file:packages/blocks", "@wordpress/data": "file:packages/data", "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/element": "file:packages/element", @@ -9096,6 +9097,7 @@ "version": "file:packages/url", "requires": { "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15", "qs": "^6.5.2" } }, diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index b9594fe7428ba..0ebe714800756 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -36,7 +36,12 @@ class InnerBlocks extends Component { } componentDidMount() { - const { templateLock, block } = this.props; + const { + block, + templateLock, + __experimentalBlocks, + 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' ) { @@ -48,10 +53,22 @@ class InnerBlocks extends Component { templateInProcess: false, } ); } + + // Set controlled blocks value from parent, if any. + if ( __experimentalBlocks ) { + replaceInnerBlocks( __experimentalBlocks ); + } } componentDidUpdate( prevProps ) { - const { template, block, templateLock } = this.props; + const { + block, + templateLock, + template, + isLastBlockChangePersistent, + onInput, + onChange, + } = this.props; const { innerBlocks } = block; this.updateNestedSettings(); @@ -62,6 +79,14 @@ class InnerBlocks extends Component { this.synchronizeBlocksWithTemplate(); } } + + // Sync with controlled blocks value from parent, if possible. + if ( prevProps.block.innerBlocks !== innerBlocks ) { + const resetFunc = isLastBlockChangePersistent ? onChange : onInput; + if ( resetFunc ) { + resetFunc( innerBlocks ); + } + } } /** @@ -141,6 +166,7 @@ InnerBlocks = compose( [ getBlockRootClientId, getTemplateLock, isNavigationMode, + isLastBlockChangePersistent, } = select( 'core/block-editor' ); const { clientId, isSmallScreen } = ownProps; const block = getBlock( clientId ); @@ -152,6 +178,7 @@ InnerBlocks = compose( [ hasOverlay: block.name !== 'core/template' && ! isBlockSelected( clientId ) && ! hasSelectedInnerBlock( clientId, true ), parentLock: getTemplateLock( rootClientId ), enableClickThrough: isNavigationMode() || isSmallScreen, + isLastBlockChangePersistent: isLastBlockChangePersistent(), }; } ), withDispatch( ( dispatch, ownProps ) => { @@ -163,7 +190,13 @@ InnerBlocks = compose( [ return { replaceInnerBlocks( blocks ) { - replaceInnerBlocks( clientId, blocks, block.innerBlocks.length === 0 && templateInsertUpdatesSelection ); + replaceInnerBlocks( + clientId, + blocks, + block.innerBlocks.length === 0 && + templateInsertUpdatesSelection && + blocks.length !== 0 + ); }, updateNestedSettings( settings ) { dispatch( updateBlockListSettings( clientId, settings ) ); diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 80486c92d11e3..c32a1028be795 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -34,6 +34,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"; diff --git a/packages/block-library/src/template-part/block.json b/packages/block-library/src/template-part/block.json index 7d3870aff2d10..86807ca749e08 100644 --- a/packages/block-library/src/template-part/block.json +++ b/packages/block-library/src/template-part/block.json @@ -11,5 +11,8 @@ "theme": { "type": "string" } + }, + "supports": { + "html": false } } diff --git a/packages/block-library/src/template-part/edit.js b/packages/block-library/src/template-part/edit.js deleted file mode 100644 index 66566932ed390..0000000000000 --- a/packages/block-library/src/template-part/edit.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function TemplatePartEdit() { - return 'Template Part Placeholder'; -} diff --git a/packages/block-library/src/template-part/edit/index.js b/packages/block-library/src/template-part/edit/index.js new file mode 100644 index 0000000000000..d40180e17c783 --- /dev/null +++ b/packages/block-library/src/template-part/edit/index.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { useRef, useEffect } from '@wordpress/element'; +import { EntityProvider } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import useTemplatePartPost from './use-template-part-post'; +import TemplatePartInnerBlocks from './inner-blocks'; +import TemplatePartPlaceholder from './placeholder'; + +export default function TemplatePartEdit( { + attributes: { postId: _postId, slug, theme }, + setAttributes, +} ) { + const initialPostId = useRef( _postId ); + const initialSlug = useRef( slug ); + const initialTheme = useRef( theme ); + + // Resolve the post ID if not set, and load its post. + const postId = useTemplatePartPost( _postId, slug, theme ); + + // Set the post ID, once found, so that edits persist. + useEffect( () => { + if ( + ( initialPostId.current === undefined || initialPostId.current === null ) && + postId !== undefined && + postId !== null + ) { + setAttributes( { postId } ); + } + }, [ postId ] ); + + if ( postId ) { + // Part of a template file, post ID already resolved. + return ( + + + + ); + } + if ( ! initialSlug.current && ! initialTheme.current ) { + // Fresh new block. + return ; + } + // Part of a template file, post ID not resolved yet. + return null; +} diff --git a/packages/block-library/src/template-part/edit/inner-blocks.js b/packages/block-library/src/template-part/edit/inner-blocks.js new file mode 100644 index 0000000000000..f6eb61341ca69 --- /dev/null +++ b/packages/block-library/src/template-part/edit/inner-blocks.js @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import { useEntityBlockEditor } from '@wordpress/core-data'; +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function TemplatePartInnerBlocks() { + const [ blocks, onInput, onChange ] = useEntityBlockEditor( + 'postType', + 'wp_template_part', + { + initialEdits: { status: 'publish' }, + } + ); + return ( + + ); +} diff --git a/packages/block-library/src/template-part/edit/placeholder.js b/packages/block-library/src/template-part/edit/placeholder.js new file mode 100644 index 0000000000000..2258b1a90e691 --- /dev/null +++ b/packages/block-library/src/template-part/edit/placeholder.js @@ -0,0 +1,122 @@ +/** + * WordPress dependencies + */ +import { useEntityBlockEditor, EntityProvider } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { BlockPreview } from '@wordpress/block-editor'; +import { useState, useCallback } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { cleanForSlug } from '@wordpress/url'; +import { Placeholder, TextControl, Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import useTemplatePartPost from './use-template-part-post'; + +function TemplatePartPreview() { + const [ blocks ] = useEntityBlockEditor( 'postType', 'wp_template_part' ); + return ( +
+
+ { __( 'Preview' ) } +
+ +
+ ); +} + +export default function TemplatePartPlaceholder( { setAttributes } ) { + const [ slug, _setSlug ] = useState(); + const [ theme, setTheme ] = useState(); + const [ help, setHelp ] = useState(); + + // Try to find an existing template part. + const postId = useTemplatePartPost( null, slug, theme ); + + // If found, get its preview. + const preview = useSelect( + ( select ) => { + if ( ! postId ) { + return; + } + const templatePart = select( 'core' ).getEntityRecord( + 'postType', + 'wp_template_part', + postId + ); + if ( templatePart ) { + return ( + + + + ); + } + }, + [ postId ] + ); + + const setSlug = useCallback( ( nextSlug ) => { + _setSlug( nextSlug ); + setHelp( cleanForSlug( nextSlug ) ); + }, [] ); + + const { saveEntityRecord } = useDispatch( 'core' ); + const onChooseOrCreate = useCallback( async () => { + const nextAttributes = { slug, theme }; + if ( postId !== undefined && postId !== null ) { + // Existing template part found. + nextAttributes.postId = postId; + } else { + // Create a new template part. + try { + const cleanSlug = cleanForSlug( slug ); + const templatePart = await saveEntityRecord( + 'postType', + 'wp_template_part', + { + title: cleanSlug, + status: 'publish', + slug: cleanSlug, + meta: { theme }, + } + ); + nextAttributes.postId = templatePart.id; + } catch ( err ) { + setHelp( __( 'Error adding template.' ) ); + } + } + setAttributes( nextAttributes ); + }, [ postId, slug, theme ] ); + return ( + +
+ + +
+ { preview } + +
+ ); +} diff --git a/packages/block-library/src/template-part/edit/use-template-part-post.js b/packages/block-library/src/template-part/edit/use-template-part-post.js new file mode 100644 index 0000000000000..54391be3e0ce9 --- /dev/null +++ b/packages/block-library/src/template-part/edit/use-template-part-post.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + +export default function useTemplatePartPost( postId, slug, theme ) { + return 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. + if ( slug && theme ) { + const posts = select( 'core' ).getEntityRecords( + 'postType', + 'wp_template_part', + { + status: 'auto-draft', + slug, + meta: { theme }, + } + ); + const foundPost = + posts && + posts.find( + ( post ) => post.slug === slug && post.meta && post.meta.theme === theme + ); + return foundPost && foundPost.id; + } + }, + [ postId, slug, theme ] + ); +} diff --git a/packages/block-library/src/template-part/editor.scss b/packages/block-library/src/template-part/editor.scss new file mode 100644 index 0000000000000..2d32895db8131 --- /dev/null +++ b/packages/block-library/src/template-part/editor.scss @@ -0,0 +1,28 @@ +.wp-block-template-part__placeholder-input-container { + display: flex; + flex-wrap: wrap; + width: 100%; +} + +.wp-block-template-part__placeholder-input { + margin: 5px; +} + +.wp-block-template-part__placeholder-preview { + margin-bottom: 15px; + width: 100%; + + .block-editor-block-preview__container { + padding: 1px; + } + + .block-editor-block-preview__content { + position: initial; + } +} + +.wp-block-template-part__placeholder-preview-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 4px; +} diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 71f5fbd092001..de23c034048d1 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -24,6 +24,7 @@ "dependencies": { "@babel/runtime": "^7.4.4", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/blocks": "file:../blocks", "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 66fbd292ec340..3a08117082084 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -249,6 +249,26 @@ export function* saveEntityRecord( const entityIdKey = entity.key || DEFAULT_ENTITY_KEY; const recordId = record[ entityIdKey ]; + // Evaluate optimized edits. + // (Function edits that should be evaluated on save to avoid expensive computations on every edit.) + for ( const [ key, value ] of Object.entries( record ) ) { + if ( typeof value === 'function' ) { + const evaluatedValue = value( + yield select( 'getEditedEntityRecord', kind, name, recordId ) + ); + yield editEntityRecord( + kind, + name, + recordId, + { + [ key ]: evaluatedValue, + }, + { undoIgnore: true } + ); + record[ key ] = evaluatedValue; + } + } + yield { type: 'SAVE_ENTITY_RECORD_START', kind, name, recordId, isAutosave }; let updatedRecord; let error; diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js index 50439f1b57ad6..d080d77b06ce2 100644 --- a/packages/core-data/src/entity-provider.js +++ b/packages/core-data/src/entity-provider.js @@ -1,8 +1,14 @@ /** * WordPress dependencies */ -import { createContext, useContext, useCallback } from '@wordpress/element'; +import { + createContext, + useContext, + useCallback, + useMemo, +} from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; +import { parse, serialize } from '@wordpress/blocks'; /** * Internal dependencies @@ -122,13 +128,14 @@ 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 ) => @@ -136,7 +143,7 @@ export function __experimentalUseEntitySaving( kind, type, props ) { ) : editKeys.length > 0, isSavingEntityRecord( kind, type, id ), - _edits, + select, ]; }, [ kind, type, id, props ] @@ -144,11 +151,18 @@ export function __experimentalUseEntitySaving( kind, type, props ) { const { saveEntityRecord } = useDispatch( 'core' ); const save = useCallback( () => { - let filteredEdits = edits; + // We use the `select` from `useSelect` here instead of importing it from + // the data module so that we get the one bound to the provided registry, + // and not the default one. + 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 ]; } @@ -156,7 +170,67 @@ export function __experimentalUseEntitySaving( kind, type, props ) { }, {} ); } saveEntityRecord( kind, type, { id, ...filteredEdits } ); - }, [ kind, type, id, props, edits ] ); + }, [ kind, type, id, props, _select ] ); return [ isDirty, isSaving, save ]; } + +/** + * Hook that returns block content getters and setters for + * the nearest provided entity of the specified type. + * + * The return value has the shape `[ blocks, onInput, onChange ]`. + * `onInput` is for block changes that don't create undo levels + * or dirty the post, non-persistent changes, and `onChange` is for + * peristent changes. They map directly to the props of a + * `BlockEditorProvider` and are intended to be used with it, + * or similar components or hooks. + * + * @param {string} kind The entity kind. + * @param {string} type The entity type. + * @param {Object} options + * @param {Object} [options.initialEdits] Initial edits object for the entity record. + * @param {string} [options.blocksProp='blocks'] The name of the entity prop that holds the blocks array. + * @param {string} [options.contentProp='content'] The name of the entity prop that holds the serialized blocks. + * + * @return {[WPBlock[], Function, Function]} The block array and setters. + */ +export function useEntityBlockEditor( + kind, + type, + { initialEdits, blocksProp = 'blocks', contentProp = 'content' } = {} +) { + const [ content, setContent ] = useEntityProp( kind, type, contentProp ); + + const { editEntityRecord } = useDispatch( 'core' ); + const id = useEntityId( kind, type ); + const initialBlocks = useMemo( () => { + if ( initialEdits ) { + editEntityRecord( kind, type, id, initialEdits, { undoIgnore: true } ); + } + + // Guard against other instances that might have + // set content to a function already. + if ( typeof content !== 'function' ) { + const parsedContent = parse( content ); + return parsedContent.length ? parsedContent : []; + } + }, [ id ] ); // Reset when the provided entity record changes. + const [ blocks = initialBlocks, onInput ] = useEntityProp( + kind, + type, + blocksProp + ); + + const onChange = useCallback( + ( nextBlocks ) => { + onInput( nextBlocks ); + // Use a function edit to avoid serializing often. + setContent( ( { blocks: blocksToSerialize } ) => + serialize( blocksToSerialize ) + ); + }, + [ onInput, setContent ] + ); + return [ blocks, onInput, onChange ]; +} diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index bf12ac2e5096f..783e2bbdd4175 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -49,9 +49,5 @@ registerStore( REDUCER_KEY, { resolvers: { ...resolvers, ...entityResolvers }, } ); -export { - default as EntityProvider, - useEntityId, - useEntityProp, - __experimentalUseEntitySaving, -} from './entity-provider'; +export { default as EntityProvider } from './entity-provider'; +export * from './entity-provider'; diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js index 3c8d770d8d106..8c404ebb58862 100644 --- a/packages/editor/src/components/entities-saved-states/index.js +++ b/packages/editor/src/components/entities-saved-states/index.js @@ -1,7 +1,6 @@ /** * External dependencies */ -import { startCase } from 'lodash'; import EquivalentKeyMap from 'equivalent-key-map'; /** @@ -20,8 +19,9 @@ const EntitiesSavedStatesCheckbox = ( { setCheckedById, } ) => ( setCheckedById( id, nextChecked ) } diff --git a/packages/url/README.md b/packages/url/README.md index 5b381e074f48c..9436880c51f43 100644 --- a/packages/url/README.md +++ b/packages/url/README.md @@ -37,6 +37,27 @@ _Returns_ - `string`: URL with arguments applied. +# **cleanForSlug** + +Performs some basic cleanup of a string for use as a post slug. + +This replicates some of what `sanitize_title()` does in WordPress core, but +is only designed to approximate what the slug will be. + +Converts whitespace, periods, forward slashes and underscores to hyphens. +Converts Latin-1 Supplement and Latin Extended-A letters to basic Latin +letters. Removes combining diacritical marks. Converts remaining string +to lowercase. It does not touch octets, HTML entities, or other encoded +characters. + +_Parameters_ + +- _string_ `string`: Title or slug to be processed. + +_Returns_ + +- `string`: Processed string. + # **filterURLForDisplay** Returns a URL for display. diff --git a/packages/url/package.json b/packages/url/package.json index f1a7768f2a67b..73c82ab0daeb5 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -23,6 +23,7 @@ "sideEffects": false, "dependencies": { "@babel/runtime": "^7.4.4", + "lodash": "^4.17.15", "qs": "^6.5.2" }, "publishConfig": { diff --git a/packages/url/src/clean-for-slug.js b/packages/url/src/clean-for-slug.js new file mode 100644 index 0000000000000..58c814eced030 --- /dev/null +++ b/packages/url/src/clean-for-slug.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { deburr, toLower, trim } from 'lodash'; + +/** + * Performs some basic cleanup of a string for use as a post slug. + * + * This replicates some of what `sanitize_title()` does in WordPress core, but + * is only designed to approximate what the slug will be. + * + * Converts whitespace, periods, forward slashes and underscores to hyphens. + * Converts Latin-1 Supplement and Latin Extended-A letters to basic Latin + * letters. Removes combining diacritical marks. Converts remaining string + * to lowercase. It does not touch octets, HTML entities, or other encoded + * characters. + * + * @param {string} string Title or slug to be processed. + * + * @return {string} Processed string. + */ +export function cleanForSlug( string ) { + if ( ! string ) { + return ''; + } + return toLower( deburr( trim( string.replace( /[\s\./_]+/g, '-' ), '-' ) ) ); +} diff --git a/packages/url/src/index.js b/packages/url/src/index.js index e39d95f280486..3a47cba0bc3b5 100644 --- a/packages/url/src/index.js +++ b/packages/url/src/index.js @@ -18,3 +18,4 @@ export { prependHTTP } from './prepend-http'; export { safeDecodeURI } from './safe-decode-uri'; export { safeDecodeURIComponent } from './safe-decode-uri-component'; export { filterURLForDisplay } from './filter-url-for-display'; +export { cleanForSlug } from './clean-for-slug'; diff --git a/packages/url/src/test/index.test.js b/packages/url/src/test/index.test.js index 31b7e05ecf3e5..aa1ef282f5d06 100644 --- a/packages/url/src/test/index.test.js +++ b/packages/url/src/test/index.test.js @@ -26,6 +26,7 @@ import { prependHTTP, safeDecodeURI, filterURLForDisplay, + cleanForSlug, } from '../'; describe( 'isURL', () => { @@ -550,3 +551,16 @@ describe( 'filterURLForDisplay', () => { } ); } ); +describe( 'cleanForSlug', () => { + it( 'should return string prepared for use as url slug', () => { + expect( cleanForSlug( ' /Déjà_vu. ' ) ).toBe( 'deja-vu' ); + } ); + + it( 'should return an empty string for missing argument', () => { + expect( cleanForSlug() ).toBe( '' ); + } ); + + it( 'should return an empty string for falsy argument', () => { + expect( cleanForSlug( null ) ).toBe( '' ); + } ); +} );