Skip to content

Commit

Permalink
Allow import/export patterns as JSON files (#54337)
Browse files Browse the repository at this point in the history
* Allow import/export patterns as JSON files

* Add success notice

* Lazily evaluate content

* Use private selectors and actions

* Fix conflict error

* Apply suggestions from code review

Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com>

* Move import to last

* Fix importing the same file

* Fix navigating to all-patterns category

* Auto assign category if users are on that category already

---------

Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com>
  • Loading branch information
kevin940726 and aaronrobertshaw authored Sep 20, 2023
1 parent da7c28d commit 549522f
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 72 deletions.
88 changes: 83 additions & 5 deletions packages/edit-site/src/components/add-new-pattern/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,19 +20,31 @@ 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 );
const isTemplatePartsMode = useSelect( ( select ) => {
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 );
Expand Down Expand Up @@ -76,6 +92,14 @@ export default function AddNewPattern() {
} );
}

controls.push( {
icon: symbol,
onClick: () => {
patternUploadInputRef.current.click();
},
title: __( 'Import pattern from JSON' ),
} );

return (
<>
<DropdownMenu
Expand All @@ -101,6 +125,60 @@ export default function AddNewPattern() {
onError={ handleError }
/>
) }

<input
type="file"
accept=".json"
hidden
ref={ patternUploadInputRef }
onChange={ async ( event ) => {
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 = '';
}
} }
/>
</>
);
}
22 changes: 22 additions & 0 deletions packages/edit-site/src/components/page-patterns/grid-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* External dependencies
*/
import classnames from 'classnames';
import downloadjs from 'downloadjs';
import { paramCase as kebabCase } from 'change-case';

/**
* WordPress dependencies
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -276,6 +292,12 @@ function GridItem( { categoryId, item, ...props } ) {
onClose={ onClose }
label={ __( 'Duplicate' ) }
/>
{ item.type === PATTERN_TYPES.user && (
<MenuItem onClick={ () => exportAsJSON() }>
{ __( 'Export as JSON' ) }
</MenuItem>
) }

{ isCustomPattern && (
<MenuItem
isDestructive={ ! hasThemeFile }
Expand Down
59 changes: 25 additions & 34 deletions packages/patterns/src/components/create-pattern-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ToggleControl,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useState, useCallback } from '@wordpress/element';
import { useState } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';

Expand All @@ -22,52 +22,43 @@ import { PATTERN_DEFAULT_CATEGORY, PATTERN_SYNC_TYPES } from '../constants';
/**
* Internal dependencies
*/
import { store } from '../store';
import { store as patternsStore } from '../store';
import CategorySelector from './category-selector';
import { unlock } from '../lock-unlock';

export default function CreatePatternModal( {
onSuccess,
onError,
clientIds,
content,
onClose,
className = 'patterns-menu-items__convert-modal',
} ) {
const [ syncType, setSyncType ] = useState( PATTERN_SYNC_TYPES.full );
const [ categories, setCategories ] = useState( [] );
const [ title, setTitle ] = useState( '' );
const { createPattern } = useDispatch( store );
const { createPattern } = unlock( useDispatch( patternsStore ) );

const { createErrorNotice } = useDispatch( noticesStore );
const onCreate = useCallback(
async function ( patternTitle, sync ) {
try {
const newPattern = await createPattern(
patternTitle,
sync,
clientIds,
categories
);
onSuccess( {
pattern: newPattern,
categoryId: PATTERN_DEFAULT_CATEGORY,
} );
} catch ( error ) {
createErrorNotice( error.message, {
type: 'snackbar',
id: 'convert-to-pattern-error',
} );
onError();
}
},
[
createPattern,
clientIds,
onSuccess,
createErrorNotice,
onError,
categories,
]
);
async function onCreate( patternTitle, sync ) {
try {
const newPattern = await createPattern(
patternTitle,
sync,
typeof content === 'function' ? content() : content,
categories
);
onSuccess( {
pattern: newPattern,
categoryId: PATTERN_DEFAULT_CATEGORY,
} );
} catch ( error ) {
createErrorNotice( error.message, {
type: 'snackbar',
id: 'convert-to-pattern-error',
} );
onError();
}
}

const handleCategorySelection = ( selectedCategories ) => {
setCategories( selectedCategories.map( ( cat ) => cat.id ) );
Expand Down
29 changes: 26 additions & 3 deletions packages/patterns/src/components/pattern-convert-button.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand All @@ -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 ) => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -111,7 +134,7 @@ export default function PatternConvertButton( { clientIds, rootClientId } ) {
</MenuItem>
{ isModalOpen && (
<CreatePatternModal
clientIds={ clientIds }
content={ getContent }
onSuccess={ ( pattern ) => {
handleSuccess( pattern );
} }
Expand Down
9 changes: 7 additions & 2 deletions packages/patterns/src/components/patterns-manage-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions packages/patterns/src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/**
* Internal dependencies
*/
import './store';

export { store } from './store';
export * from './private-apis';
Loading

0 comments on commit 549522f

Please sign in to comment.