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

Patterns: Add editing of pattern categories to site editor #54640

Merged
merged 3 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,37 @@ import TemplateActions from './template-actions';
import TemplateAreas from './template-areas';
import LastRevision from './last-revision';
import SidebarCard from '../sidebar-card';
import PatternCategories from './pattern-categories';

const CARD_ICONS = {
wp_block: symbol,
wp_navigation: navigation,
};

export default function TemplatePanel() {
const { title, description, icon, record } = useSelect( ( select ) => {
const { getEditedPostType, getEditedPostId } = select( editSiteStore );
const { getEditedEntityRecord } = select( coreStore );
const { __experimentalGetTemplateInfo: getTemplateInfo } =
select( editorStore );
const { title, description, icon, record, postType } = useSelect(
( select ) => {
const { getEditedPostType, getEditedPostId } =
select( editSiteStore );
const { getEditedEntityRecord } = select( coreStore );
const { __experimentalGetTemplateInfo: getTemplateInfo } =
select( editorStore );

const postType = getEditedPostType();
const postId = getEditedPostId();
const _record = getEditedEntityRecord( 'postType', postType, postId );
const info = getTemplateInfo( _record );
const type = getEditedPostType();
const postId = getEditedPostId();
const _record = getEditedEntityRecord( 'postType', type, postId );
const info = getTemplateInfo( _record );

return {
title: info.title,
description: info.description,
icon: info.icon,
record: _record,
};
}, [] );
return {
title: info.title,
description: info.description,
icon: info.icon,
record: _record,
postType: type,
};
},
[]
);

if ( ! title && ! description ) {
return null;
Expand All @@ -64,6 +70,7 @@ export default function TemplatePanel() {
>
<LastRevision />
</PanelRow>
{ postType === 'wp_block' && <PatternCategories post={ record } /> }
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we be using the recently consolidated constants here and in other places instead of 'wp_block'?

Copy link
Contributor

Choose a reason for hiding this comment

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

If desired, perhaps it could be included in a code quality follow-up as suggested for refactoring the PatternCategories component.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have switched to the constant.

</PanelBody>
);
}
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for adding the link. The copy pasta was flagged earlier with the possibility of a dedicated follow-up issue discussed.

Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/**
* WordPress dependencies
*/
import { __, _x, sprintf } from '@wordpress/i18n';
import { useEffect, useMemo, useState } from '@wordpress/element';
import { FormTokenField, PanelRow } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { useDebounce } from '@wordpress/compose';
import { store as noticesStore } from '@wordpress/notices';
import { decodeEntities } from '@wordpress/html-entities';

export const unescapeString = ( arg ) => {
return decodeEntities( arg );
};

/**
* Returns a term object with name unescaped.
*
* @param {Object} term The term object to unescape.
*
* @return {Object} Term object with name property unescaped.
*/
export const unescapeTerm = ( term ) => {
return {
...term,
name: unescapeString( term.name ),
};
};

/**
* Shared reference to an empty array for cases where it is important to avoid
* returning a new array reference on every invocation.
*
* @type {Array<any>}
*/
const EMPTY_ARRAY = [];

/**
* Module constants
*/
const MAX_TERMS_SUGGESTIONS = 20;
const DEFAULT_QUERY = {
per_page: MAX_TERMS_SUGGESTIONS,
_fields: 'id,name',
context: 'view',
};

const isSameTermName = ( termA, termB ) =>
unescapeString( termA ).toLowerCase() ===
unescapeString( termB ).toLowerCase();

const termNamesToIds = ( names, terms ) => {
return names.map(
( termName ) =>
terms.find( ( term ) => isSameTermName( term.name, termName ) ).id
);
};

export default function PatternCategories( { post } ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this involved a bit of copying from the editor package because of the different way we are handling editing posts in the site editor, it would be nice to reduce the duplication at some point but I think this would be better handled post 6.4 - I think this small amount of copy paste is ok in the meantime.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good, perhaps we should create an issue to follow up on this so it doesn't slip through the cracks.

const slug = 'wp_pattern_category';
const [ values, setValues ] = useState( [] );
const [ search, setSearch ] = useState( '' );
const debouncedSearch = useDebounce( setSearch, 500 );

const {
terms,
taxonomy,
hasAssignAction,
hasCreateAction,
hasResolvedTerms,
} = useSelect(
( select ) => {
const { getEntityRecords, getTaxonomy, hasFinishedResolution } =
select( coreStore );
const _taxonomy = getTaxonomy( slug );
const _termIds =
post?.wp_pattern_category?.length > 0
? post?.wp_pattern_category
: EMPTY_ARRAY;
const query = {
...DEFAULT_QUERY,
include: _termIds?.join( ',' ),
per_page: -1,
};

return {
hasCreateAction: _taxonomy
? post._links?.[
'wp:action-create-' + _taxonomy.rest_base
] ?? false
: false,
hasAssignAction: _taxonomy
? post._links?.[
'wp:action-assign-' + _taxonomy.rest_base
] ?? false
: false,
taxonomy: _taxonomy,
termIds: _termIds,
terms: _termIds?.length
? getEntityRecords( 'taxonomy', slug, query )
: EMPTY_ARRAY,
hasResolvedTerms: hasFinishedResolution( 'getEntityRecords', [
'taxonomy',
slug,
query,
] ),
};
},
[ slug, post ]
);

const { searchResults } = useSelect(
( select ) => {
const { getEntityRecords } = select( coreStore );

return {
searchResults: !! search
? getEntityRecords( 'taxonomy', slug, {
...DEFAULT_QUERY,
search,
} )
: EMPTY_ARRAY,
};
},
[ search, slug ]
);

// Update terms state only after the selectors are resolved.
// We're using this to avoid terms temporarily disappearing on slow networks
// while core data makes REST API requests.
useEffect( () => {
if ( hasResolvedTerms ) {
const newValues = ( terms ?? [] ).map( ( term ) =>
unescapeString( term.name )
);

setValues( newValues );
}
}, [ terms, hasResolvedTerms ] );

const suggestions = useMemo( () => {
return ( searchResults ?? [] ).map( ( term ) =>
unescapeString( term.name )
);
}, [ searchResults ] );

const { saveEntityRecord, editEntityRecord } = useDispatch( coreStore );
const { createErrorNotice } = useDispatch( noticesStore );

if ( ! hasAssignAction ) {
return null;
}

async function findOrCreateTerm( term ) {
try {
const newTerm = await saveEntityRecord( 'taxonomy', slug, term, {
throwOnError: true,
} );
return unescapeTerm( newTerm );
} catch ( error ) {
if ( error.code !== 'term_exists' ) {
throw error;
}

return {
id: error.data.term_id,
name: term.name,
};
}
}

function onUpdateTerms( newTermIds ) {
editEntityRecord( 'postType', 'wp_block', post.id, {
wp_pattern_category: newTermIds,
} );
}

function onChange( termNames ) {
const availableTerms = [
...( terms ?? [] ),
...( searchResults ?? [] ),
];
const uniqueTerms = termNames.reduce( ( acc, name ) => {
if (
! acc.some( ( n ) => n.toLowerCase() === name.toLowerCase() )
) {
acc.push( name );
}
return acc;
}, [] );

const newTermNames = uniqueTerms.filter(
( termName ) =>
! availableTerms.find( ( term ) =>
isSameTermName( term.name, termName )
)
);

// Optimistically update term values.
// The selector will always re-fetch terms later.
setValues( uniqueTerms );

if ( newTermNames.length === 0 ) {
return onUpdateTerms(
termNamesToIds( uniqueTerms, availableTerms )
);
}

if ( ! hasCreateAction ) {
return;
}

Promise.all(
newTermNames.map( ( termName ) =>
findOrCreateTerm( { name: termName } )
)
)
.then( ( newTerms ) => {
const newAvailableTerms = availableTerms.concat( newTerms );
return onUpdateTerms(
termNamesToIds( uniqueTerms, newAvailableTerms )
);
} )
.catch( ( error ) => {
createErrorNotice( error.message, {
type: 'snackbar',
} );
} );
}

const singularName =
taxonomy?.labels?.singular_name ??
( slug === 'post_tag' ? __( 'Tag' ) : __( 'Term' ) );
const termAddedLabel = sprintf(
/* translators: %s: term name. */
_x( '%s added', 'term' ),
singularName
);
const termRemovedLabel = sprintf(
/* translators: %s: term name. */
_x( '%s removed', 'term' ),
singularName
);
const removeTermLabel = sprintf(
/* translators: %s: term name. */
_x( 'Remove %s', 'term' ),
singularName
);

return (
<PanelRow initialOpen={ true } title={ __( 'Categories' ) }>
<FormTokenField
__next40pxDefaultSize
value={ values }
suggestions={ suggestions }
onChange={ onChange }
onInputChange={ debouncedSearch }
maxSuggestions={ MAX_TERMS_SUGGESTIONS }
label={ __( 'Pattern categories' ) }
messages={ {
added: termAddedLabel,
removed: termRemovedLabel,
remove: removeTermLabel,
} }
/>
</PanelRow>
);
}
Loading