Skip to content

Commit

Permalink
Preview: Enable published posts preview (#7189)
Browse files Browse the repository at this point in the history
* REST API: Move preview_link to autosaves controller

* Post Preview: Redirect to preview by autosave property availability

* Preview Button: Use preview link as href if available

* Preview Button: Move window bind to after write

Was not reliably triggered when redirect occurs by setPreviewWindowLink, possibly because document.open triggered by document.write erased window handlers.

* Preview Button: Always trigger autosave when dirty

* Preview Button: Consider preview ready by change in value

* State: Set default value for savePost options

Allows use in handlers to assume options present

* Store: Clarify preview link with query parameter

* REST API: Set preview link by parent only if autosave

* Store: Invalid autosave preview link on save intent

Preview link is no longer valid until autosave completes, by which UI can infer availability of preview as being ready to view.

* Testing: Add preview E2E tests

* Testing: Fix copypasta

* Preview: Use correct location property for URL comparison
  • Loading branch information
aduth authored Jun 11, 2018
1 parent 4df1bb6 commit 111fb73
Show file tree
Hide file tree
Showing 16 changed files with 448 additions and 153 deletions.
59 changes: 33 additions & 26 deletions editor/components/post-preview-button/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,43 +16,49 @@ export class PostPreviewButton extends Component {
super( ...arguments );

this.saveForPreview = this.saveForPreview.bind( this );

this.state = {
isAwaitingSave: false,
};
}

componentWillReceiveProps( nextProps ) {
const { modified, link } = nextProps;
const { isAwaitingSave } = this.state;
const hasFinishedSaving = (
isAwaitingSave &&
modified !== this.props.modified
);
componentDidUpdate( prevProps ) {
const { previewLink } = this.props;

if ( hasFinishedSaving && this.previewWindow ) {
this.previewWindow.location = link;
this.setState( { isAwaitingSave: false } );
// This relies on the window being responsible to unset itself when
// navigation occurs or a new preview window is opened, to avoid
// unintentional forceful redirects.
if ( previewLink && previewLink !== prevProps.previewLink ) {
this.setPreviewWindowLink( previewLink );
}
}

/**
* Sets the preview window's location to the given URL, if a preview window
* exists and is not already at that location.
*
* @param {string} url URL to assign as preview window location.
*/
setPreviewWindowLink( url ) {
const { previewWindow } = this;
if ( ! previewWindow || previewWindow.location.href === url ) {
return;
}

previewWindow.location = url;
}

getWindowTarget() {
const { postId } = this.props;
return `wp-preview-${ postId }`;
}

saveForPreview( event ) {
const { isDirty, isNew } = this.props;

// Let default link behavior occur if no changes to saved post
if ( ! isDirty && ! isNew ) {
return;
}

// Save post prior to opening window
this.props.autosave();
this.setState( {
isAwaitingSave: true,
} );

// Open a popup, BUT: Set it to a blank page until save completes. This
// is necessary because popups can only be opened in response to user
Expand All @@ -63,10 +69,6 @@ export class PostPreviewButton extends Component {
this.getWindowTarget()
);

// When popup is closed, delete reference to avoid later assignment of
// location in a post update.
this.previewWindow.onbeforeunload = () => delete this.previewWindow;

const markup = `
<div>
<p>Please wait&hellip;</p>
Expand All @@ -92,16 +94,20 @@ export class PostPreviewButton extends Component {

this.previewWindow.document.write( markup );
this.previewWindow.document.close();

// When popup is closed or redirected by setPreviewWindowLink, delete
// reference to avoid later assignment of location in a post update.
this.previewWindow.onbeforeunload = () => delete this.previewWindow;
}

render() {
const { link, isSaveable } = this.props;
const { currentPostLink, isSaveable } = this.props;

return (
<Button
className="editor-post-preview"
isLarge
href={ link }
href={ currentPostLink }
onClick={ this.saveForPreview }
target={ this.getWindowTarget() }
disabled={ ! isSaveable }
Expand All @@ -116,7 +122,8 @@ export default compose( [
withSelect( ( select ) => {
const {
getCurrentPostId,
getEditedPostPreviewLink,
getCurrentPostAttribute,
getAutosaveAttribute,
getEditedPostAttribute,
isEditedPostDirty,
isEditedPostNew,
Expand All @@ -128,12 +135,12 @@ export default compose( [
const postType = getPostType( getEditedPostAttribute( 'type' ) );
return {
postId: getCurrentPostId(),
link: getEditedPostPreviewLink(),
currentPostLink: getCurrentPostAttribute( 'link' ),
previewLink: getAutosaveAttribute( 'preview_link' ),
isDirty: isEditedPostDirty(),
isNew: isEditedPostNew(),
isSaveable: isEditedPostSaveable(),
isViewable: get( postType, [ 'viewable' ], false ),
modified: getEditedPostAttribute( 'modified' ),
};
} ),
withDispatch( ( dispatch ) => ( {
Expand Down
68 changes: 54 additions & 14 deletions editor/components/post-preview-button/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,55 @@ import { shallow } from 'enzyme';
import { PostPreviewButton } from '../';

describe( 'PostPreviewButton', () => {
describe( 'constructor()', () => {
it( 'should initialize with non-awaiting-save', () => {
const instance = new PostPreviewButton( {} );
describe( 'setPreviewWindowLink()', () => {
it( 'should do nothing if there is no preview window', () => {
const url = 'https://wordpress.org';
const setter = jest.fn();
const wrapper = shallow( <PostPreviewButton /> );

expect( instance.state.isAwaitingSave ).toBe( false );
wrapper.instance().setPreviewWindowLink( url );

expect( setter ).not.toHaveBeenCalled();
} );

it( 'should do nothing if the preview window is already at url location', () => {
const url = 'https://wordpress.org';
const setter = jest.fn();
const wrapper = shallow( <PostPreviewButton /> );
wrapper.instance().previewWindow = {
get location() {
return {
href: url,
};
},
set location( value ) {
setter( value );
},
};

wrapper.instance().setPreviewWindowLink( url );

expect( setter ).not.toHaveBeenCalled();
} );

it( 'set preview window location to url', () => {
const url = 'https://wordpress.org';
const setter = jest.fn();
const wrapper = shallow( <PostPreviewButton /> );
wrapper.instance().previewWindow = {
get location() {
return {
href: 'about:blank',
};
},
set location( value ) {
setter( value );
},
};

wrapper.instance().setPreviewWindowLink( url );

expect( setter ).toHaveBeenCalledWith( url );
} );
} );

Expand All @@ -28,23 +72,21 @@ describe( 'PostPreviewButton', () => {
} );

describe( 'componentDidUpdate()', () => {
it( 'should change popup location if save finishes', () => {
it( 'should change popup location if preview link is available', () => {
const wrapper = shallow(
<PostPreviewButton
postId={ 1 }
link="https://wordpress.org/?p=1"
currentPostLink="https://wordpress.org/?p=1"
isSaveable
modified="2017-08-03T15:05:50" />
);
wrapper.instance().previewWindow = {};
wrapper.setState( { isAwaitingSave: true } );
wrapper.instance().previewWindow = { location: {} };

wrapper.setProps( { modified: '2017-08-03T15:05:52' } );
wrapper.setProps( { previewLink: 'https://wordpress.org/?p=1' } );

expect(
wrapper.instance().previewWindow.location
).toBe( 'https://wordpress.org/?p=1' );
expect( wrapper.state( 'isAwaitingSave' ) ).toBe( false );
} );
} );

Expand All @@ -71,13 +113,11 @@ describe( 'PostPreviewButton', () => {
if ( isExpectingSave ) {
expect( autosave ).toHaveBeenCalled();
expect( preventDefault ).toHaveBeenCalled();
expect( wrapper.state( 'isAwaitingSave' ) ).toBe( true );
expect( window.open ).toHaveBeenCalled();
expect( wrapper.instance().previewWindow.document.write ).toHaveBeenCalled();
} else {
expect( autosave ).not.toHaveBeenCalled();
expect( preventDefault ).not.toHaveBeenCalled();
expect( wrapper.state( 'isAwaitingSave' ) ).not.toBe( true );
expect( window.open ).not.toHaveBeenCalled();
}

Expand Down Expand Up @@ -118,7 +158,7 @@ describe( 'PostPreviewButton', () => {
<PostPreviewButton
postId={ 1 }
isSaveable
link="https://wordpress.org/?p=1" />
currentPostLink="https://wordpress.org/?p=1" />
);

expect( wrapper.prop( 'href' ) ).toBe( 'https://wordpress.org/?p=1' );
Expand All @@ -130,7 +170,7 @@ describe( 'PostPreviewButton', () => {
const wrapper = shallow(
<PostPreviewButton
postId={ 1 }
link="https://wordpress.org/?p=1" />
currentPostLink="https://wordpress.org/?p=1" />
);

expect( wrapper.prop( 'disabled' ) ).toBe( true );
Expand Down
2 changes: 1 addition & 1 deletion editor/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ export function editPost( edits ) {
*
* @return {Object} Action object.
*/
export function savePost( options ) {
export function savePost( options = {} ) {
return {
type: 'REQUEST_POST_UPDATE',
options,
Expand Down
43 changes: 4 additions & 39 deletions editor/store/effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export default {
const { dispatch, getState } = store;
const state = getState();
const post = getCurrentPost( state );
const isAutosave = get( action.options, [ 'autosave' ], false );
const isAutosave = !! action.options.autosave;

// Prevent save if not saveable.
const isSaveable = isAutosave ? isEditedPostAutosaveable : isEditedPostSaveable;
Expand Down Expand Up @@ -157,49 +157,14 @@ export default {

request.then(
( newPost ) => {
const reset = isAutosave ? resetAutosave : resetPost;
dispatch( reset( newPost ) );

// An autosave may be processed by the server as a regular save
// when its update is requested by the author and the post was
// draft or auto-draft.
const isRevision = newPost.id !== post.id;

// Thus, the following behaviors occur:
//
// - If it was a revision, it is treated as latest autosave
// and updates optimistically applied are reverted.
// - If it was an autosave but not revision under the above
// noted conditions, cherry-pick updated properties since
// the received revision entity shares some but not all
// properties of a post.
// - Otherwise, it was a full save and the received entity
// can be considered the new reset post.
let updateAction;
if ( isRevision ) {
updateAction = resetAutosave( newPost );
} else if ( isAutosave ) {
const revisionEdits = pick( newPost, [
// Autosave content fields.
'title',
'content',
'excerpt',

// UI considers save to have occurred if modified date
// of post changes (e.g. PostPreviewButton).
//
// TODO: Consider formalized pattern for identifying a
// save as having completed.
'date',
'date_gmt',
'modified',
'modified_gmt',
] );

updateAction = updatePost( revisionEdits );
} else {
updateAction = resetPost( newPost );
}

dispatch( updateAction );

dispatch( {
type: 'REQUEST_POST_UPDATE_SUCCESS',
previousPost: post,
Expand Down
14 changes: 13 additions & 1 deletion editor/store/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,19 @@ export function autosave( state = null, action ) {
'content',
].map( ( field ) => getPostRawValue( post[ field ] ) );

return { title, excerpt, content };
return {
title,
excerpt,
content,
preview_link: post.preview_link,
};

case 'REQUEST_POST_UPDATE':
// Invalidate known preview link when autosave starts.
if ( state && action.options.autosave ) {
return omit( state, 'preview_link' );
}
break;
}

return state;
Expand Down
43 changes: 40 additions & 3 deletions editor/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ export function getPostEdits( state ) {
return get( state, [ 'editor', 'present', 'edits' ], {} );
}

/**
* Returns an attribute value of the saved post.
*
* @param {Object} state Global application state.
* @param {string} attributeName Post attribute name.
*
* @return {*} Post attribute value.
*/
export function getCurrentPostAttribute( state, attributeName ) {
const post = getCurrentPost( state );
if ( post.hasOwnProperty( attributeName ) ) {
return post[ attributeName ];
}
}

/**
* Returns a single attribute of the post being edited, preferring the unsaved
* edit if one exists, but falling back to the attribute for the last known
Expand All @@ -201,9 +216,31 @@ export function getEditedPostAttribute( state, attributeName ) {
return getEditedPostContent( state );
}

return edits[ attributeName ] === undefined ?
state.currentPost[ attributeName ] :
edits[ attributeName ];
if ( ! edits.hasOwnProperty( attributeName ) ) {
return getCurrentPostAttribute( state, attributeName );
}

return edits[ attributeName ];
}

/**
* Returns an attribute value of the current autosave revision for a post, or
* null if there is no autosave for the post.
*
* @param {Object} state Global application state.
* @param {string} attributeName Autosave attribute name.
*
* @return {*} Autosave attribute value.
*/
export function getAutosaveAttribute( state, attributeName ) {
if ( ! hasAutosave( state ) ) {
return null;
}

const autosave = getAutosave( state );
if ( autosave.hasOwnProperty( attributeName ) ) {
return autosave[ attributeName ];
}
}

/**
Expand Down
Loading

0 comments on commit 111fb73

Please sign in to comment.