Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow import/export patterns as JSON files #54337

Merged
merged 10 commits into from
Sep 20, 2023
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() }>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Should we close the dropdown menu after it's pressed? I feel like that might be doing too much. I could use some accessibility feedback here 🤔 .

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm no a11y expert so I'll defer to others here. One point to note though is that the same dropdown is closed when we rename a pattern etc so it should probably be consistent one way or the other.

{ __( '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
Loading