diff --git a/packages/fields/README.md b/packages/fields/README.md index 422d25f3d68bdc..b8d02a53adb798 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -14,6 +14,10 @@ npm install @wordpress/fields --save +### deleteTemplate + +Undocumented declaration. + ### duplicatePattern Undocumented declaration. @@ -54,6 +58,10 @@ Undocumented declaration. Undocumented declaration. +### resetTemplate + +Undocumented declaration. + ### restorePost Undocumented declaration. diff --git a/packages/fields/package.json b/packages/fields/package.json index 2e417c9f4de570..0ebc8f1bcd5d04 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -33,6 +33,7 @@ ], "dependencies": { "@babel/runtime": "^7.16.0", + "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", "@wordpress/blocks": "file:../blocks", "@wordpress/components": "file:../components", diff --git a/packages/fields/src/actions/index.ts b/packages/fields/src/actions/index.ts index cf4fd6833f3fbe..b11ebbd5a1886b 100644 --- a/packages/fields/src/actions/index.ts +++ b/packages/fields/src/actions/index.ts @@ -1,3 +1,4 @@ export * from './base-post'; export * from './common'; export * from './pattern'; +export * from './template'; diff --git a/packages/fields/src/actions/pattern/delete-pattern.tsx b/packages/fields/src/actions/pattern/delete-pattern.tsx new file mode 100644 index 00000000000000..de2ff1221aeff1 --- /dev/null +++ b/packages/fields/src/actions/pattern/delete-pattern.tsx @@ -0,0 +1,167 @@ +/** + * WordPress dependencies + */ +import { trash } from '@wordpress/icons'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; +import type { Action } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { getItemTitle } from '../utils'; +import type { Pattern } from '../../types'; +import { decodeEntities } from '@wordpress/html-entities'; +import { unlock } from '../../lock-unlock'; +import type { Notice } from '../../mutation'; +import { deleteWithNotices } from '../../mutation'; + +const { PATTERN_TYPES } = unlock( patternsPrivateApis ); + +const deletePatternAction: Action< Pattern > = { + id: 'delete-pattern', + label: __( 'Delete' ), + isPrimary: true, + icon: trash, + isEligible( post ) { + return post.type === PATTERN_TYPES.user; + }, + supportsBulk: true, + hideModalHeader: true, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ isBusy, setIsBusy ] = useState( false ); + + return ( + + + { items.length > 1 + ? sprintf( + // translators: %d: number of items to delete. + _n( + 'Delete %d item?', + 'Delete %d items?', + items.length + ), + items.length + ) + : sprintf( + // translators: %s: The template or template part's titles + __( 'Delete "%s"?' ), + getItemTitle( items[ 0 ] ) + ) } + + + + + + + ); + }, +}; + +export default deletePatternAction; diff --git a/packages/fields/src/actions/template/delete-template.tsx b/packages/fields/src/actions/template/delete-template.tsx new file mode 100644 index 00000000000000..82a42636e4a210 --- /dev/null +++ b/packages/fields/src/actions/template/delete-template.tsx @@ -0,0 +1,219 @@ +/** + * WordPress dependencies + */ +import { trash } from '@wordpress/icons'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +// @ts-ignore +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; + +import type { Action } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { isTemplateRemovable, getItemTitle } from '../utils'; +import type { Template, TemplatePart } from '../../types'; +import { dispatch } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; + +export const removeTemplates = async ( + items: ( Template | TemplatePart )[] +) => { + const isResetting = items.every( ( item ) => item?.has_theme_file ); + + const promiseResult: any[] = await Promise.allSettled( + items.map( ( item ) => + dispatch( coreStore ).deleteEntityRecord( + 'postType', + item.type, + item.id, + { force: true }, + { throwOnError: true } + ) + ) + ); + + // If all the promises were fulfilled with sucess. + if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + + if ( items.length === 1 ) { + let title = ''; + if ( typeof items[ 0 ].title === 'string' ) { + title = items[ 0 ].title; + } else if ( + 'rendered' in items[ 0 ].title && + typeof items[ 0 ].title.rendered === 'string' + ) { + title = items[ 0 ].title?.rendered; + } else if ( + 'raw' in items[ 0 ].title && + typeof items[ 0 ].title?.raw === 'string' + ) { + title = items[ 0 ].title?.raw; + } + + successMessage = isResetting + ? sprintf( + /* translators: The template/part's name. */ + __( '"%s" reset.' ), + decodeEntities( title ) + ) + : sprintf( + /* translators: The template/part's name. */ + __( '"%s" deleted.' ), + decodeEntities( title ) + ); + } else { + successMessage = isResetting + ? __( 'Items reset.' ) + : __( 'Items deleted.' ); + } + + 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( ',' ) + ); + } + } + dispatch( noticesStore ).createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } +}; + +// This action is used for templates, patterns and template parts. +// Every other post type uses the similar `trashPostAction` which +// moves the post to trash. +const deleteTemplateAction: Action< Template | TemplatePart > = { + id: 'delete-post', + label: __( 'Delete' ), + isPrimary: true, + icon: trash, + isEligible( post ) { + return isTemplateRemovable( post ); + }, + supportsBulk: true, + hideModalHeader: true, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ isBusy, setIsBusy ] = useState( false ); + + return ( + + + { items.length > 1 + ? sprintf( + // translators: %d: number of items to delete. + _n( + 'Delete %d item?', + 'Delete %d items?', + items.length + ), + items.length + ) + : sprintf( + // translators: %s: The template or template part's titles + __( 'Delete "%s"?' ), + getItemTitle( items[ 0 ] ) + ) } + + + + + + + ); + }, +}; + +export default deleteTemplateAction; diff --git a/packages/fields/src/actions/template/index.ts b/packages/fields/src/actions/template/index.ts new file mode 100644 index 00000000000000..34614942acae65 --- /dev/null +++ b/packages/fields/src/actions/template/index.ts @@ -0,0 +1,2 @@ +export { default as deleteTemplate } from './delete-template'; +export { default as resetTemplate } from './reset-template'; diff --git a/packages/fields/src/actions/template/reset-template.tsx b/packages/fields/src/actions/template/reset-template.tsx new file mode 100644 index 00000000000000..2e672199cce2e5 --- /dev/null +++ b/packages/fields/src/actions/template/reset-template.tsx @@ -0,0 +1,300 @@ +/** + * WordPress dependencies + */ +import { backup } from '@wordpress/icons'; +import { dispatch, select, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useState } from '@wordpress/element'; +// @ts-ignore +import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import type { Action } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { + getItemTitle, + isTemplateOrTemplatePart, + TEMPLATE_ORIGINS, + TEMPLATE_POST_TYPE, +} from '../utils'; +import type { CoreDataError, Template, TemplatePart } from '../../types'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; + +const isTemplateRevertable = ( + templateOrTemplatePart: Template | TemplatePart +) => { + if ( ! templateOrTemplatePart ) { + return false; + } + + return ( + templateOrTemplatePart.source === TEMPLATE_ORIGINS.custom && + ( Boolean( templateOrTemplatePart?.plugin ) || + templateOrTemplatePart?.has_theme_file ) + ); +}; + +/** + * Copied - pasted from https://github.com/WordPress/gutenberg/blob/bf1462ad37d4637ebbf63270b9c244b23c69e2a8/packages/editor/src/store/private-actions.js#L233-L365 + * + * @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. + */ +const revertTemplate = async ( + template: TemplatePart | Template, + { allowUndo = true } = {} +) => { + const noticeId = 'edit-site-template-reverted'; + dispatch( noticesStore ).removeNotice( noticeId ); + if ( ! isTemplateRevertable( template ) ) { + dispatch( noticesStore ).createErrorNotice( + __( 'This template is not revertable.' ), + { + type: 'snackbar', + } + ); + return; + } + + try { + const templateEntityConfig = select( coreStore ).getEntityConfig( + 'postType', + template.type + ); + + if ( ! templateEntityConfig ) { + 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, + } ) ) as any; + if ( ! fileTemplate ) { + dispatch( noticesStore ).createErrorNotice( + __( + 'The editor has encountered an unexpected error. Please reload.' + ), + { type: 'snackbar' } + ); + return; + } + + const serializeBlocks = ( { blocks: blocksForSerialization = [] } ) => + __unstableSerializeAndClean( blocksForSerialization ); + + const edited = select( coreStore ).getEditedEntityRecord( + 'postType', + template.type, + template.id + ) as any; + + // We are fixing up the undo level here to make sure we can undo + // the revert in the header toolbar correctly. + 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 ); + + dispatch( coreStore ).editEntityRecord( + 'postType', + template.type, + fileTemplate.id, + { + content: serializeBlocks, + blocks, + source: 'theme', + } + ); + + if ( allowUndo ) { + const undoRevert = () => { + dispatch( coreStore ).editEntityRecord( + 'postType', + template.type, + edited.id, + { + content: serializeBlocks, + blocks: edited.blocks, + source: 'custom', + } + ); + }; + + dispatch( noticesStore ).createSuccessNotice( + __( 'Template reset.' ), + { + type: 'snackbar', + id: noticeId, + actions: [ + { + label: __( 'Undo' ), + onClick: undoRevert, + }, + ], + } + ); + } + } catch ( error: any ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'Template revert failed. Please reload.' ); + + dispatch( noticesStore ).createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } +}; + +const resetTemplateAction: Action< Template | TemplatePart > = { + id: 'reset-template', + label: __( 'Reset' ), + isEligible: ( item ) => { + return ( + isTemplateOrTemplatePart( item ) && + item?.source === TEMPLATE_ORIGINS.custom && + ( Boolean( item.type === 'wp_template' && item?.plugin ) || + item?.has_theme_file ) + ); + }, + icon: backup, + supportsBulk: true, + hideModalHeader: true, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ isBusy, setIsBusy ] = useState( false ); + + const { saveEditedEntityRecord } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const onConfirm = async () => { + try { + for ( const template of items ) { + await revertTemplate( template, { + allowUndo: false, + } ); + await saveEditedEntityRecord( + 'postType', + template.type, + template.id + ); + } + createSuccessNotice( + items.length > 1 + ? sprintf( + /* translators: The number of items. */ + __( '%s items reset.' ), + items.length + ) + : sprintf( + /* translators: The template/part's name. */ + __( '"%s" reset.' ), + getItemTitle( items[ 0 ] ) + ), + { + type: 'snackbar', + id: 'revert-template-action', + } + ); + } catch ( error ) { + let fallbackErrorMessage; + if ( items[ 0 ].type === TEMPLATE_POST_TYPE ) { + fallbackErrorMessage = + items.length === 1 + ? __( + 'An error occurred while reverting the template.' + ) + : __( + 'An error occurred while reverting the templates.' + ); + } else { + fallbackErrorMessage = + items.length === 1 + ? __( + 'An error occurred while reverting the template part.' + ) + : __( + 'An error occurred while reverting the template parts.' + ); + } + + const typedError = error as CoreDataError; + const errorMessage = + typedError.message && typedError.code !== 'unknown_error' + ? typedError.message + : fallbackErrorMessage; + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }; + return ( + + + { __( 'Reset to default and clear all customizations?' ) } + + + + + + + ); + }, +}; + +export default resetTemplateAction; diff --git a/packages/fields/src/module.d.ts b/packages/fields/src/module.d.ts new file mode 100644 index 00000000000000..f15f8771b3ca59 --- /dev/null +++ b/packages/fields/src/module.d.ts @@ -0,0 +1,2 @@ +declare module '@wordpress/editor'; +declare module '@wordpress/patterns'; diff --git a/packages/fields/src/mutation/index.ts b/packages/fields/src/mutation/index.ts new file mode 100644 index 00000000000000..76d35b7b691b42 --- /dev/null +++ b/packages/fields/src/mutation/index.ts @@ -0,0 +1,205 @@ +/** + * WordPress dependencies + */ +import { store as noticesStore } from '@wordpress/notices'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import type { CoreDataError, Post } from '../types'; +import { dispatch } from '@wordpress/data'; + +export type Notice< T extends Post > = { + onSuccess: { + id?: string; + type?: string; + messages: { + getOneItemMessage: ( posts: T ) => string; + getMultipleItemMessage: ( posts: T[] ) => string; + }; + }; + onError: { + id?: string; + type?: string; + messages: { + getOneItemMessage: ( errors: Set< string > ) => string; + getMultipleItemMessage: ( errors: Set< string > ) => string; + }; + }; +}; + +export const deleteWithNotices = async < T extends Post >( + posts: T[], + notice: Notice< T >, + callbacks: { + onActionPerformed?: ( posts: T[] ) => void; + onActionError?: () => void; + } +) => { + const { createSuccessNotice, createErrorNotice } = dispatch( noticesStore ); + const { deleteEntityRecord } = dispatch( coreStore ); + const promiseResult = await Promise.allSettled( + posts.map( ( post ) => { + return deleteEntityRecord( + 'postType', + post.type, + post.id, + { force: true }, + { throwOnError: true } + ); + } ) + ); + // If all the promises were fulfilled with success. + if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + if ( promiseResult.length === 1 ) { + successMessage = notice.onSuccess.messages.getOneItemMessage( + posts[ 0 ] + ); + } else { + successMessage = + notice.onSuccess.messages.getMultipleItemMessage( posts ); + } + createSuccessNotice( successMessage, { + type: notice.onSuccess.type ?? 'snackbar', + id: notice.onSuccess.id, + } ); + callbacks.onActionPerformed?.( posts ); + } else { + const errorMessages = new Set< string >(); + // If there was at lease one failure. + let errorMessage; + // If we were trying to permanently delete a single post. + if ( promiseResult.length === 1 ) { + const typedError = promiseResult[ 0 ] as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessage = typedError.reason.message; + errorMessages.add( typedError.reason.message ); + } else { + errorMessage = + notice.onError.messages.getOneItemMessage( errorMessages ); + } + // If we were trying to permanently delete multiple posts + } else { + const failedPromises = promiseResult.filter( + ( { status } ) => status === 'rejected' + ); + for ( const failedPromise of failedPromises ) { + const typedError = failedPromise as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessages.add( typedError.reason.message ); + } + } + + errorMessage = + notice.onError.messages.getMultipleItemMessage( errorMessages ); + } + createErrorNotice( errorMessage, { + type: notice.onError.type ?? 'snackbar', + id: notice.onError.id, + } ); + callbacks.onActionError?.(); + } +}; + +export const editWithNotices = async < T extends Post >( + postsWithUpdates: { + originalPost: T; + changes: Partial< T >; + }[], + notice: Notice< T >, + callbacks: { + onActionPerformed?: ( posts: T[] ) => void; + onActionError?: () => void; + } +) => { + const { createSuccessNotice, createErrorNotice } = dispatch( noticesStore ); + const { editEntityRecord, saveEditedEntityRecord } = dispatch( coreStore ); + await Promise.allSettled( + postsWithUpdates.map( ( post ) => { + return editEntityRecord( + 'postType', + post.originalPost.type, + post.originalPost.id, + { + ...post.changes, + } + ); + } ) + ); + const promiseResult = await Promise.allSettled( + postsWithUpdates.map( ( post ) => { + return saveEditedEntityRecord( + 'postType', + post.originalPost.type, + post.originalPost.id, + { + throwOnError: true, + } + ); + } ) + ); + // If all the promises were fulfilled with success. + if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + if ( promiseResult.length === 1 ) { + successMessage = notice.onSuccess.messages.getOneItemMessage( + postsWithUpdates[ 0 ].originalPost + ); + } else { + successMessage = notice.onSuccess.messages.getMultipleItemMessage( + postsWithUpdates.map( ( post ) => post.originalPost ) + ); + } + createSuccessNotice( successMessage, { + type: notice.onSuccess.type ?? 'snackbar', + id: notice.onSuccess.id, + } ); + callbacks.onActionPerformed?.( + postsWithUpdates.map( ( post ) => post.originalPost ) + ); + } else { + const errorMessages = new Set< string >(); + // If there was at lease one failure. + let errorMessage; + // If we were trying to permanently delete a single post. + if ( promiseResult.length === 1 ) { + const typedError = promiseResult[ 0 ] as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessage = typedError.reason.message; + errorMessages.add( typedError.reason.message ); + } else { + errorMessage = + notice.onError.messages.getOneItemMessage( errorMessages ); + } + // If we were trying to permanently delete multiple posts + } else { + const failedPromises = promiseResult.filter( + ( { status } ) => status === 'rejected' + ); + for ( const failedPromise of failedPromises ) { + const typedError = failedPromise as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessages.add( typedError.reason.message ); + } + } + + errorMessage = + notice.onError.messages.getMultipleItemMessage( errorMessages ); + } + createErrorNotice( errorMessage, { + type: notice.onError.type ?? 'snackbar', + id: notice.onError.id, + } ); + callbacks.onActionError?.(); + } +}; diff --git a/packages/fields/src/types.ts b/packages/fields/src/types.ts index 664c2dd417201c..a5ed9596b07dfd 100644 --- a/packages/fields/src/types.ts +++ b/packages/fields/src/types.ts @@ -54,6 +54,7 @@ export interface TemplatePart extends CommonPost { has_theme_file: boolean; id: string; area: string; + plugin?: string; } export interface Pattern extends CommonPost { diff --git a/packages/fields/src/wordpress-editor.d.ts b/packages/fields/src/wordpress-editor.d.ts deleted file mode 100644 index 915dacd5f05a9c..00000000000000 --- a/packages/fields/src/wordpress-editor.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@wordpress/editor'; diff --git a/packages/fields/tsconfig.json b/packages/fields/tsconfig.json index c55be59acf40f0..69dbd076d05747 100644 --- a/packages/fields/tsconfig.json +++ b/packages/fields/tsconfig.json @@ -7,6 +7,7 @@ "checkJs": false }, "references": [ + { "path": "../api-fetch" }, { "path": "../components" }, { "path": "../compose" }, { "path": "../data" }, @@ -24,6 +25,5 @@ { "path": "../hooks" }, { "path": "../html-entities" } ], - "include": [ "src" ], - "exclude": [ "@wordpress/editor" ] + "include": [ "src" ] }