diff --git a/packages/editor/src/components/post-publish-button/index.js b/packages/editor/src/components/post-publish-button/index.js index 0de16662485ccb..64569e7a71162c 100644 --- a/packages/editor/src/components/post-publish-button/index.js +++ b/packages/editor/src/components/post-publish-button/index.js @@ -2,9 +2,7 @@ * WordPress dependencies */ import { Button } from '@wordpress/components'; -import { Component } from '@wordpress/element'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { compose } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -14,220 +12,173 @@ import { store as editorStore } from '../../store'; const noop = () => {}; -export class PostPublishButton extends Component { - constructor( props ) { - super( props ); +export function PostPublishButton( { + forceIsDirty, + isOpen, + isToggle, + onSubmit = noop, + onToggle, + setEntitiesSavedStatesCallback, +} ) { + const { + hasPublishAction, + isBeingScheduled, + isPostSavingLocked, + isPublishable, + isPublished, + isSaveable, + isSaving, + isAutoSaving, + visibility, + hasNonPostEntityChanges, + isSavingNonPostEntityChanges, + postStatus, + postStatusHasChanged, + postType, + postId, + } = useSelect( ( select ) => { + const store = select( editorStore ); + return { + isSaving: store.isSavingPost(), + isAutoSaving: store.isAutosavingPost(), + isBeingScheduled: store.isEditedPostBeingScheduled(), + visibility: store.getEditedPostVisibility(), + isSaveable: store.isEditedPostSaveable(), + isPostSavingLocked: store.isPostSavingLocked(), + isPublishable: store.isEditedPostPublishable(), + isPublished: store.isCurrentPostPublished(), + hasPublishAction: + store.getCurrentPost()._links?.[ 'wp:action-publish' ] ?? false, + postType: store.getCurrentPostType(), + postId: store.getCurrentPostId(), + postStatus: store.getEditedPostAttribute( 'status' ), + postStatusHasChanged: store.getPostEdits()?.status, + hasNonPostEntityChanges: store.hasNonPostEntityChanges(), + isSavingNonPostEntityChanges: store.isSavingNonPostEntityChanges(), + }; + }, [] ); - this.createOnClick = this.createOnClick.bind( this ); - this.closeEntitiesSavedStates = - this.closeEntitiesSavedStates.bind( this ); + const { editPost, savePost } = useDispatch( editorStore ); - this.state = { - entitiesSavedStatesCallback: false, - }; - } + const savePostStatus = ( status ) => { + editPost( { status }, { undoIgnore: true } ); + savePost(); + }; - createOnClick( callback ) { - return ( ...args ) => { - const { hasNonPostEntityChanges, setEntitiesSavedStatesCallback } = - this.props; + const createOnClick = + ( callback ) => + ( ...args ) => { // If a post with non-post entities is published, but the user // elects to not save changes to the non-post entities, those // entities will still be dirty when the Publish button is clicked. // We also need to check that the `setEntitiesSavedStatesCallback` // prop was passed. See https://github.com/WordPress/gutenberg/pull/37383 if ( hasNonPostEntityChanges && setEntitiesSavedStatesCallback ) { - // The modal for multiple entity saving will open, - // hold the callback for saving/publishing the post - // so that we can call it if the post entity is checked. - this.setState( { - entitiesSavedStatesCallback: () => callback( ...args ), - } ); + // The modal for multiple entity saving will open. If the post + // entity is checked when it closes, run the held callback. + const onClose = ( savedEntities ) => { + if ( + savedEntities && + savedEntities.some( + ( elt ) => + elt.kind === 'postType' && + elt.name === postType && + elt.key === postId + ) + ) { + callback( ...args ); + } + }; // Open the save panel by setting its callback. // To set a function on the useState hook, we must set it // with another function (() => myFunction). Passing the // function on its own will cause an error when called. - setEntitiesSavedStatesCallback( - () => this.closeEntitiesSavedStates - ); + setEntitiesSavedStatesCallback( () => onClose ); return noop; } return callback( ...args ); }; - } - closeEntitiesSavedStates( savedEntities ) { - const { postType, postId } = this.props; - const { entitiesSavedStatesCallback } = this.state; - this.setState( { entitiesSavedStatesCallback: false }, () => { - if ( - savedEntities && - savedEntities.some( - ( elt ) => - elt.kind === 'postType' && - elt.name === postType && - elt.key === postId - ) - ) { - // The post entity was checked, call the held callback from `createOnClick`. - entitiesSavedStatesCallback(); - } - } ); + const isButtonDisabled = + isPostSavingLocked || + ( ( isSaving || + ! isSaveable || + ( ! isPublishable && ! forceIsDirty ) ) && + ( ! hasNonPostEntityChanges || isSavingNonPostEntityChanges ) ); + + const isToggleDisabled = + isPostSavingLocked || + ( ( isPublished || + isSaving || + ! isSaveable || + ( ! isPublishable && ! forceIsDirty ) ) && + ( ! hasNonPostEntityChanges || isSavingNonPostEntityChanges ) ); + + // If the new status has not changed explicitly, we derive it from + // other factors, like having a publish action, etc.. We need to preserve + // this because it affects when to show the pre and post publish panels. + // If it has changed though explicitly, we need to respect that. + let publishStatus = 'publish'; + if ( postStatusHasChanged ) { + publishStatus = postStatus; + } else if ( ! hasPublishAction ) { + publishStatus = 'pending'; + } else if ( visibility === 'private' ) { + publishStatus = 'private'; + } else if ( isBeingScheduled ) { + publishStatus = 'future'; } - render() { - const { - forceIsDirty, - hasPublishAction, - isBeingScheduled, - isOpen, - isPostSavingLocked, - isPublishable, - isPublished, - isSaveable, - isSaving, - isAutoSaving, - isToggle, - savePostStatus, - onSubmit = noop, - onToggle, - visibility, - hasNonPostEntityChanges, - isSavingNonPostEntityChanges, - postStatus, - postStatusHasChanged, - } = this.props; - - const isButtonDisabled = - isPostSavingLocked || - ( ( isSaving || - ! isSaveable || - ( ! isPublishable && ! forceIsDirty ) ) && - ( ! hasNonPostEntityChanges || isSavingNonPostEntityChanges ) ); - - const isToggleDisabled = - isPostSavingLocked || - ( ( isPublished || - isSaving || - ! isSaveable || - ( ! isPublishable && ! forceIsDirty ) ) && - ( ! hasNonPostEntityChanges || isSavingNonPostEntityChanges ) ); - - // If the new status has not changed explicitly, we derive it from - // other factors, like having a publish action, etc.. We need to preserve - // this because it affects when to show the pre and post publish panels. - // If it has changed though explicitly, we need to respect that. - let publishStatus = 'publish'; - if ( postStatusHasChanged ) { - publishStatus = postStatus; - } else if ( ! hasPublishAction ) { - publishStatus = 'pending'; - } else if ( visibility === 'private' ) { - publishStatus = 'private'; - } else if ( isBeingScheduled ) { - publishStatus = 'future'; + const onClickButton = () => { + if ( isButtonDisabled ) { + return; } - - const onClickButton = () => { - if ( isButtonDisabled ) { - return; - } - onSubmit(); - savePostStatus( publishStatus ); - }; - - // Callback to open the publish panel. - const onClickToggle = () => { - if ( isToggleDisabled ) { - return; - } - onToggle(); - }; - - const buttonProps = { - 'aria-disabled': isButtonDisabled, - className: 'editor-post-publish-button', - isBusy: ! isAutoSaving && isSaving, - variant: 'primary', - onClick: this.createOnClick( onClickButton ), - 'aria-haspopup': hasNonPostEntityChanges ? 'dialog' : undefined, - }; - - const toggleProps = { - 'aria-disabled': isToggleDisabled, - 'aria-expanded': isOpen, - className: 'editor-post-publish-panel__toggle', - isBusy: isSaving && isPublished, - variant: 'primary', - size: 'compact', - onClick: this.createOnClick( onClickToggle ), - 'aria-haspopup': hasNonPostEntityChanges ? 'dialog' : undefined, - }; - const componentProps = isToggle ? toggleProps : buttonProps; - return ( - <> - - - ); - } + onSubmit(); + savePostStatus( publishStatus ); + }; + + // Callback to open the publish panel. + const onClickToggle = () => { + if ( isToggleDisabled ) { + return; + } + onToggle(); + }; + + const buttonProps = { + 'aria-disabled': isButtonDisabled, + className: 'editor-post-publish-button', + isBusy: ! isAutoSaving && isSaving, + variant: 'primary', + onClick: createOnClick( onClickButton ), + 'aria-haspopup': hasNonPostEntityChanges ? 'dialog' : undefined, + }; + + const toggleProps = { + 'aria-disabled': isToggleDisabled, + 'aria-expanded': isOpen, + className: 'editor-post-publish-panel__toggle', + isBusy: isSaving && isPublished, + variant: 'primary', + size: 'compact', + onClick: createOnClick( onClickToggle ), + 'aria-haspopup': hasNonPostEntityChanges ? 'dialog' : undefined, + }; + const componentProps = isToggle ? toggleProps : buttonProps; + return ( + + ); } /** * Renders the publish button. */ -export default compose( [ - withSelect( ( select ) => { - const { - isSavingPost, - isAutosavingPost, - isEditedPostBeingScheduled, - getEditedPostVisibility, - isCurrentPostPublished, - isEditedPostSaveable, - isEditedPostPublishable, - isPostSavingLocked, - getCurrentPost, - getCurrentPostType, - getCurrentPostId, - hasNonPostEntityChanges, - isSavingNonPostEntityChanges, - getEditedPostAttribute, - getPostEdits, - } = select( editorStore ); - return { - isSaving: isSavingPost(), - isAutoSaving: isAutosavingPost(), - isBeingScheduled: isEditedPostBeingScheduled(), - visibility: getEditedPostVisibility(), - isSaveable: isEditedPostSaveable(), - isPostSavingLocked: isPostSavingLocked(), - isPublishable: isEditedPostPublishable(), - isPublished: isCurrentPostPublished(), - hasPublishAction: - getCurrentPost()._links?.[ 'wp:action-publish' ] ?? false, - postType: getCurrentPostType(), - postId: getCurrentPostId(), - postStatus: getEditedPostAttribute( 'status' ), - postStatusHasChanged: getPostEdits()?.status, - hasNonPostEntityChanges: hasNonPostEntityChanges(), - isSavingNonPostEntityChanges: isSavingNonPostEntityChanges(), - }; - } ), - withDispatch( ( dispatch ) => { - const { editPost, savePost } = dispatch( editorStore ); - return { - savePostStatus: ( status ) => { - editPost( { status }, { undoIgnore: true } ); - savePost(); - }, - }; - } ), -] )( PostPublishButton ); +export default PostPublishButton; diff --git a/packages/editor/src/components/post-publish-button/test/index.js b/packages/editor/src/components/post-publish-button/test/index.js index e7a0465950cb85..2da07b8bace926 100644 --- a/packages/editor/src/components/post-publish-button/test/index.js +++ b/packages/editor/src/components/post-publish-button/test/index.js @@ -4,186 +4,209 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { dispatch, select } from '@wordpress/data'; + /** * Internal dependencies */ -import { PostPublishButton } from '../'; +import PostPublishButton from '../'; +import { store as editorStore } from '../../../store'; describe( 'PostPublishButton', () => { + beforeEach( () => { + jest.spyOn( select( editorStore ), 'getCurrentPost' ).mockReturnValue( { + _links: {}, + } ); + jest.spyOn( dispatch( editorStore ), 'editPost' ).mockReturnValue(); + jest.spyOn( dispatch( editorStore ), 'savePost' ).mockReturnValue(); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + function mockSelector( name, value ) { + jest.spyOn( select( editorStore ), name ).mockReturnValue( value ); + } + + function mockHasPublishAction( hasPublishAction ) { + jest.spyOn( select( editorStore ), 'getCurrentPost' ).mockReturnValue( { + _links: hasPublishAction ? { 'wp:action-publish': true } : {}, + } ); + } + describe( 'aria-disabled', () => { it( 'should be true if post is currently saving', () => { - render( ); + mockSelector( 'isEditedPostPublishable', true ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isSavingPost', true ); + + render( ); - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( screen.getByRole( 'button' ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); } ); it( 'should be true if post is not publishable and not forceIsDirty', () => { - render( - - ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isEditedPostPublishable', false ); + + render( ); - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( screen.getByRole( 'button' ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); } ); it( 'should be true if post is not saveable', () => { - render( ); + mockSelector( 'isEditedPostPublishable', true ); + mockSelector( 'isEditedPostSaveable', false ); + + render( ); - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( screen.getByRole( 'button' ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); } ); it( 'should be true if post saving is locked', () => { - render( - - ); + mockSelector( 'isEditedPostPublishable', true ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isPostSavingLocked', true ); + + render( ); - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( screen.getByRole( 'button' ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); } ); it( 'should be false if post is saveable but not publishable and forceIsDirty is true', () => { - render( - - ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isEditedPostPublishable', false ); + + render( ); - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveAttribute( 'aria-disabled', 'false' ); + expect( screen.getByRole( 'button' ) ).toHaveAttribute( + 'aria-disabled', + 'false' + ); } ); it( 'should be false if post is publishave and saveable', () => { - render( ); + mockSelector( 'isEditedPostPublishable', true ); + mockSelector( 'isEditedPostSaveable', true ); - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveAttribute( 'aria-disabled', 'false' ); + render( ); + + expect( screen.getByRole( 'button' ) ).toHaveAttribute( + 'aria-disabled', + 'false' + ); } ); } ); describe( 'publish status', () => { it( 'should be pending for contributor', async () => { const user = userEvent.setup(); - const savePostStatus = jest.fn(); - render( - - ); + mockHasPublishAction( false ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isEditedPostPublishable', true ); - await user.click( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ); + render( ); + + await user.click( screen.getByRole( 'button' ) ); - expect( savePostStatus ).toHaveBeenCalledWith( 'pending' ); + expect( dispatch( editorStore ).editPost ).toHaveBeenCalledWith( + { status: 'pending' }, + { undoIgnore: true } + ); } ); it( 'should be future for scheduled post', async () => { const user = userEvent.setup(); - const savePostStatus = jest.fn(); - render( - - ); + mockHasPublishAction( true ); + mockSelector( 'isEditedPostBeingScheduled', true ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isEditedPostPublishable', true ); - await user.click( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ); + render( ); + + await user.click( screen.getByRole( 'button' ) ); - expect( savePostStatus ).toHaveBeenCalledWith( 'future' ); + expect( dispatch( editorStore ).editPost ).toHaveBeenCalledWith( + { status: 'future' }, + { undoIgnore: true } + ); } ); it( 'should be private for private visibility', async () => { const user = userEvent.setup(); - const savePostStatus = jest.fn(); - render( - - ); + mockHasPublishAction( true ); + mockSelector( 'getEditedPostVisibility', 'private' ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isEditedPostPublishable', true ); - await user.click( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ); + render( ); - expect( savePostStatus ).toHaveBeenCalledWith( 'private' ); + await user.click( screen.getByRole( 'button' ) ); + + expect( dispatch( editorStore ).editPost ).toHaveBeenCalledWith( + { status: 'private' }, + { undoIgnore: true } + ); } ); it( 'should be publish otherwise', async () => { const user = userEvent.setup(); - const savePostStatus = jest.fn(); - render( - - ); + mockHasPublishAction( true ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isEditedPostPublishable', true ); - await user.click( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ); + render( ); + + await user.click( screen.getByRole( 'button' ) ); - expect( savePostStatus ).toHaveBeenCalledWith( 'publish' ); + expect( dispatch( editorStore ).editPost ).toHaveBeenCalledWith( + { status: 'publish' }, + { undoIgnore: true } + ); } ); } ); describe( 'click', () => { it( 'should save with status', async () => { const user = userEvent.setup(); - const savePostStatus = jest.fn(); - render( - - ); + mockHasPublishAction( true ); + mockSelector( 'isEditedPostSaveable', true ); + mockSelector( 'isEditedPostPublishable', true ); - await user.click( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ); + render( ); + + await user.click( screen.getByRole( 'button' ) ); - expect( savePostStatus ).toHaveBeenCalledWith( 'publish' ); + expect( dispatch( editorStore ).editPost ).toHaveBeenCalledWith( + { status: 'publish' }, + { undoIgnore: true } + ); + expect( dispatch( editorStore ).savePost ).toHaveBeenCalled(); } ); } ); it( 'should have save modifier class', () => { - render( ); + mockSelector( 'isSavingPost', true ); + mockSelector( 'isCurrentPostPublished', true ); + + render( ); - expect( - screen.getByRole( 'button', { name: 'Submit for Review' } ) - ).toHaveClass( 'is-busy' ); + expect( screen.getByRole( 'button' ) ).toHaveClass( 'is-busy' ); } ); } );