diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 9cd27c390b29ea..babe2b12d1c96d 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -655,6 +655,19 @@ _Related_ - hasMultiSelection in core/block-editor store. +# **hasNonPostEntityChanges** + +Returns true if there are unsaved edits for entities other than +the editor's post, and false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether there are edits or not. + # **hasSelectedBlock** _Related_ diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md index 79f3f41991c08f..f5e06a7659e038 100644 --- a/docs/designers-developers/developers/data/data-core.md +++ b/docs/designers-developers/developers/data/data-core.md @@ -153,6 +153,21 @@ _Returns_ - `?Object`: Record. +# **getEntityRecordChangesByRecord** + +Returns a map of objects with each edited +raw entity record and its corresponding edits. + +The map is keyed by entity `kind => name => key => { rawRecord, edits }`. + +_Parameters_ + +- _state_ `Object`: State tree. + +_Returns_ + +- `null`: The map of edited records with their edits. + # **getEntityRecordEdits** Returns the specified entity record's edits. diff --git a/package-lock.json b/package-lock.json index fee8be7cc525cd..e9d3e71fa5c1ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7948,6 +7948,7 @@ "@wordpress/viewport": "file:packages/viewport", "@wordpress/wordcount": "file:packages/wordcount", "classnames": "^2.2.5", + "equivalent-key-map": "^0.2.2", "lodash": "^4.17.15", "memize": "^1.0.5", "react-autosize-textarea": "^3.0.2", diff --git a/packages/core-data/README.md b/packages/core-data/README.md index a2bb3ccbe6deeb..5b5cda9580b62f 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -366,6 +366,21 @@ _Returns_ - `?Object`: Record. +# **getEntityRecordChangesByRecord** + +Returns a map of objects with each edited +raw entity record and its corresponding edits. + +The map is keyed by entity `kind => name => key => { rawRecord, edits }`. + +_Parameters_ + +- _state_ `Object`: State tree. + +_Returns_ + +- `null`: The map of edited records with their edits. + # **getEntityRecordEdits** Returns the specified entity record's edits. diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 1475ffd2562f8f..a4ea47ac8c745e 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -153,6 +153,54 @@ export function getEntityRecords( state, kind, name, query ) { return getQueriedItems( queriedState, query ); } +/** + * Returns a map of objects with each edited + * raw entity record and its corresponding edits. + * + * The map is keyed by entity `kind => name => key => { rawRecord, edits }`. + * + * @param {Object} state State tree. + * + * @return {{ [kind: string]: { [name: string]: { [key: string]: { rawRecord: Object, edits: Object } } } }} The map of edited records with their edits. + */ +export const getEntityRecordChangesByRecord = createSelector( + ( state ) => { + const { + entities: { data }, + } = state; + return Object.keys( data ).reduce( ( acc, kind ) => { + Object.keys( data[ kind ] ).forEach( ( name ) => { + const editsKeys = Object.keys( data[ kind ][ name ].edits ).filter( ( editsKey ) => + hasEditsForEntityRecord( state, kind, name, editsKey ) + ); + if ( editsKeys.length ) { + if ( ! acc[ kind ] ) { + acc[ kind ] = {}; + } + if ( ! acc[ kind ][ name ] ) { + acc[ kind ][ name ] = {}; + } + editsKeys.forEach( + ( editsKey ) => + ( acc[ kind ][ name ][ editsKey ] = { + rawRecord: getRawEntityRecord( state, kind, name, editsKey ), + edits: getEntityRecordNonTransientEdits( + state, + kind, + name, + editsKey + ), + } ) + ); + } + } ); + + return acc; + }, {} ); + }, + ( state ) => [ state.entities.data ] +); + /** * Returns the specified entity record's edits. * diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 13e1551f2f1d6e..ae75557d40f1a9 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -9,6 +9,7 @@ import deepFreeze from 'deep-freeze'; import { getEntityRecord, getEntityRecords, + getEntityRecordChangesByRecord, getEntityRecordNonTransientEdits, getEmbedPreview, isPreviewEmbedFallback, @@ -105,10 +106,63 @@ describe( 'getEntityRecords', () => { } ); } ); +describe( 'getEntityRecordChangesByRecord', () => { + it( 'should return a map of objects with each raw edited entity record and its corresponding edits', () => { + const state = deepFreeze( { + entities: { + config: [ + { + kind: 'someKind', + name: 'someName', + transientEdits: { someTransientEditProperty: true }, + }, + ], + data: { + someKind: { + someName: { + queriedData: { + items: { + someKey: { + someProperty: 'somePersistedValue', + someRawProperty: { raw: 'somePersistedRawValue' }, + }, + }, + }, + edits: { + someKey: { + someProperty: 'someEditedValue', + someRawProperty: 'someEditedRawValue', + someTransientEditProperty: 'someEditedTransientEditValue', + }, + }, + }, + }, + }, + }, + } ); + expect( getEntityRecordChangesByRecord( state ) ).toEqual( { + someKind: { + someName: { + someKey: { + rawRecord: { + someProperty: 'somePersistedValue', + someRawProperty: 'somePersistedRawValue', + }, + edits: { + someProperty: 'someEditedValue', + someRawProperty: 'someEditedRawValue', + }, + }, + }, + }, + } ); + } ); +} ); + describe( 'getEntityRecordNonTransientEdits', () => { it( 'should return an empty object when the entity does not have a loaded config.', () => { const state = deepFreeze( { - entities: { config: {}, data: {} }, + entities: { config: [], data: {} }, } ); expect( getEntityRecordNonTransientEdits( state, 'someKind', 'someName', 'someId' ) diff --git a/packages/editor/package.json b/packages/editor/package.json index b2055f150bb3e4..fecbbc95429342 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -50,6 +50,7 @@ "@wordpress/viewport": "file:../viewport", "@wordpress/wordcount": "file:../wordcount", "classnames": "^2.2.5", + "equivalent-key-map": "^0.2.2", "lodash": "^4.17.15", "memize": "^1.0.5", "react-autosize-textarea": "^3.0.2", diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js new file mode 100644 index 00000000000000..3c8d770d8d1064 --- /dev/null +++ b/packages/editor/src/components/entities-saved-states/index.js @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { startCase } from 'lodash'; +import EquivalentKeyMap from 'equivalent-key-map'; + +/** + * WordPress dependencies + */ +import { CheckboxControl, Modal, Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; + +const EntitiesSavedStatesCheckbox = ( { + id, + name, + changes: { rawRecord }, + checked, + setCheckedById, +} ) => ( + setCheckedById( id, nextChecked ) } + /> +); + +export default function EntitiesSavedStates( { + isOpen, + onRequestClose, + ignoredForSave = new EquivalentKeyMap(), +} ) { + const entityRecordChangesByRecord = useSelect( ( select ) => + select( 'core' ).getEntityRecordChangesByRecord() + ); + const { saveEditedEntityRecord } = useDispatch( 'core' ); + + const [ checkedById, _setCheckedById ] = useState( () => new EquivalentKeyMap() ); + const setCheckedById = ( id, checked ) => + _setCheckedById( ( prevCheckedById ) => { + const nextCheckedById = new EquivalentKeyMap( prevCheckedById ); + if ( checked ) { + nextCheckedById.set( id, true ); + } else { + nextCheckedById.delete( id ); + } + return nextCheckedById; + } ); + const saveCheckedEntities = () => { + checkedById.forEach( ( _checked, id ) => { + if ( ! ignoredForSave.has( id ) ) { + saveEditedEntityRecord( + ...id.filter( ( s, i ) => i !== id.length - 1 || s !== 'undefined' ) + ); + } + } ); + onRequestClose( checkedById ); + }; + return ( + isOpen && ( + onRequestClose() } + contentLabel={ __( 'Select items to save.' ) } + > + { Object.keys( entityRecordChangesByRecord ).map( ( changedKind ) => + Object.keys( entityRecordChangesByRecord[ changedKind ] ).map( + ( changedName ) => + Object.keys( + entityRecordChangesByRecord[ changedKind ][ changedName ] + ).map( ( changedKey ) => { + const id = [ changedKind, changedName, changedKey ]; + return ( + + ); + } ) + ) + ) } + + + ) + ); +} diff --git a/packages/editor/src/components/entities-saved-states/style.scss b/packages/editor/src/components/entities-saved-states/style.scss new file mode 100644 index 00000000000000..6751b3ef2673b8 --- /dev/null +++ b/packages/editor/src/components/entities-saved-states/style.scss @@ -0,0 +1,5 @@ +.editor-entities-saved-states__save-button { + display: block; + margin-left: auto; + margin-right: 0; +} diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index d5af7f4ff443bf..ce7f2e60c528d0 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -13,6 +13,7 @@ export { default as TextEditorGlobalKeyboardShortcuts } from './global-keyboard- export { default as EditorHistoryRedo } from './editor-history/redo'; export { default as EditorHistoryUndo } from './editor-history/undo'; export { default as EditorNotices } from './editor-notices'; +export { default as EntitiesSavedStates } from './entities-saved-states'; export { default as ErrorBoundary } from './error-boundary'; export { default as LocalAutosaveMonitor } from './local-autosave-monitor'; export { default as PageAttributesCheck } from './page-attributes/check'; diff --git a/packages/editor/src/components/post-publish-button/index.js b/packages/editor/src/components/post-publish-button/index.js index b378fe7ae798df..de7b3069d17265 100644 --- a/packages/editor/src/components/post-publish-button/index.js +++ b/packages/editor/src/components/post-publish-button/index.js @@ -2,6 +2,9 @@ * External dependencies */ import { noop, get } from 'lodash'; +import classnames from 'classnames'; +import memoize from 'memize'; +import EquivalentKeyMap from 'equivalent-key-map'; /** * WordPress dependencies @@ -15,11 +18,25 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ +import EntitiesSavedStates from '../entities-saved-states'; import PublishButtonLabel from './label'; + export class PostPublishButton extends Component { constructor( props ) { super( props ); this.buttonNode = createRef(); + + this.createOnClick = this.createOnClick.bind( this ); + this.closeEntitiesSavedStates = this.closeEntitiesSavedStates.bind( this ); + + this.state = { + entitiesSavedStatesCallback: false, + }; + this.createIgnoredForSave = memoize( + ( postType, postId ) => + new EquivalentKeyMap( [ [ [ 'postType', postType, String( postId ) ], true ] ] ), + { maxSize: 1 } + ); } componentDidMount() { if ( this.props.focusOnMount ) { @@ -27,6 +44,34 @@ export class PostPublishButton extends Component { } } + createOnClick( callback ) { + return ( ...args ) => { + const { hasNonPostEntityChanges } = this.props; + if ( hasNonPostEntityChanges ) { + // 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 ), + } ); + return noop; + } + + return callback( ...args ); + }; + } + + closeEntitiesSavedStates( savedById ) { + const { postType, postId } = this.props; + const { entitiesSavedStatesCallback } = this.state; + this.setState( { entitiesSavedStatesCallback: false }, () => { + if ( savedById && savedById.has( [ 'postType', postType, String( postId ) ] ) ) { + // The post entity was checked, call the held callback from `createOnClick`. + entitiesSavedStatesCallback(); + } + } ); + } + render() { const { forceIsDirty, @@ -45,7 +90,14 @@ export class PostPublishButton extends Component { onSubmit = noop, onToggle, visibility, + hasNonPostEntityChanges, + postType, + postId, } = this.props; + const { + entitiesSavedStatesCallback, + } = this.state; + const isButtonDisabled = isSaving || forceIsSaving || @@ -88,34 +140,53 @@ export class PostPublishButton extends Component { }; const buttonProps = { - 'aria-disabled': isButtonDisabled, + 'aria-disabled': isButtonDisabled && ! hasNonPostEntityChanges, className: 'editor-post-publish-button', isBusy: isSaving && isPublished, isPrimary: true, - onClick: onClickButton, + onClick: this.createOnClick( onClickButton ), }; const toggleProps = { - 'aria-disabled': isToggleDisabled, + 'aria-disabled': isToggleDisabled && ! hasNonPostEntityChanges, 'aria-expanded': isOpen, className: 'editor-post-publish-panel__toggle', isBusy: isSaving && isPublished, isPrimary: true, - onClick: onClickToggle, + onClick: this.createOnClick( onClickToggle ), }; const toggleChildren = isBeingScheduled ? __( 'Schedule…' ) : __( 'Publish…' ); - const buttonChildren = ; + const buttonChildren = ( + + ); const componentProps = isToggle ? toggleProps : buttonProps; const componentChildren = isToggle ? toggleChildren : buttonChildren; return ( - + <> + + + ); } } @@ -132,6 +203,8 @@ export default compose( [ isPostSavingLocked, getCurrentPost, getCurrentPostType, + getCurrentPostId, + hasNonPostEntityChanges, } = select( 'core/editor' ); return { isSaving: isSavingPost(), @@ -143,6 +216,8 @@ export default compose( [ isPublished: isCurrentPostPublished(), hasPublishAction: get( getCurrentPost(), [ '_links', 'wp:action-publish' ], false ), postType: getCurrentPostType(), + postId: getCurrentPostId(), + hasNonPostEntityChanges: hasNonPostEntityChanges(), }; } ), withDispatch( ( dispatch ) => { diff --git a/packages/editor/src/components/post-publish-button/label.js b/packages/editor/src/components/post-publish-button/label.js index fe9081c971c325..5373d39322558f 100644 --- a/packages/editor/src/components/post-publish-button/label.js +++ b/packages/editor/src/components/post-publish-button/label.js @@ -17,6 +17,7 @@ export function PublishButtonLabel( { isPublishing, hasPublishAction, isAutosaving, + hasNonPostEntityChanges, } ) { if ( isPublishing ) { return __( 'Publishing…' ); @@ -27,14 +28,16 @@ export function PublishButtonLabel( { } if ( ! hasPublishAction ) { - return __( 'Submit for Review' ); + return hasNonPostEntityChanges ? + __( 'Submit for Review…' ) : + __( 'Submit for Review' ); } else if ( isPublished ) { - return __( 'Update' ); + return hasNonPostEntityChanges ? __( 'Update…' ) : __( 'Update' ); } else if ( isBeingScheduled ) { - return __( 'Schedule' ); + return hasNonPostEntityChanges ? __( 'Schedule…' ) : __( 'Schedule' ); } - return __( 'Publish' ); + return hasNonPostEntityChanges ? __( 'Publish…' ) : __( 'Publish' ); } export default compose( [ diff --git a/packages/editor/src/components/post-publish-button/style.scss b/packages/editor/src/components/post-publish-button/style.scss new file mode 100644 index 00000000000000..11aed2ab58e3ed --- /dev/null +++ b/packages/editor/src/components/post-publish-button/style.scss @@ -0,0 +1,8 @@ +.editor-post-publish-button__button.has-changes-dot::before { + background: currentcolor; + border-radius: 4px; + content: ""; + height: 8px; + margin: auto 5px auto -3px; + width: 8px; +} diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 9cabf7bff4708e..d8777cb5b590a4 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -134,6 +134,65 @@ export const isEditedPostDirty = createRegistrySelector( ( select ) => ( state ) return false; } ); +/** + * Returns true if there are unsaved edits for entities other than + * the editor's post, and false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether there are edits or not. + */ +export const hasNonPostEntityChanges = createRegistrySelector( + ( select ) => ( state ) => { + const enableFullSiteEditing = getEditorSettings( state ) + .__experimentalEnableFullSiteEditing; + if ( ! enableFullSiteEditing ) { + return false; + } + + const entityRecordChangesByRecord = select( + 'core' + ).getEntityRecordChangesByRecord(); + const changedKinds = Object.keys( entityRecordChangesByRecord ); + if ( + changedKinds.length > 1 || + ( changedKinds.length === 1 && ! entityRecordChangesByRecord.postType ) + ) { + // Return true if there is more than one edited entity kind + // or the edited entity kind is not the editor's post's kind. + return true; + } else if ( ! entityRecordChangesByRecord.postType ) { + // Don't continue if there are no edited entity kinds. + return false; + } + + const { type, id } = getCurrentPost( state ); + const changedPostTypes = Object.keys( entityRecordChangesByRecord.postType ); + if ( + changedPostTypes.length > 1 || + ( changedPostTypes.length === 1 && + ! entityRecordChangesByRecord.postType[ type ] ) + ) { + // Return true if there is more than one edited post type + // or the edited entity's post type is not the editor's post's post type. + return true; + } + + const changedPosts = Object.keys( entityRecordChangesByRecord.postType[ type ] ); + if ( + changedPosts.length > 1 || + ( changedPosts.length === 1 && + ! entityRecordChangesByRecord.postType[ type ][ id ] ) + ) { + // Return true if there is more than one edited post + // or the edited post is not the editor's post. + return true; + } + + return false; + } +); + /** * Returns true if there are no unsaved values for the current edit session and * if the currently edited post is new (has never been saved before). diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index f379c7eaae47ec..a6608f28b6cd68 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -33,6 +33,13 @@ selectorNames.forEach( ( name ) => { return state.currentPost; }, + getEntityRecordChangesByRecord() { + return ( + state.getEntityRecordChangesByRecord && + state.getEntityRecordChangesByRecord() + ); + }, + getEntityRecordEdits() { const present = state.editor && state.editor.present; let edits = present && present.edits; @@ -123,6 +130,7 @@ const { isEditedPostNew, hasChangedContent, isEditedPostDirty, + hasNonPostEntityChanges, isCleanNewPost, getCurrentPost, getCurrentPostId, @@ -444,6 +452,69 @@ describe( 'selectors', () => { } ); } ); + describe( 'hasNonPostEntityChanges', () => { + it( 'should return false if the full site editing experiment is disabled.', () => { + const state = { + currentPost: { id: 1, type: 'post' }, + editorSettings: { + __experimentalEnableFullSiteEditing: false, + }, + getEntityRecordChangesByRecord() { + return { someKind: { someName: { someKey: {} } } }; + }, + }; + expect( hasNonPostEntityChanges( state ) ).toBe( false ); + } ); + it( 'should return true if there are changes to an arbitrary entity', () => { + const state = { + currentPost: { id: 1, type: 'post' }, + editorSettings: { + __experimentalEnableFullSiteEditing: true, + }, + getEntityRecordChangesByRecord() { + return { someKind: { someName: { someKey: {} } } }; + }, + }; + expect( hasNonPostEntityChanges( state ) ).toBe( true ); + } ); + it( 'should return false if there are only changes for the current post', () => { + const state = { + currentPost: { id: 1, type: 'post' }, + editorSettings: { + __experimentalEnableFullSiteEditing: true, + }, + getEntityRecordChangesByRecord() { + return { postType: { post: { 1: {} } } }; + }, + }; + expect( hasNonPostEntityChanges( state ) ).toBe( false ); + } ); + it( 'should return true if there are changes to multiple posts', () => { + const state = { + currentPost: { id: 1, type: 'post' }, + editorSettings: { + __experimentalEnableFullSiteEditing: true, + }, + getEntityRecordChangesByRecord() { + return { postType: { post: { 1: {}, 2: {} } } }; + }, + }; + expect( hasNonPostEntityChanges( state ) ).toBe( true ); + } ); + it( 'should return true if there are changes to multiple posts of different post types', () => { + const state = { + currentPost: { id: 1, type: 'post' }, + editorSettings: { + __experimentalEnableFullSiteEditing: true, + }, + getEntityRecordChangesByRecord() { + return { postType: { post: { 1: {} }, wp_template: { 1: {} } } }; + }, + }; + expect( hasNonPostEntityChanges( state ) ).toBe( true ); + } ); + } ); + describe( 'isCleanNewPost', () => { it( 'should return true when the post is not dirty and has not been saved before', () => { const state = { diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 949f751bd72af5..83a75edd9013ac 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -1,6 +1,7 @@ @import "./components/autocompleters/style.scss"; @import "./components/document-outline/style.scss"; @import "./components/editor-notices/style.scss"; +@import "./components/entities-saved-states/style.scss"; @import "./components/error-boundary/style.scss"; @import "./components/page-attributes/style.scss"; @import "./components/post-excerpt/style.scss"; @@ -9,6 +10,7 @@ @import "./components/post-last-revision/style.scss"; @import "./components/post-locked-modal/style.scss"; @import "./components/post-permalink/style.scss"; +@import "./components/post-publish-button/style.scss"; @import "./components/post-publish-panel/style.scss"; @import "./components/post-saved-state/style.scss"; @import "./components/post-taxonomies/style.scss";