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 (
+
+ );
+ /* 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'] );
}
/**