diff --git a/packages/edit-site/src/components/add-new-pattern/index.js b/packages/edit-site/src/components/add-new-pattern/index.js index 5e0f1626fc8fd..e4b22031b1582 100644 --- a/packages/edit-site/src/components/add-new-pattern/index.js +++ b/packages/edit-site/src/components/add-new-pattern/index.js @@ -2,12 +2,16 @@ * WordPress dependencies */ import { DropdownMenu } from '@wordpress/components'; -import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { useState, useRef } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; import { plus, symbol, symbolFilled } from '@wordpress/icons'; -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { privateApis as routerPrivateApis } from '@wordpress/router'; -import { privateApis as editPatternsPrivateApis } from '@wordpress/patterns'; +import { + privateApis as editPatternsPrivateApis, + store as patternsStore, +} from '@wordpress/patterns'; +import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies @@ -16,12 +20,19 @@ import CreateTemplatePartModal from '../create-template-part-modal'; import SidebarButton from '../sidebar-button'; import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; +import { + PATTERN_TYPES, + PATTERN_DEFAULT_CATEGORY, + TEMPLATE_PART_POST_TYPE, +} from '../../utils/constants'; +import usePatternCategories from '../sidebar-navigation-screen-patterns/use-pattern-categories'; -const { useHistory } = unlock( routerPrivateApis ); +const { useHistory, useLocation } = unlock( routerPrivateApis ); const { CreatePatternModal } = unlock( editPatternsPrivateApis ); export default function AddNewPattern() { const history = useHistory(); + const { params } = useLocation(); const [ showPatternModal, setShowPatternModal ] = useState( false ); const [ showTemplatePartModal, setShowTemplatePartModal ] = useState( false ); @@ -29,6 +40,11 @@ export default function AddNewPattern() { const settings = select( editSiteStore ).getSettings(); return !! settings.supportsTemplatePartsMode; }, [] ); + const { createPatternFromFile } = unlock( useDispatch( patternsStore ) ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const patternUploadInputRef = useRef(); + const { patternCategories } = usePatternCategories(); function handleCreatePattern( { pattern, categoryId } ) { setShowPatternModal( false ); @@ -76,6 +92,14 @@ export default function AddNewPattern() { } ); } + controls.push( { + icon: symbol, + onClick: () => { + patternUploadInputRef.current.click(); + }, + title: __( 'Import pattern from JSON' ), + } ); + return ( <> ) } + + { + const file = event.target.files?.[ 0 ]; + if ( ! file ) return; + try { + const currentCategoryId = + params.categoryType !== TEMPLATE_PART_POST_TYPE && + patternCategories.find( + ( category ) => + category.name === params.categoryId + )?.id; + const pattern = await createPatternFromFile( + file, + currentCategoryId + ? [ currentCategoryId ] + : undefined + ); + + // Navigate to the All patterns category for the newly created pattern + // if we're not on that page already. + if ( ! currentCategoryId ) { + history.push( { + path: `/patterns`, + categoryType: PATTERN_TYPES.theme, + categoryId: PATTERN_DEFAULT_CATEGORY, + } ); + } + + createSuccessNotice( + sprintf( + // translators: %s: The imported pattern's title. + __( 'Imported "%s" from JSON.' ), + pattern.title.raw + ), + { + type: 'snackbar', + id: 'import-pattern-success', + } + ); + } catch ( err ) { + createErrorNotice( err.message, { + type: 'snackbar', + id: 'import-pattern-error', + } ); + } finally { + event.target.value = ''; + } + } } + /> ); } diff --git a/packages/edit-site/src/components/page-patterns/grid-item.js b/packages/edit-site/src/components/page-patterns/grid-item.js index a9c6fdc2d9d1a..f1da1f925229d 100644 --- a/packages/edit-site/src/components/page-patterns/grid-item.js +++ b/packages/edit-site/src/components/page-patterns/grid-item.js @@ -2,6 +2,8 @@ * External dependencies */ import classnames from 'classnames'; +import downloadjs from 'downloadjs'; +import { paramCase as kebabCase } from 'change-case'; /** * WordPress dependencies @@ -108,6 +110,20 @@ function GridItem( { categoryId, item, ...props } ) { }; const deleteItem = () => isTemplatePart ? removeTemplate( item ) : deletePattern(); + const exportAsJSON = () => { + const json = { + __file: item.type, + title: item.title, + content: item.patternBlock.content.raw, + syncStatus: item.patternBlock.wp_pattern_sync_status, + }; + + return downloadjs( + JSON.stringify( json, null, 2 ), + `${ kebabCase( item.title ) }.json`, + 'application/json' + ); + }; // Only custom patterns or custom template parts can be renamed or deleted. const isCustomPattern = @@ -276,6 +292,12 @@ function GridItem( { categoryId, item, ...props } ) { onClose={ onClose } label={ __( 'Duplicate' ) } /> + { item.type === PATTERN_TYPES.user && ( + exportAsJSON() }> + { __( 'Export as JSON' ) } + + ) } + { isCustomPattern && ( { setCategories( selectedCategories.map( ( cat ) => cat.id ) ); diff --git a/packages/patterns/src/components/pattern-convert-button.js b/packages/patterns/src/components/pattern-convert-button.js index b2164877a18fc..8434009133871 100644 --- a/packages/patterns/src/components/pattern-convert-button.js +++ b/packages/patterns/src/components/pattern-convert-button.js @@ -1,9 +1,14 @@ /** * WordPress dependencies */ -import { hasBlockSupport, isReusableBlock } from '@wordpress/blocks'; +import { + hasBlockSupport, + isReusableBlock, + createBlock, + serialize, +} from '@wordpress/blocks'; import { store as blockEditorStore } from '@wordpress/block-editor'; -import { useState } from '@wordpress/element'; +import { useState, useCallback } from '@wordpress/element'; import { MenuItem } from '@wordpress/components'; import { symbol } from '@wordpress/icons'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -13,7 +18,9 @@ import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ +import { store as patternsStore } from '../store'; import CreatePatternModal from './create-pattern-modal'; +import { unlock } from '../lock-unlock'; /** * Menu control to convert block(s) to a pattern block. @@ -25,6 +32,10 @@ import CreatePatternModal from './create-pattern-modal'; */ export default function PatternConvertButton( { clientIds, rootClientId } ) { const { createSuccessNotice } = useDispatch( noticesStore ); + const { replaceBlocks } = useDispatch( blockEditorStore ); + // Ignore reason: false positive of the lint rule. + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const { setEditingPattern } = unlock( useDispatch( patternsStore ) ); const [ isModalOpen, setIsModalOpen ] = useState( false ); const canConvert = useSelect( ( select ) => { @@ -74,12 +85,24 @@ export default function PatternConvertButton( { clientIds, rootClientId } ) { }, [ clientIds, rootClientId ] ); + const { getBlocksByClientId } = useSelect( blockEditorStore ); + const getContent = useCallback( + () => serialize( getBlocksByClientId( clientIds ) ), + [ getBlocksByClientId, clientIds ] + ); if ( ! canConvert ) { return null; } const handleSuccess = ( { pattern } ) => { + const newBlock = createBlock( 'core/block', { + ref: pattern.id, + } ); + + replaceBlocks( clientIds, newBlock ); + setEditingPattern( newBlock.clientId, true ); + createSuccessNotice( pattern.wp_pattern_sync_status === 'unsynced' ? sprintf( @@ -111,7 +134,7 @@ export default function PatternConvertButton( { clientIds, rootClientId } ) { { isModalOpen && ( { handleSuccess( pattern ); } } diff --git a/packages/patterns/src/components/patterns-manage-button.js b/packages/patterns/src/components/patterns-manage-button.js index eae307f1838de..5f407682bea0f 100644 --- a/packages/patterns/src/components/patterns-manage-button.js +++ b/packages/patterns/src/components/patterns-manage-button.js @@ -12,7 +12,8 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import { store as editorStore } from '../store'; +import { store as patternsStore } from '../store'; +import { unlock } from '../lock-unlock'; function PatternsManageButton( { clientId } ) { const { canRemove, isVisible, innerBlockCount, managePatternsUrl } = @@ -51,7 +52,11 @@ function PatternsManageButton( { clientId } ) { [ clientId ] ); - const { convertSyncedPatternToStatic } = useDispatch( editorStore ); + // Ignore reason: false positive of the lint rule. + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const { convertSyncedPatternToStatic } = unlock( + useDispatch( patternsStore ) + ); if ( ! isVisible ) { return null; diff --git a/packages/patterns/src/index.js b/packages/patterns/src/index.js index ed74eba99ffae..38cbd8e95737d 100644 --- a/packages/patterns/src/index.js +++ b/packages/patterns/src/index.js @@ -1,6 +1,5 @@ /** * Internal dependencies */ -import './store'; - +export { store } from './store'; export * from './private-apis'; diff --git a/packages/patterns/src/store/actions.js b/packages/patterns/src/store/actions.js index 589dad326d3b6..2861e4ce2dac0 100644 --- a/packages/patterns/src/store/actions.js +++ b/packages/patterns/src/store/actions.js @@ -2,7 +2,7 @@ * WordPress dependencies */ -import { parse, serialize, createBlock } from '@wordpress/blocks'; +import { parse } from '@wordpress/blocks'; import { store as coreStore } from '@wordpress/core-data'; import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -14,14 +14,14 @@ import { PATTERN_SYNC_TYPES } from '../constants'; /** * Returns a generator converting one or more static blocks into a pattern, or creating a new empty pattern. * - * @param {string} title Pattern title. - * @param {'full'|'unsynced'} syncType They way block is synced, 'full' or 'unsynced'. - * @param {string[]|undefined} clientIds Optional client IDs of blocks to convert to pattern. - * @param {number[]|undefined} categories Ids of any selected categories. + * @param {string} title Pattern title. + * @param {'full'|'unsynced'} syncType They way block is synced, 'full' or 'unsynced'. + * @param {string|undefined} [content] Optional serialized content of blocks to convert to pattern. + * @param {number[]|undefined} [categories] Ids of any selected categories. */ export const createPattern = - ( title, syncType, clientIds, categories ) => - async ( { registry, dispatch } ) => { + ( title, syncType, content, categories ) => + async ( { registry } ) => { const meta = syncType === PATTERN_SYNC_TYPES.unsynced ? { @@ -31,13 +31,7 @@ export const createPattern = const reusableBlock = { title, - content: clientIds - ? serialize( - registry - .select( blockEditorStore ) - .getBlocksByClientId( clientIds ) - ) - : undefined, + content, status: 'publish', meta, wp_pattern_category: categories, @@ -47,18 +41,45 @@ export const createPattern = .dispatch( coreStore ) .saveEntityRecord( 'postType', 'wp_block', reusableBlock ); - if ( syncType === 'unsynced' || ! clientIds ) { - return updatedRecord; + return updatedRecord; + }; + +/** + * Create a pattern from a JSON file. + * @param {File} file The JSON file instance of the pattern. + * @param {number[]|undefined} [categories] Ids of any selected categories. + */ +export const createPatternFromFile = + ( file, categories ) => + async ( { dispatch } ) => { + const fileContent = await file.text(); + /** @type {import('./types').PatternJSON} */ + let parsedContent; + try { + parsedContent = JSON.parse( fileContent ); + } catch ( e ) { + throw new Error( 'Invalid JSON file' ); + } + if ( + parsedContent.__file !== 'wp_block' || + ! parsedContent.title || + ! parsedContent.content || + typeof parsedContent.title !== 'string' || + typeof parsedContent.content !== 'string' || + ( parsedContent.syncStatus && + typeof parsedContent.syncStatus !== 'string' ) + ) { + throw new Error( 'Invalid Pattern JSON file' ); } - const newBlock = createBlock( 'core/block', { - ref: updatedRecord.id, - } ); - registry - .dispatch( blockEditorStore ) - .replaceBlocks( clientIds, newBlock ); - dispatch.setEditingPattern( newBlock.clientId, true ); - return updatedRecord; + const pattern = await dispatch.createPattern( + parsedContent.title, + parsedContent.syncStatus, + parsedContent.content, + categories + ); + + return pattern; }; /** diff --git a/packages/patterns/src/store/index.js b/packages/patterns/src/store/index.js index 6293a7b33408e..af3da7f0ff498 100644 --- a/packages/patterns/src/store/index.js +++ b/packages/patterns/src/store/index.js @@ -10,6 +10,7 @@ import reducer from './reducer'; import * as actions from './actions'; import { STORE_NAME } from './constants'; import * as selectors from './selectors'; +import { unlock } from '../lock-unlock'; /** * Post editor data store configuration. @@ -20,8 +21,6 @@ import * as selectors from './selectors'; */ export const storeConfig = { reducer, - selectors, - actions, }; /** @@ -36,3 +35,5 @@ export const store = createReduxStore( STORE_NAME, { } ); register( store ); +unlock( store ).registerPrivateActions( actions ); +unlock( store ).registerPrivateSelectors( selectors );