UNPKG

@wordpress/editor

Version:
720 lines (672 loc) 18.8 kB
/** * WordPress dependencies */ import { store as coreStore } from '@wordpress/core-data'; import { __, _x, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as preferencesStore } from '@wordpress/preferences'; import { addQueryArgs } from '@wordpress/url'; import apiFetch from '@wordpress/api-fetch'; import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; import { decodeEntities } from '@wordpress/html-entities'; import { dateI18n, getSettings as getDateSettings } from '@wordpress/date'; /** * Internal dependencies */ import isTemplateRevertable from './utils/is-template-revertable'; export * from '../dataviews/store/private-actions'; /** * Returns an action object used to set which template is currently being used/edited. * * @param {string} id Template Id. * * @return {Object} Action object. */ export function setCurrentTemplateId( id ) { return { type: 'SET_CURRENT_TEMPLATE_ID', id, }; } /** * Create a block based template. * * @param {?Object} template Template to create and assign. */ export const createTemplate = ( template ) => async ( { select, dispatch, registry } ) => { const savedTemplate = await registry .dispatch( coreStore ) .saveEntityRecord( 'postType', 'wp_template', template ); registry .dispatch( coreStore ) .editEntityRecord( 'postType', select.getCurrentPostType(), select.getCurrentPostId(), { template: savedTemplate.slug, } ); registry .dispatch( noticesStore ) .createSuccessNotice( __( "Custom template created. You're in template mode now." ), { type: 'snackbar', actions: [ { label: __( 'Go back' ), onClick: () => dispatch.setRenderingMode( select.getEditorSettings() .defaultRenderingMode ), }, ], } ); return savedTemplate; }; /** * Update the provided block types to be visible. * * @param {string[]} blockNames Names of block types to show. */ export const showBlockTypes = ( blockNames ) => ( { registry } ) => { const existingBlockNames = registry .select( preferencesStore ) .get( 'core', 'hiddenBlockTypes' ) ?? []; const newBlockNames = existingBlockNames.filter( ( type ) => ! ( Array.isArray( blockNames ) ? blockNames : [ blockNames ] ).includes( type ) ); registry .dispatch( preferencesStore ) .set( 'core', 'hiddenBlockTypes', newBlockNames ); }; /** * Update the provided block types to be hidden. * * @param {string[]} blockNames Names of block types to hide. */ export const hideBlockTypes = ( blockNames ) => ( { registry } ) => { const existingBlockNames = registry .select( preferencesStore ) .get( 'core', 'hiddenBlockTypes' ) ?? []; const mergedBlockNames = new Set( [ ...existingBlockNames, ...( Array.isArray( blockNames ) ? blockNames : [ blockNames ] ), ] ); registry .dispatch( preferencesStore ) .set( 'core', 'hiddenBlockTypes', [ ...mergedBlockNames ] ); }; /** * Save entity records marked as dirty. * * @param {Object} options Options for the action. * @param {Function} [options.onSave] Callback when saving happens. * @param {object[]} [options.dirtyEntityRecords] Array of dirty entities. * @param {object[]} [options.entitiesToSkip] Array of entities to skip saving. * @param {Function} [options.close] Callback when the actions is called. It should be consolidated with `onSave`. * @param {string} [options.successNoticeContent] Optional custom success notice content. Defaults to 'Site updated.'. */ export const saveDirtyEntities = ( { onSave, dirtyEntityRecords = [], entitiesToSkip = [], close, successNoticeContent, } = {} ) => ( { registry } ) => { const PUBLISH_ON_SAVE_ENTITIES = [ { kind: 'postType', name: 'wp_navigation' }, ]; const saveNoticeId = 'site-editor-save-success'; const homeUrl = registry .select( coreStore ) .getEntityRecord( 'root', '__unstableBase' )?.home; registry.dispatch( noticesStore ).removeNotice( saveNoticeId ); const entitiesToSave = dirtyEntityRecords.filter( ( { kind, name, key, property } ) => { return ! entitiesToSkip.some( ( elt ) => elt.kind === kind && elt.name === name && elt.key === key && elt.property === property ); } ); close?.( entitiesToSave ); const siteItemsToSave = []; const pendingSavedRecords = []; entitiesToSave.forEach( ( { kind, name, key, property } ) => { if ( 'root' === kind && 'site' === name ) { siteItemsToSave.push( property ); } else { if ( PUBLISH_ON_SAVE_ENTITIES.some( ( typeToPublish ) => typeToPublish.kind === kind && typeToPublish.name === name ) ) { registry .dispatch( coreStore ) .editEntityRecord( kind, name, key, { status: 'publish', } ); } pendingSavedRecords.push( registry .dispatch( coreStore ) .saveEditedEntityRecord( kind, name, key ) ); } } ); if ( siteItemsToSave.length ) { pendingSavedRecords.push( registry .dispatch( coreStore ) .__experimentalSaveSpecifiedEntityEdits( 'root', 'site', undefined, siteItemsToSave ) ); } registry .dispatch( blockEditorStore ) .__unstableMarkLastChangeAsPersistent(); Promise.all( pendingSavedRecords ) .then( ( values ) => { return onSave ? onSave( values ) : values; } ) .then( ( values ) => { if ( values.some( ( value ) => typeof value === 'undefined' ) ) { registry .dispatch( noticesStore ) .createErrorNotice( __( 'Saving failed.' ) ); } else { registry .dispatch( noticesStore ) .createSuccessNotice( successNoticeContent || __( 'Site updated.' ), { type: 'snackbar', id: saveNoticeId, actions: [ { label: __( 'View site' ), url: homeUrl, openInNewTab: true, }, ], } ); } } ) .catch( ( error ) => registry .dispatch( noticesStore ) .createErrorNotice( `${ __( 'Saving failed.' ) } ${ error }` ) ); }; /** * Reverts a template to its original theme-provided file. * * @param {Object} template The template to revert. * @param {Object} [options] * @param {boolean} [options.allowUndo] Whether to allow the user to undo * reverting the template. Default true. */ export const revertTemplate = ( template, { allowUndo = true } = {} ) => async ( { registry } ) => { const noticeId = 'edit-site-template-reverted'; registry.dispatch( noticesStore ).removeNotice( noticeId ); if ( ! isTemplateRevertable( template ) ) { registry .dispatch( noticesStore ) .createErrorNotice( __( 'This template is not revertable.' ), { type: 'snackbar', } ); return; } try { const templateEntityConfig = registry .select( coreStore ) .getEntityConfig( 'postType', template.type ); if ( ! templateEntityConfig ) { registry .dispatch( noticesStore ) .createErrorNotice( __( 'The editor has encountered an unexpected error. Please reload.' ), { type: 'snackbar' } ); return; } const fileTemplatePath = addQueryArgs( `${ templateEntityConfig.baseURL }/${ template.id }`, { context: 'edit', source: template.origin } ); const fileTemplate = await apiFetch( { path: fileTemplatePath } ); if ( ! fileTemplate ) { registry .dispatch( noticesStore ) .createErrorNotice( __( 'The editor has encountered an unexpected error. Please reload.' ), { type: 'snackbar' } ); return; } const serializeBlocks = ( { blocks: blocksForSerialization = [], } ) => __unstableSerializeAndClean( blocksForSerialization ); const edited = registry .select( coreStore ) .getEditedEntityRecord( 'postType', template.type, template.id ); // We are fixing up the undo level here to make sure we can undo // the revert in the header toolbar correctly. registry.dispatch( coreStore ).editEntityRecord( 'postType', template.type, template.id, { content: serializeBlocks, // Required to make the `undo` behave correctly. blocks: edited.blocks, // Required to revert the blocks in the editor. source: 'custom', // required to avoid turning the editor into a dirty state }, { undoIgnore: true, // Required to merge this edit with the last undo level. } ); const blocks = parse( fileTemplate?.content?.raw ); registry .dispatch( coreStore ) .editEntityRecord( 'postType', template.type, fileTemplate.id, { content: serializeBlocks, blocks, source: 'theme', } ); if ( allowUndo ) { const undoRevert = () => { registry .dispatch( coreStore ) .editEntityRecord( 'postType', template.type, edited.id, { content: serializeBlocks, blocks: edited.blocks, source: 'custom', } ); }; registry .dispatch( noticesStore ) .createSuccessNotice( __( 'Template reset.' ), { type: 'snackbar', id: noticeId, actions: [ { label: __( 'Undo' ), onClick: undoRevert, }, ], } ); } } catch ( error ) { const errorMessage = error.message && error.code !== 'unknown_error' ? error.message : __( 'Template revert failed. Please reload.' ); registry .dispatch( noticesStore ) .createErrorNotice( errorMessage, { type: 'snackbar' } ); } }; /** * Action that removes an array of templates, template parts or patterns. * * @param {Array} items An array of template,template part or pattern objects to remove. */ export const removeTemplates = ( items ) => async ( { registry } ) => { const isResetting = items.every( ( item ) => item?.has_theme_file ); const promiseResult = await Promise.allSettled( items.map( ( item ) => { return registry .dispatch( coreStore ) .deleteEntityRecord( 'postType', item.type, item.id, { force: true }, { throwOnError: true } ); } ) ); // If all the promises were fulfilled with success. if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { let successMessage; if ( items.length === 1 ) { // Depending on how the entity was retrieved its title might be // an object or simple string. let title; if ( typeof items[ 0 ].title === 'string' ) { title = items[ 0 ].title; } else if ( typeof items[ 0 ].title?.rendered === 'string' ) { title = items[ 0 ].title?.rendered; } else if ( typeof items[ 0 ].title?.raw === 'string' ) { title = items[ 0 ].title?.raw; } successMessage = isResetting ? sprintf( /* translators: %s: The template/part's name. */ __( '"%s" reset.' ), decodeEntities( title ) ) : sprintf( /* translators: %s: The template/part's name. */ _x( '"%s" deleted.', 'template part' ), decodeEntities( title ) ); } else { successMessage = isResetting ? __( 'Items reset.' ) : __( 'Items deleted.' ); } registry .dispatch( noticesStore ) .createSuccessNotice( successMessage, { type: 'snackbar', id: 'editor-template-deleted-success', } ); } else { // If there was at lease one failure. let errorMessage; // If we were trying to delete a single template. if ( promiseResult.length === 1 ) { if ( promiseResult[ 0 ].reason?.message ) { errorMessage = promiseResult[ 0 ].reason.message; } else { errorMessage = isResetting ? __( 'An error occurred while reverting the item.' ) : __( 'An error occurred while deleting the item.' ); } // If we were trying to delete a multiple templates } else { const errorMessages = new Set(); const failedPromises = promiseResult.filter( ( { status } ) => status === 'rejected' ); for ( const failedPromise of failedPromises ) { if ( failedPromise.reason?.message ) { errorMessages.add( failedPromise.reason.message ); } } if ( errorMessages.size === 0 ) { errorMessage = __( 'An error occurred while deleting the items.' ); } else if ( errorMessages.size === 1 ) { errorMessage = isResetting ? sprintf( /* translators: %s: an error message */ __( 'An error occurred while reverting the items: %s' ), [ ...errorMessages ][ 0 ] ) : sprintf( /* translators: %s: an error message */ __( 'An error occurred while deleting the items: %s' ), [ ...errorMessages ][ 0 ] ); } else { errorMessage = isResetting ? sprintf( /* translators: %s: a list of comma separated error messages */ __( 'Some errors occurred while reverting the items: %s' ), [ ...errorMessages ].join( ',' ) ) : sprintf( /* translators: %s: a list of comma separated error messages */ __( 'Some errors occurred while deleting the items: %s' ), [ ...errorMessages ].join( ',' ) ); } } registry .dispatch( noticesStore ) .createErrorNotice( errorMessage, { type: 'snackbar' } ); } }; /** * Set the default rendering mode preference for the current post type. * * @param {string} mode The rendering mode to set as default. */ export const setDefaultRenderingMode = ( mode ) => ( { select, registry } ) => { const postType = select.getCurrentPostType(); const theme = registry .select( coreStore ) .getCurrentTheme()?.stylesheet; const renderingModes = registry .select( preferencesStore ) .get( 'core', 'renderingModes' )?.[ theme ] ?? {}; if ( renderingModes[ postType ] === mode ) { return; } const newModes = { [ theme ]: { ...renderingModes, [ postType ]: mode, }, }; registry .dispatch( preferencesStore ) .set( 'core', 'renderingModes', newModes ); }; /** * Set the current global styles navigation path. * * @param {string} path The navigation path. * @return {Object} Action object. */ export function setStylesPath( path ) { return { type: 'SET_STYLES_PATH', path, }; } /** * Set whether the stylebook is visible. * * @param {boolean} show Whether to show the stylebook. * @return {Object} Action object. */ export function setShowStylebook( show ) { return { type: 'SET_SHOW_STYLEBOOK', show, }; } /** * Reset the global styles navigation to initial state. * * @return {Object} Action object. */ export function resetStylesNavigation() { return { type: 'RESET_STYLES_NAVIGATION', }; } /** * Set the minimum height of the canvas. * * @param {number} minHeight * @return {Object} Action object. */ export function setCanvasMinHeight( minHeight ) { return { type: 'SET_CANVAS_MIN_HEIGHT', minHeight, }; } /** * Set the current revision ID for revisions preview mode. * Pass a revision ID to enter revisions mode, or null to exit. * * @param {number|null} revisionId The revision ID, or null to exit revisions mode. * @return {Object} Action object. */ export function setCurrentRevisionId( revisionId ) { return { type: 'SET_CURRENT_REVISION_ID', revisionId, }; } /** * Set whether the revision diff highlighting is shown. * * @param {boolean} showDiff Whether to show diff highlighting. * @return {Object} Action object. */ export function setShowRevisionDiff( showDiff ) { return { type: 'SET_SHOW_REVISION_DIFF', showDiff, }; } /** * Restore a revision by replacing the current content with the revision's content * and auto-saving. * * @param {number} revisionId The revision ID to restore. */ export const restoreRevision = ( revisionId ) => async ( { select, dispatch, registry } ) => { const postType = select.getCurrentPostType(); const postId = select.getCurrentPostId(); const entityConfig = registry .select( coreStore ) .getEntityConfig( 'postType', postType ); const revisionKey = entityConfig?.revisionKey || 'id'; // Use resolveSelect to ensure the revision is fetched if not yet // in the store. The _fields parameter matches the query used by // getRevisions so the result is served from cache without an // extra API call. const revision = await registry .resolveSelect( coreStore ) .getRevision( 'postType', postType, postId, revisionId, { context: 'edit', _fields: [ ...new Set( [ 'id', 'date', 'modified', 'author', 'meta', 'title.raw', 'excerpt.raw', 'content.raw', revisionKey, ] ), ].join(), } ); if ( ! revision ) { return; } // Build the edits object with all restorable fields from the revision. const edits = { blocks: undefined, content: revision.content.raw, }; if ( revision.title?.raw !== undefined ) { edits.title = revision.title.raw; } if ( revision.excerpt?.raw !== undefined ) { edits.excerpt = revision.excerpt.raw; } if ( revision.meta !== undefined ) { edits.meta = revision.meta; } // Apply edits and save. dispatch.editPost( edits ); // Exit revisions mode. dispatch.setCurrentRevisionId( null ); // Save the post to persist the restored revision. await dispatch.savePost(); // Show success notice. registry.dispatch( noticesStore ).createSuccessNotice( sprintf( /* translators: %s: Date and time of the revision. */ __( 'Restored to revision from %s.' ), dateI18n( getDateSettings().formats.datetime, // Template revisions use the template REST API format, which // exposes 'modified' instead of 'date'. revisionKey === 'wp_id' ? revision.modified : revision.date ) ), { type: 'snackbar', id: 'editor-revision-restored', } ); }; /** * Select a note by its ID, or clear the selection. * * @param {undefined|number|'new'} noteId The note ID to select, 'new' to open the new note form, or undefined to clear. * @param {Object} [options] Optional options for the selection. * @param {boolean} [options.focus] Whether to focus the selected note. Default false. * @return {Object} Action object. */ export function selectNote( noteId, options = { focus: false } ) { return { type: 'SELECT_NOTE', noteId, options, }; }