-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Prevent autosave when the post is locked #32475
base: trunk
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -36,6 +36,7 @@ export interface State { | |||||
currentGlobalStylesId: string; | ||||||
currentTheme: string; | ||||||
currentUser: ET.User< 'edit' >; | ||||||
currentUserSession: { isActive: boolean }; | ||||||
embedPreviews: Record< string, { html: string } >; | ||||||
entities: EntitiesState; | ||||||
themeBaseGlobalStyles: Record< string, Object >; | ||||||
|
@@ -185,6 +186,21 @@ export function getCurrentUser( state: State ): ET.User< 'edit' > { | |||||
return state.currentUser; | ||||||
} | ||||||
|
||||||
/** | ||||||
*Returns a boolean flag for current user's active login session. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
* | ||||||
* By default this returns true, because we assume that by default the user session is active. | ||||||
* If the block editor is already being loaded for user and if the data store is already being initialised for the user, | ||||||
* it means user is already logged in. | ||||||
* | ||||||
* @param {Object} state Current state data. | ||||||
* | ||||||
* @return {boolean} Whether the current user has active login maintained or not. | ||||||
*/ | ||||||
export function __experimentalIsCurrentUserSessionActive( state: State ) { | ||||||
return state.currentUserSession?.isActive ?? true; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Returns all the users returned by a query ID. | ||||||
* | ||||||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -10,7 +10,11 @@ import { | |||||||||||||||||||||||
} from '@wordpress/components'; | ||||||||||||||||||||||||
import { useSelect, useDispatch } from '@wordpress/data'; | ||||||||||||||||||||||||
import { addQueryArgs } from '@wordpress/url'; | ||||||||||||||||||||||||
import { useEffect, createInterpolateElement } from '@wordpress/element'; | ||||||||||||||||||||||||
import { | ||||||||||||||||||||||||
useEffect, | ||||||||||||||||||||||||
useState, | ||||||||||||||||||||||||
createInterpolateElement, | ||||||||||||||||||||||||
} from '@wordpress/element'; | ||||||||||||||||||||||||
import { addAction, removeAction } from '@wordpress/hooks'; | ||||||||||||||||||||||||
import { useInstanceId } from '@wordpress/compose'; | ||||||||||||||||||||||||
import { store as coreStore } from '@wordpress/core-data'; | ||||||||||||||||||||||||
|
@@ -21,10 +25,14 @@ import { store as coreStore } from '@wordpress/core-data'; | |||||||||||||||||||||||
import { store as editorStore } from '../../store'; | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
export default function PostLockedModal() { | ||||||||||||||||||||||||
const [ heartbeatTickTriggered, triggerHeartbeatTick ] = useState( false ); | ||||||||||||||||||||||||
const instanceId = useInstanceId( PostLockedModal ); | ||||||||||||||||||||||||
const hookName = 'core/editor/post-locked-modal-' + instanceId; | ||||||||||||||||||||||||
const { autosave, updatePostLock } = useDispatch( editorStore ); | ||||||||||||||||||||||||
const { __experimentalUpdateCurrentUserSession: updateCurrentUserSession } = | ||||||||||||||||||||||||
useDispatch( coreStore ); | ||||||||||||||||||||||||
const { | ||||||||||||||||||||||||
isDraft, | ||||||||||||||||||||||||
isLocked, | ||||||||||||||||||||||||
isTakeover, | ||||||||||||||||||||||||
user, | ||||||||||||||||||||||||
|
@@ -33,6 +41,7 @@ export default function PostLockedModal() { | |||||||||||||||||||||||
activePostLock, | ||||||||||||||||||||||||
postType, | ||||||||||||||||||||||||
previewLink, | ||||||||||||||||||||||||
isCurrentUserSessionActive, | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ℹ️ For now, this data ( |
||||||||||||||||||||||||
} = useSelect( ( select ) => { | ||||||||||||||||||||||||
const { | ||||||||||||||||||||||||
isPostLocked, | ||||||||||||||||||||||||
|
@@ -44,8 +53,12 @@ export default function PostLockedModal() { | |||||||||||||||||||||||
getEditedPostPreviewLink, | ||||||||||||||||||||||||
getEditorSettings, | ||||||||||||||||||||||||
} = select( editorStore ); | ||||||||||||||||||||||||
const { getPostType } = select( coreStore ); | ||||||||||||||||||||||||
const { getPostType, __experimentalIsCurrentUserSessionActive } = | ||||||||||||||||||||||||
select( coreStore ); | ||||||||||||||||||||||||
return { | ||||||||||||||||||||||||
isDraft: [ 'draft', 'auto-draft' ].includes( | ||||||||||||||||||||||||
getEditedPostAttribute( 'status' ) | ||||||||||||||||||||||||
), | ||||||||||||||||||||||||
isLocked: isPostLocked(), | ||||||||||||||||||||||||
isTakeover: isPostLockTakeover(), | ||||||||||||||||||||||||
user: getPostLockUser(), | ||||||||||||||||||||||||
|
@@ -54,9 +67,61 @@ export default function PostLockedModal() { | |||||||||||||||||||||||
activePostLock: getActivePostLock(), | ||||||||||||||||||||||||
postType: getPostType( getEditedPostAttribute( 'type' ) ), | ||||||||||||||||||||||||
previewLink: getEditedPostPreviewLink(), | ||||||||||||||||||||||||
isCurrentUserSessionActive: | ||||||||||||||||||||||||
__experimentalIsCurrentUserSessionActive(), | ||||||||||||||||||||||||
}; | ||||||||||||||||||||||||
}, [] ); | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
useEffect( () => { | ||||||||||||||||||||||||
const checkUserLoginHookName = `core/editor/check-user-login/post-locked-modal-${ instanceId }`; | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
/** | ||||||||||||||||||||||||
* Binds to the Heartbeat Tick event and checks if the user is logged in or not. | ||||||||||||||||||||||||
* | ||||||||||||||||||||||||
* @param {Object} data Data received in the heartbeat request | ||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||
function checkUserLogin( data ) { | ||||||||||||||||||||||||
// Bail early, if the heartbeat has neither auth check data attribute nor nonce expiry attribute. | ||||||||||||||||||||||||
if ( | ||||||||||||||||||||||||
! [ 'wp-auth-check', 'nonces_expired' ].some( ( attr ) => | ||||||||||||||||||||||||
Object.prototype.hasOwnProperty.call( data, attr ) | ||||||||||||||||||||||||
) | ||||||||||||||||||||||||
) { | ||||||||||||||||||||||||
return; | ||||||||||||||||||||||||
} | ||||||||||||||||||||||||
Comment on lines
+84
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
So I'd remove the And then simplify to:
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we used I'm not sure if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No that would require a REST endpoint. See the ither comments about that. This comment here is still relevant for admin-ajax |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
if ( ! data[ 'wp-auth-check' ] && isCurrentUserSessionActive ) { | ||||||||||||||||||||||||
// Update login state to false, | ||||||||||||||||||||||||
// if server does not respond with auth check | ||||||||||||||||||||||||
// and current login state is true. | ||||||||||||||||||||||||
updateCurrentUserSession( { isActive: false } ); | ||||||||||||||||||||||||
} else if ( ! isCurrentUserSessionActive && data.nonces_expired ) { | ||||||||||||||||||||||||
// If current login state is false and nonce is renewed, | ||||||||||||||||||||||||
// trigger a heartbeat tick to check for latest post lock. | ||||||||||||||||||||||||
// Because first heartbeat will only renew the nonce. | ||||||||||||||||||||||||
// It will not return any errors if the post is already locked by other user or not. | ||||||||||||||||||||||||
if ( window.wp?.heartbeat?.connectNow ) { | ||||||||||||||||||||||||
triggerHeartbeatTick( true ); | ||||||||||||||||||||||||
window.wp.heartbeat.connectNow(); | ||||||||||||||||||||||||
} | ||||||||||||||||||||||||
} else if ( | ||||||||||||||||||||||||
! isCurrentUserSessionActive && | ||||||||||||||||||||||||
heartbeatTickTriggered && | ||||||||||||||||||||||||
data[ 'wp-auth-check' ] | ||||||||||||||||||||||||
) { | ||||||||||||||||||||||||
// If current login state is false and server responds with auth check for previously triggered heartbeat, | ||||||||||||||||||||||||
// update login state to true. | ||||||||||||||||||||||||
updateCurrentUserSession( { isActive: true } ); | ||||||||||||||||||||||||
} | ||||||||||||||||||||||||
} | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ℹ️ This whole function (which is managing the current user session in data store) can be questionable and there could be better way to handle this. So I'm happy to take any advise of improvement here. |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
addAction( 'heartbeat.tick', checkUserLoginHookName, checkUserLogin ); | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We already subscribe to this action in the hook down here. Was it a deliberate choice to separate these two logics. The reasoning is not clear to me. |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
return () => { | ||||||||||||||||||||||||
removeAction( 'heartbeat.tick', checkUserLoginHookName ); | ||||||||||||||||||||||||
}; | ||||||||||||||||||||||||
}, [ heartbeatTickTriggered, isCurrentUserSessionActive ] ); | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
useEffect( () => { | ||||||||||||||||||||||||
/** | ||||||||||||||||||||||||
* Keep the lock refreshed. | ||||||||||||||||||||||||
|
@@ -90,7 +155,12 @@ export default function PostLockedModal() { | |||||||||||||||||||||||
const received = data[ 'wp-refresh-post-lock' ]; | ||||||||||||||||||||||||
if ( received.lock_error ) { | ||||||||||||||||||||||||
// Auto save and display the takeover modal. | ||||||||||||||||||||||||
autosave(); | ||||||||||||||||||||||||
// Meanwhile, other user may have opened the post and started to make new changes. | ||||||||||||||||||||||||
// Post may have been published or may have been switched back to draft. | ||||||||||||||||||||||||
// In either case, we don't want to accidentally loose the changes made by other user. | ||||||||||||||||||||||||
// If we perform server side autosave, and the post is in draft, previous changes will be overwritten in the database. | ||||||||||||||||||||||||
// Hence, we will only perform local autosave. | ||||||||||||||||||||||||
autosave( { local: true } ); | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ℹ️ Ideally, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't think that's true. It creates a new autosave, not overriding any of the other user's changes. The original post and the other user's autosave both still exist. So this change would break that. FWIW it's possible to write e2e tests around post locking and autosaves... Sounds like now would be a good time to do that :-) |
||||||||||||||||||||||||
updatePostLock( { | ||||||||||||||||||||||||
isLocked: true, | ||||||||||||||||||||||||
isTakeover: true, | ||||||||||||||||||||||||
|
@@ -141,7 +211,7 @@ export default function PostLockedModal() { | |||||||||||||||||||||||
removeAction( 'heartbeat.tick', hookName ); | ||||||||||||||||||||||||
window.removeEventListener( 'beforeunload', releasePostLock ); | ||||||||||||||||||||||||
}; | ||||||||||||||||||||||||
}, [] ); | ||||||||||||||||||||||||
}, [ isDraft, isLocked ] ); | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this intentional? More dependencies means the effect is triggered more often. I think an empty dependencies array was deliberately chosen here. |
||||||||||||||||||||||||
|
||||||||||||||||||||||||
if ( ! isLocked ) { | ||||||||||||||||||||||||
return null; | ||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -106,8 +106,8 @@ export default function PostPreviewButton( { | |
role, | ||
onPreview, | ||
} ) { | ||
const { postId, currentPostLink, previewLink, isSaveable, isViewable } = | ||
useSelect( ( select ) => { | ||
const { postId, currentPostLink, previewLink, isViewable } = useSelect( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why remove the
|
||
( select ) => { | ||
const editor = select( editorStore ); | ||
const core = select( coreStore ); | ||
|
||
|
@@ -119,10 +119,11 @@ export default function PostPreviewButton( { | |
postId: editor.getCurrentPostId(), | ||
currentPostLink: editor.getCurrentPostAttribute( 'link' ), | ||
previewLink: editor.getEditedPostPreviewLink(), | ||
isSaveable: editor.isEditedPostSaveable(), | ||
isViewable: postType?.viewable ?? false, | ||
}; | ||
}, [] ); | ||
}, | ||
[] | ||
); | ||
|
||
const { __unstableSaveForPreview } = useDispatch( editorStore ); | ||
|
||
|
@@ -168,7 +169,6 @@ export default function PostPreviewButton( { | |
className={ className || 'editor-post-preview' } | ||
href={ href } | ||
target={ targetId } | ||
disabled={ ! isSaveable } | ||
onClick={ openPreviewWindow } | ||
role={ role } | ||
> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -139,22 +139,6 @@ describe( 'PostPreviewButton', () => { | |
).toBeInTheDocument(); | ||
} ); | ||
|
||
it( 'should be disabled if post is not saveable.', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As per my other comment, it doesn't seem right to remove this functionality. |
||
mockUseSelect( { isEditedPostSaveable: () => false } ); | ||
|
||
render( <PostPreviewButton /> ); | ||
|
||
expect( screen.getByRole( 'button' ) ).toBeDisabled(); | ||
} ); | ||
|
||
it( 'should not be disabled if post is saveable.', () => { | ||
mockUseSelect( { isEditedPostSaveable: () => true } ); | ||
|
||
render( <PostPreviewButton /> ); | ||
|
||
expect( screen.getByRole( 'button' ) ).toBeEnabled(); | ||
} ); | ||
|
||
it( 'should set `href` to edited post preview link if specified.', () => { | ||
const url = 'https://wordpress.org'; | ||
mockUseSelect( { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -495,6 +495,10 @@ export function isEditedPostSaveable( state ) { | |
return false; | ||
} | ||
|
||
if ( isPostLocked( state ) ) { | ||
return false; | ||
} | ||
|
||
// TODO: Post should not be saveable if not dirty. Cannot be added here at | ||
// this time since posts where meta boxes are present can be saved even if | ||
// the post is not dirty. Currently this restriction is imposed at UI, but | ||
|
@@ -1031,7 +1035,7 @@ export function getPermalinkParts( state ) { | |
* @return {boolean} Is locked. | ||
*/ | ||
export function isPostLocked( state ) { | ||
return state.postLock.isLocked; | ||
return state.postLock?.isLocked ?? false; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ℹ️ This is irrelevant change to the issue. Without this certain unit tests started to fail complaining about postLock being undefined in the state. This will just make sure that it has valid fallback. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think in the test file we usually just made sure a default is set. So for consistency probably best to update those tests too. |
||
} | ||
|
||
/** | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like local state. core-data was not really mean to store things like that. So can you explain more the reasoning here and what this is about?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this state could be very easily moved to the
editor
store, to thestate.postLock
object. That object already has plenty of fields (isLocked
,isTakeover
,user
, ...), so why not add anotherisUserActive
field there?All other aspects of this user session check should be integrated into the post lock state, too. Why add another hook to
heartbeat.tick
, if we can just expand the existingreceivePostLock
function?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moving this to the
postLock
object sounds confusing. Post lock state is coming from an entirely different API response and it's easy to accidentally overridepostLock.isUserActive
(or vice-versa) in the process.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then it could be renamed to
state.heartbeat
, with subfields for post lock, auth check and possibly other things. They are all requested together, and also used together.