diff --git a/components/button/index.js b/components/button/index.js index 98a076fc3d8d7e..49713d398a8245 100644 --- a/components/button/index.js +++ b/components/button/index.js @@ -29,6 +29,10 @@ class Button extends Component { this.ref = ref; } + focus() { + this.ref.focus(); + } + render() { const { href, diff --git a/editor/components/post-permalink/editor.js b/editor/components/post-permalink/editor.js new file mode 100644 index 00000000000000..64a5a2f87a9fd9 --- /dev/null +++ b/editor/components/post-permalink/editor.js @@ -0,0 +1,98 @@ +/** + * WordPress dependencies + */ +import { withDispatch, withSelect } from '@wordpress/data'; +import { Component, compose } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; + +/** + * Internal Dependencies + */ +import './style.scss'; + +class PostPermalinkEditor extends Component { + constructor( { permalinkParts } ) { + super( ...arguments ); + + this.state = { + editedPostName: permalinkParts.postName, + }; + + this.onSavePermalink = this.onSavePermalink.bind( this ); + } + + onSavePermalink( event ) { + const postName = this.state.editedPostName.replace( /\s+/g, '-' ); + + event.preventDefault(); + + this.props.onSave(); + + if ( ! postName || postName === this.props.postName ) { + return; + } + + this.props.editPost( { + slug: postName, + } ); + + this.setState( { + editedPostName: postName, + } ); + } + + render() { + const { prefix, suffix } = this.props.permalinkParts; + const { editedPostName } = this.state; + + /* eslint-disable jsx-a11y/no-autofocus */ + // Autofocus is allowed here, as this mini-UI is only loaded when the user clicks to open it. + return ( +
+ + + { prefix } + + this.setState( { editedPostName: event.target.value } ) } + required + autoFocus + /> + + { suffix } + + ‎ + + +
+ ); + /* eslint-enable jsx-a11y/no-autofocus */ + } +} + +export default compose( [ + withSelect( ( select ) => { + const { getPermalinkParts } = select( 'core/editor' ); + return { + permalinkParts: getPermalinkParts(), + }; + } ), + withDispatch( ( dispatch ) => { + const { editPost } = dispatch( 'core/editor' ); + return { editPost }; + } ), +] )( PostPermalinkEditor ); + diff --git a/editor/components/post-permalink/index.js b/editor/components/post-permalink/index.js index c7c19f8b5e5a4c..b479e7aac288f0 100644 --- a/editor/components/post-permalink/index.js +++ b/editor/components/post-permalink/index.js @@ -1,79 +1,134 @@ -/** - * External dependencies - */ -import { connect } from 'react-redux'; - /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { Component, compose } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Dashicon, ClipboardButton, Button } from '@wordpress/components'; +import { Dashicon, Button, ClipboardButton, Tooltip } from '@wordpress/components'; /** * Internal Dependencies */ import './style.scss'; -import { isEditedPostNew, getEditedPostAttribute } from '../../store/selectors'; +import PostPermalinkEditor from './editor.js'; +import { getWPAdminURL } from '../../utils/url'; class PostPermalink extends Component { constructor() { super( ...arguments ); + + this.addVisibilityCheck = this.addVisibilityCheck.bind( this ); + this.onVisibilityChange = this.onVisibilityChange.bind( this ); + this.state = { - showCopyConfirmation: false, + iconClass: '', + isEditingPermalink: false, }; - this.onCopy = this.onCopy.bind( this ); - this.onFinishCopy = this.onFinishCopy.bind( this ); } - componentWillUnmount() { - clearTimeout( this.dismissCopyConfirmation ); + addVisibilityCheck() { + window.addEventListener( 'visibilitychange', this.onVisibilityChange ); + } + + onVisibilityChange() { + const { isEditable, refreshPost } = this.props; + // If the user just returned after having clicked the "Change Permalinks" button, + // fetch a new copy of the post from the server, just in case they enabled permalinks. + if ( ! isEditable && 'visible' === document.visibilityState ) { + refreshPost(); + } } - onCopy() { - this.setState( { - showCopyConfirmation: true, - } ); + componentDidUpdate( prevProps, prevState ) { + // If we've just stopped editing the permalink, focus on the new permalink. + if ( prevState.isEditingPermalink && ! this.state.isEditingPermalink ) { + this.permalinkButton.focus(); + } } - onFinishCopy() { - this.setState( { - showCopyConfirmation: false, - } ); + componentWillUnmount() { + window.removeEventListener( 'visibilitychange', this.addVisibilityCheck ); } render() { - const { isNew, link } = this.props; - if ( isNew || ! link ) { + const { isNew, previewLink, isEditable, samplePermalink } = this.props; + const { iconClass, isEditingPermalink } = this.state; + + if ( isNew || ! previewLink ) { return null; } return (
- + + this.setState( { iconClass: 'is-copied' } ) } + > + + + + { __( 'Permalink:' ) } - - - { this.state.showCopyConfirmation ? __( 'Copied!' ) : __( 'Copy' ) } - + + { ! isEditingPermalink && + + } + + { isEditingPermalink && + this.setState( { isEditingPermalink: false } ) } + /> + } + + { isEditable && ! isEditingPermalink && + + } + + { ! isEditable && + + }
); } } -export default connect( - ( state ) => { +export default compose( [ + withSelect( ( select ) => { + const { isEditedPostNew, isPermalinkEditable, getEditedPostPreviewLink, getPermalink } = select( 'core/editor' ); return { - isNew: isEditedPostNew( state ), - link: getEditedPostAttribute( state, 'link' ), + isNew: isEditedPostNew(), + previewLink: getEditedPostPreviewLink(), + isEditable: isPermalinkEditable(), + samplePermalink: getPermalink(), }; - } -)( PostPermalink ); + } ), + withDispatch( ( dispatch ) => { + const { refreshPost } = dispatch( 'core/editor' ); + return { refreshPost }; + } ), +] )( PostPermalink ); diff --git a/editor/components/post-permalink/style.scss b/editor/components/post-permalink/style.scss index 92213eb18813ae..1747133f5b8966 100644 --- a/editor/components/post-permalink/style.scss +++ b/editor/components/post-permalink/style.scss @@ -17,6 +17,14 @@ } } +.editor-post-permalink__copy { + margin-top: 4px; +} + +.editor-post-permalink__copy .is-copied { + opacity: 0.3; +} + .editor-post-permalink__label { margin: 0 10px; } @@ -31,6 +39,37 @@ white-space: nowrap; &:after { - @include long-content-fade( $size: 20% ); + @include long-content-fade( $size: 20%, $edge: 1px ); + } +} + +.editor-post-permalink-editor { + width: 100%; + min-width: 20%; + display: inline-flex; + align-items: center; + + // Higher specificity required to override core margin styles + .editor-post-permalink-editor__save { + margin-left: auto; } } + +.editor-post-permalink-editor__prefix { + color: $dark-gray-300; + min-width: 20%; + overflow: hidden; + position: relative; + white-space: nowrap; + text-overflow: ellipsis; +} + +.editor-post-permalink-editor__edit { + min-width: 20%; + margin: 0 5px; +} + +.editor-post-permalink-editor__suffix { + color: $dark-gray-300; + margin-right: 10px; +} diff --git a/editor/components/post-title/index.js b/editor/components/post-title/index.js index 060320dc293a9e..79d04da5a7387f 100644 --- a/editor/components/post-title/index.js +++ b/editor/components/post-title/index.js @@ -3,6 +3,7 @@ */ import Textarea from 'react-autosize-textarea'; import classnames from 'classnames'; +import { get } from 'lodash'; /** * WordPress dependencies @@ -88,7 +89,7 @@ class PostTitle extends Component { } render() { - const { title, placeholder, instanceId } = this.props; + const { title, placeholder, instanceId, isPostTypeViewable } = this.props; const { isSelected } = this.state; const className = classnames( 'editor-post-title', { 'is-selected': isSelected } ); const decodedPlaceholder = decodeEntities( placeholder ); @@ -96,7 +97,6 @@ class PostTitle extends Component { return (
- { isSelected && } + { isSelected && isPostTypeViewable && }
); @@ -125,9 +126,12 @@ class PostTitle extends Component { const applyWithSelect = withSelect( ( select ) => { const { getEditedPostAttribute } = select( 'core/editor' ); + const { getPostType } = select( 'core' ); + const postType = getPostType( getEditedPostAttribute( 'type' ) ); return { title: getEditedPostAttribute( 'title' ), + isPostTypeViewable: get( postType, [ 'viewable' ], false ), }; } ); diff --git a/editor/store/actions.js b/editor/store/actions.js index 47005fd67a4ad3..d80417db2227b4 100644 --- a/editor/store/actions.js +++ b/editor/store/actions.js @@ -359,6 +359,12 @@ export function savePost() { }; } +export function refreshPost() { + return { + type: 'REFRESH_POST', + }; +} + export function trashPost( postId, postType ) { return { type: 'TRASH_POST', diff --git a/editor/store/effects.js b/editor/store/effects.js index bf40697c05d5d7..cf8ac1774354b8 100644 --- a/editor/store/effects.js +++ b/editor/store/effects.js @@ -117,6 +117,7 @@ export default { type: 'REQUEST_POST_UPDATE_SUCCESS', previousPost: post, post: newPost, + edits: toSend, optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID }, } ); }, @@ -238,6 +239,23 @@ export default { const message = action.error.message && action.error.code !== 'unknown_error' ? action.error.message : __( 'Trashing failed' ); store.dispatch( createErrorNotice( message, { id: TRASH_POST_NOTICE_ID } ) ); }, + REFRESH_POST( action, store ) { + const { dispatch, getState } = store; + + const state = getState(); + const post = getCurrentPost( state ); + const basePath = wp.api.getPostTypeRoute( getCurrentPostType( state ) ); + + const data = { + context: 'edit', + }; + + wp.apiRequest( { path: `/wp/v2/${ basePath }/${ post.id }`, data } ).then( + ( newPost ) => { + dispatch( resetPost( newPost ) ); + } + ); + }, MERGE_BLOCKS( action, store ) { const { dispatch } = store; const state = store.getState(); diff --git a/editor/store/reducer.js b/editor/store/reducer.js index d36fbc32fe7494..6d981f278de586 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -254,9 +254,14 @@ export const editor = flow( [ return state; + case 'UPDATE_POST': case 'RESET_POST': + const getCanonicalValue = action.type === 'UPDATE_POST' ? + ( key ) => action.edits[ key ] : + ( key ) => getPostRawValue( action.post[ key ] ); + return reduce( state, ( result, value, key ) => { - if ( value !== getPostRawValue( action.post[ key ] ) ) { + if ( value !== getCanonicalValue( key ) ) { return result; } diff --git a/editor/store/selectors.js b/editor/store/selectors.js index 7285e02d7e9511..9bbe5e78b15947 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -31,6 +31,7 @@ import { deprecated } from '@wordpress/utils'; */ const MAX_RECENT_BLOCKS = 9; export const POST_UPDATE_TRANSACTION_ID = 'post-update'; +const PERMALINK_POSTNAME_REGEX = /%(?:postname|pagename)%/; /** * Shared reference to an empty array for cases where it is important to avoid @@ -1516,3 +1517,53 @@ export function isPublishingPost( state ) { export function getProvisionalBlockUID( state ) { return state.provisionalBlockUID; } + +/** + * Returns whether the permalink is editable or not. + * + * @param {Object} state Editor state. + * + * @return {boolean} Whether or not the permalink is editable. + */ +export function isPermalinkEditable( state ) { + const permalinkTemplate = getEditedPostAttribute( state, 'permalink_template' ); + + return PERMALINK_POSTNAME_REGEX.test( permalinkTemplate ); +} + +/** + * Returns the permalink for the post. + * + * @param {Object} state Editor state. + * + * @return {string} The permalink. + */ +export function getPermalink( state ) { + const { prefix, postName, suffix } = getPermalinkParts( state ); + + if ( isPermalinkEditable( state ) ) { + return prefix + postName + suffix; + } + + return prefix; +} + +/** + * Returns the permalink for a post, split into it's three parts: the prefix, the postName, and the suffix. + * + * @param {Object} state Editor state. + * + * @return {Object} The prefix, postName, and suffix for the permalink. + */ +export function getPermalinkParts( state ) { + const permalinkTemplate = getEditedPostAttribute( state, 'permalink_template' ); + const postName = getEditedPostAttribute( state, 'slug' ) || getEditedPostAttribute( state, 'draft_slug' ); + + const [ prefix, suffix ] = permalinkTemplate.split( PERMALINK_POSTNAME_REGEX ); + + return { + prefix, + postName, + suffix, + }; +} diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index e89c0f445f0ab2..a3c6d7e1ca3209 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -84,6 +84,9 @@ const { getTemplate, getTemplateLock, POST_UPDATE_TRANSACTION_ID, + isPermalinkEditable, + getPermalink, + getPermalinkParts, } = selectors; describe( 'selectors', () => { @@ -3093,4 +3096,93 @@ describe( 'selectors', () => { expect( getTemplateLock( state ) ).toBe( 'all' ); } ); } ); + + describe( 'isPermalinkEditable', () => { + it( 'should be false if there is no permalink', () => { + const state = { + currentPost: { permalink_template: '' }, + }; + + expect( isPermalinkEditable( state ) ).toBe( false ); + } ); + + it( 'should be false if the permalink is not of an editable kind', () => { + const state = { + currentPost: { permalink_template: 'http://foo.test/bar/%baz%/' }, + }; + + expect( isPermalinkEditable( state ) ).toBe( false ); + } ); + + it( 'should be true if the permalink has %postname%', () => { + const state = { + currentPost: { permalink_template: 'http://foo.test/bar/%postname%/' }, + }; + + expect( isPermalinkEditable( state ) ).toBe( true ); + } ); + + it( 'should be true if the permalink has %pagename%', () => { + const state = { + currentPost: { permalink_template: 'http://foo.test/bar/%pagename%/' }, + }; + + expect( isPermalinkEditable( state ) ).toBe( true ); + } ); + } ); + + describe( 'getPermalink', () => { + it( 'should work if the permalink is not of an editable kind', () => { + const url = 'http://foo.test/?post=1'; + const state = { + currentPost: { permalink_template: url }, + }; + + expect( getPermalink( state ) ).toBe( url ); + } ); + + it( 'should return the permalink if it is editable', () => { + const state = { + currentPost: { + permalink_template: 'http://foo.test/bar/%postname%/', + slug: 'baz', + }, + }; + + expect( getPermalink( state ) ).toBe( 'http://foo.test/bar/baz/' ); + } ); + } ); + + describe( 'getPermalinkParts', () => { + it( 'should split the permalink correctly', () => { + const parts = { + prefix: 'http://foo.test/bar/', + postName: 'baz', + suffix: '/', + }; + const state = { + currentPost: { + permalink_template: 'http://foo.test/bar/%postname%/', + slug: 'baz', + }, + }; + + expect( getPermalinkParts( state ) ).toEqual( parts ); + } ); + + it( 'should leave an uneditable permalink in the prefix', () => { + const parts = { + prefix: 'http://foo.test/?post=1', + postName: 'baz', + }; + const state = { + currentPost: { + permalink_template: 'http://foo.test/?post=1', + slug: 'baz', + }, + }; + + expect( getPermalinkParts( state ) ).toEqual( parts ); + } ); + } ); } ); diff --git a/lib/compat.php b/lib/compat.php index c53ad6847e1a0b..23ceedb0f011b6 100644 --- a/lib/compat.php +++ b/lib/compat.php @@ -437,3 +437,76 @@ function gutenberg_get_taxonomy_visibility_data( $object ) { } add_action( 'rest_api_init', 'gutenberg_add_taxonomy_visibility_field' ); + +/** + * Add a permalink template to posts in the post REST API response. + * + * @param WP_REST_Response $response WP REST API response of a post. + * @param WP_Post $post The post being returned. + * @param WP_REST_Request $request WP REST API request. + * @return WP_REST_Response Response containing the permalink_template. + */ +function gutenberg_add_permalink_template_to_posts( $response, $post, $request ) { + if ( 'edit' !== $request['context'] ) { + return $response; + } + + if ( ! function_exists( 'get_sample_permalink' ) ) { + require_once ABSPATH . '/wp-admin/includes/post.php'; + } + + $sample_permalink = get_sample_permalink( $post->ID ); + + $response->data['permalink_template'] = $sample_permalink[0]; + + if ( 'draft' === $post->post_status && ! $post->post_name ) { + $response->data['draft_slug'] = $sample_permalink[1]; + } + + return $response; +} + +/** + * Whenever a post type is registered, ensure we're hooked into it's WP REST API response. + * + * @param string $post_type The newly registered post type. + * @return string That same post type. + */ +function gutenberg_register_permalink_template_function( $post_type ) { + add_filter( "rest_prepare_{$post_type}", 'gutenberg_add_permalink_template_to_posts', 10, 3 ); + return $post_type; +} +add_filter( 'registered_post_type', 'gutenberg_register_permalink_template_function' ); + +/** + * Includes the value for the 'viewable' attribute of a post type resource. + * + * @see https://core.trac.wordpress.org/ticket/43739 + * + * @param object $post_type Post type response object. + * @return boolean Whether or not the post type can be viewed. + */ +function gutenberg_get_post_type_viewable( $post_type ) { + return is_post_type_viewable( $post_type['slug'] ); +} + +/** + * Adds the 'viewable' attribute to the REST API response of a post type. + * + * @see https://core.trac.wordpress.org/ticket/43739 + */ +function gutenberg_register_rest_api_post_type_viewable() { + register_rest_field( 'type', + 'viewable', + array( + 'get_callback' => 'gutenberg_get_post_type_viewable', + 'schema' => array( + 'description' => __( 'Whether or not the post type can be viewed', 'gutenberg' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_register_rest_api_post_type_viewable' ); diff --git a/phpunit/class-rest-blocks-controller-test.php b/phpunit/class-rest-blocks-controller-test.php index f29ff5c2f767cb..bafcb2b31b6a7d 100644 --- a/phpunit/class-rest-blocks-controller-test.php +++ b/phpunit/class-rest-blocks-controller-test.php @@ -124,13 +124,16 @@ public function test_create_item() { $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => self::$post_id, - 'title' => 'New cool block', - 'content' => '

Wow!

', - ), $response->get_data() - ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertArrayHasKey( 'content', $data ); + + $this->assertEquals( self::$post_id, $data['id'] ); + $this->assertEquals( 'New cool block', $data['title'] ); + $this->assertEquals( '

Wow!

', $data['content'] ); } /** @@ -150,13 +153,16 @@ public function test_update_item() { $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => self::$post_id, - 'title' => 'Updated cool block', - 'content' => '

Nice!

', - ), $response->get_data() - ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertArrayHasKey( 'content', $data ); + + $this->assertEquals( self::$post_id, $data['id'] ); + $this->assertEquals( 'Updated cool block', $data['title'] ); + $this->assertEquals( '

Nice!

', $data['content'] ); } /** @@ -170,16 +176,21 @@ public function test_delete_item() { $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'deleted' => true, - 'previous' => array( - 'id' => self::$post_id, - 'title' => 'My cool block', - 'content' => '

Hello!

', - ), - ), $response->get_data() - ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'deleted', $data ); + $this->assertArrayHasKey( 'previous', $data ); + + $this->assertTrue( $data['deleted'] ); + + $this->assertArrayHasKey( 'id', $data['previous'] ); + $this->assertArrayHasKey( 'title', $data['previous'] ); + $this->assertArrayHasKey( 'content', $data['previous'] ); + + $this->assertEquals( self::$post_id, $data['previous']['id'] ); + $this->assertEquals( 'My cool block', $data['previous']['title'] ); + $this->assertEquals( '

Hello!

', $data['previous']['content'] ); } /**