From e6bdfd5fbaf0604ea837a6463154ce88b9f15859 Mon Sep 17 00:00:00 2001 From: Luigi Teschio Date: Wed, 30 Oct 2024 11:22:32 +0100 Subject: [PATCH] QuickEdit: Add Parent field (#66527) * Data Views: Add Parent field * add comment * improve documentation * fix preview * use number type * enable sorting * move logic in the useMemo function * consolidate data.parent in one variable Co-authored-by: gigitux Co-authored-by: oandregal --- package-lock.json | 3 +- .../src/components/post-edit/index.js | 1 + .../src/components/post-fields/index.js | 3 +- packages/fields/README.md | 4 + packages/fields/package.json | 3 +- packages/fields/src/fields/index.ts | 1 + packages/fields/src/fields/parent/index.ts | 27 ++ .../fields/src/fields/parent/parent-edit.tsx | 348 ++++++++++++++++++ .../fields/src/fields/parent/parent-view.tsx | 33 ++ packages/fields/src/fields/parent/utils.ts | 18 + 10 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 packages/fields/src/fields/parent/index.ts create mode 100644 packages/fields/src/fields/parent/parent-edit.tsx create mode 100644 packages/fields/src/fields/parent/parent-view.tsx create mode 100644 packages/fields/src/fields/parent/utils.ts diff --git a/package-lock.json b/package-lock.json index c3ffa28d21f3b..99cbae41c6fac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54467,7 +54467,8 @@ "@wordpress/url": "*", "@wordpress/warning": "*", "change-case": "4.1.2", - "client-zip": "^2.4.5" + "client-zip": "^2.4.5", + "remove-accents": "^0.5.0" }, "engines": { "node": ">=18.12.0", diff --git a/packages/edit-site/src/components/post-edit/index.js b/packages/edit-site/src/components/post-edit/index.js index b9d3f60ef5da2..a2b3007757e42 100644 --- a/packages/edit-site/src/components/post-edit/index.js +++ b/packages/edit-site/src/components/post-edit/index.js @@ -66,6 +66,7 @@ function PostEditForm( { postType, postId } ) { 'author', 'date', 'slug', + 'parent', 'comment_status', ], }; diff --git a/packages/edit-site/src/components/post-fields/index.js b/packages/edit-site/src/components/post-fields/index.js index a921799aabbea..7a52b05321794 100644 --- a/packages/edit-site/src/components/post-fields/index.js +++ b/packages/edit-site/src/components/post-fields/index.js @@ -8,7 +8,7 @@ import clsx from 'clsx'; */ import { __, sprintf } from '@wordpress/i18n'; import { decodeEntities } from '@wordpress/html-entities'; -import { featuredImageField, slugField } from '@wordpress/fields'; +import { featuredImageField, slugField, parentField } from '@wordpress/fields'; import { createInterpolateElement, useMemo, @@ -321,6 +321,7 @@ function usePostFields( viewType ) { }, }, slugField, + parentField, { id: 'comment_status', label: __( 'Discussion' ), diff --git a/packages/fields/README.md b/packages/fields/README.md index 0a891f9b07420..5fd1031b50fe2 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -46,6 +46,10 @@ Undocumented declaration. Undocumented declaration. +### parentField + +This field is used to display the post parent. + ### permanentlyDeletePost Undocumented declaration. diff --git a/packages/fields/package.json b/packages/fields/package.json index 43772cb41981a..acb01dd1882eb 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -54,7 +54,8 @@ "@wordpress/url": "*", "@wordpress/warning": "*", "change-case": "4.1.2", - "client-zip": "^2.4.5" + "client-zip": "^2.4.5", + "remove-accents": "^0.5.0" }, "peerDependencies": { "react": "^18.0.0" diff --git a/packages/fields/src/fields/index.ts b/packages/fields/src/fields/index.ts index 24655a3d866cf..9f1abef0417f4 100644 --- a/packages/fields/src/fields/index.ts +++ b/packages/fields/src/fields/index.ts @@ -2,3 +2,4 @@ export { default as slugField } from './slug'; export { default as titleField } from './title'; export { default as orderField } from './order'; export { default as featuredImageField } from './featured-image'; +export { default as parentField } from './parent'; diff --git a/packages/fields/src/fields/parent/index.ts b/packages/fields/src/fields/parent/index.ts new file mode 100644 index 0000000000000..2476d071b8165 --- /dev/null +++ b/packages/fields/src/fields/parent/index.ts @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import type { Field } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import type { BasePost } from '../../types'; +import { __ } from '@wordpress/i18n'; +import { ParentEdit } from './parent-edit'; +import { ParentView } from './parent-view'; + +const parentField: Field< BasePost > = { + id: 'parent', + type: 'text', + label: __( 'Parent' ), + getValue: ( { item } ) => item.parent, + Edit: ParentEdit, + render: ParentView, + enableSorting: true, +}; + +/** + * This field is used to display the post parent. + */ +export default parentField; diff --git a/packages/fields/src/fields/parent/parent-edit.tsx b/packages/fields/src/fields/parent/parent-edit.tsx new file mode 100644 index 0000000000000..030287b8f8fc5 --- /dev/null +++ b/packages/fields/src/fields/parent/parent-edit.tsx @@ -0,0 +1,348 @@ +/** + * WordPress dependencies + */ +import { ComboboxControl, ExternalLink } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { + createInterpolateElement, + useCallback, + useMemo, + useState, +} from '@wordpress/element'; +// @ts-ignore +import { store as coreStore } from '@wordpress/core-data'; +import type { DataFormControlProps } from '@wordpress/dataviews'; + +/** + * External dependencies + */ +import removeAccents from 'remove-accents'; + +/** + * Internal dependencies + */ +import { debounce } from '@wordpress/compose'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __, sprintf } from '@wordpress/i18n'; +import type { BasePost } from '../../types'; +import { getTitleWithFallbackName } from './utils'; +import { filterURLForDisplay } from '@wordpress/url'; + +type TreeBase = { + id: number; + name: string; + [ key: string ]: any; +}; + +type TreeWithParent = TreeBase & { + parent: number; +}; + +type TreeWithoutParent = TreeBase & { + parent: null; +}; + +type Tree = TreeWithParent | TreeWithoutParent; + +function buildTermsTree( flatTerms: Tree[] ) { + const flatTermsWithParentAndChildren = flatTerms.map( ( term ) => { + return { + children: [], + ...term, + }; + } ); + + // All terms should have a `parent` because we're about to index them by it. + if ( + flatTermsWithParentAndChildren.some( + ( { parent } ) => parent === null || parent === undefined + ) + ) { + return flatTermsWithParentAndChildren as TreeWithParent[]; + } + + const termsByParent = ( + flatTermsWithParentAndChildren as TreeWithParent[] + ).reduce( + ( acc, term ) => { + const { parent } = term; + if ( ! acc[ parent ] ) { + acc[ parent ] = []; + } + acc[ parent ].push( term ); + return acc; + }, + {} as Record< string, Array< TreeWithParent > > + ); + + const fillWithChildren = ( + terms: Array< TreeWithParent > + ): Array< TreeWithParent > => { + return terms.map( ( term ) => { + const children = termsByParent[ term.id ]; + return { + ...term, + children: + children && children.length + ? fillWithChildren( children ) + : [], + }; + } ); + }; + + return fillWithChildren( termsByParent[ '0' ] || [] ); +} + +export const getItemPriority = ( name: string, searchValue: string ) => { + const normalizedName = removeAccents( name || '' ).toLowerCase(); + const normalizedSearch = removeAccents( searchValue || '' ).toLowerCase(); + if ( normalizedName === normalizedSearch ) { + return 0; + } + + if ( normalizedName.startsWith( normalizedSearch ) ) { + return normalizedName.length; + } + + return Infinity; +}; + +export function PageAttributesParent( { + data, + onChangeControl, +}: { + data: BasePost; + onChangeControl: ( newValue: number ) => void; +} ) { + const [ fieldValue, setFieldValue ] = useState< null | string >( null ); + + const pageId = data.parent; + const postId = data.id; + const postTypeSlug = data.type; + + const { parentPostTitle, pageItems, isHierarchical } = useSelect( + ( select ) => { + // @ts-expect-error getPostType is not typed + const { getEntityRecord, getEntityRecords, getPostType } = + select( coreStore ); + + const postTypeInfo = getPostType( postTypeSlug ); + + const postIsHierarchical = + postTypeInfo?.hierarchical && postTypeInfo.viewable; + + const parentPost = pageId + ? getEntityRecord< BasePost >( + 'postType', + postTypeSlug, + pageId + ) + : null; + + const query = { + per_page: 100, + exclude: postId, + parent_exclude: postId, + orderby: 'menu_order', + order: 'asc', + _fields: 'id,title,parent', + ...( fieldValue !== null && { + search: fieldValue, + } ), + }; + + return { + isHierarchical: postIsHierarchical, + parentPostTitle: parentPost + ? getTitleWithFallbackName( parentPost ) + : '', + pageItems: postIsHierarchical + ? getEntityRecords< BasePost >( + 'postType', + postTypeSlug, + query + ) + : null, + }; + }, + [ fieldValue, pageId, postId, postTypeSlug ] + ); + + /** + * This logic has been copied from https://github.com/WordPress/gutenberg/blob/0249771b519d5646171fb9fae422006c8ab773f2/packages/editor/src/components/page-attributes/parent.js#L106. + */ + const parentOptions = useMemo( () => { + const getOptionsFromTree = ( + tree: Array< Tree >, + level = 0 + ): Array< { + value: number; + label: string; + rawName: string; + } > => { + const mappedNodes = tree.map( ( treeNode ) => [ + { + value: treeNode.id, + label: + '— '.repeat( level ) + decodeEntities( treeNode.name ), + rawName: treeNode.name, + }, + ...getOptionsFromTree( treeNode.children || [], level + 1 ), + ] ); + + const sortedNodes = mappedNodes.sort( ( [ a ], [ b ] ) => { + const priorityA = getItemPriority( + a.rawName, + fieldValue ?? '' + ); + const priorityB = getItemPriority( + b.rawName, + fieldValue ?? '' + ); + return priorityA >= priorityB ? 1 : -1; + } ); + + return sortedNodes.flat(); + }; + + if ( ! pageItems ) { + return []; + } + + let tree = pageItems.map( ( item ) => ( { + id: item.id as number, + parent: item.parent ?? null, + name: getTitleWithFallbackName( item ), + } ) ); + + // Only build a hierarchical tree when not searching. + if ( ! fieldValue ) { + tree = buildTermsTree( tree ); + } + + const opts = getOptionsFromTree( tree ); + + // Ensure the current parent is in the options list. + const optsHasParent = opts.find( ( item ) => item.value === pageId ); + if ( pageId && parentPostTitle && ! optsHasParent ) { + opts.unshift( { + value: pageId, + label: parentPostTitle, + rawName: '', + } ); + } + return opts.map( ( option ) => ( { + ...option, + value: option.value.toString(), + } ) ); + }, [ pageItems, fieldValue, parentPostTitle, pageId ] ); + + if ( ! isHierarchical ) { + return null; + } + + /** + * Handle user input. + * + * @param {string} inputValue The current value of the input field. + */ + const handleKeydown = ( inputValue: string ) => { + setFieldValue( inputValue ); + }; + + /** + * Handle author selection. + * + * @param {Object} selectedPostId The selected Author. + */ + const handleChange = ( selectedPostId: string | null | undefined ) => { + if ( selectedPostId ) { + return onChangeControl( parseInt( selectedPostId, 10 ) ?? 0 ); + } + + onChangeControl( 0 ); + }; + + return ( + handleKeydown( value as string ), + 300 + ) } + onChange={ handleChange } + hideLabelFromVision + /> + ); +} + +export const ParentEdit = ( { + data, + field, + onChange, +}: DataFormControlProps< BasePost > ) => { + const { id } = field; + + const homeUrl = useSelect( ( select ) => { + // @ts-expect-error getEntityRecord is not typed with unstableBase as argument. + return select( coreStore ).getEntityRecord< { + home: string; + } >( 'root', '__unstableBase' )?.home as string; + }, [] ); + + const onChangeControl = useCallback( + ( newValue?: number ) => + onChange( { + [ id ]: newValue, + } ), + [ id, onChange ] + ); + + return ( +
+
+ { createInterpolateElement( + sprintf( + /* translators: %1$s The home URL of the WordPress installation without the scheme. */ + __( + 'Child pages inherit characteristics from their parent, such as URL structure. For instance, if "Pricing" is a child of "Services", its URL would be %1$s/services/pricing.' + ), + filterURLForDisplay( homeUrl ).replace( + /([/.])/g, + '$1' + ) + ), + { + wbr: , + } + ) } +

+ { createInterpolateElement( + __( + 'They also show up as sub-items in the default navigation menu. Learn more.' + ), + { + a: ( + + ), + } + ) } +

+ +
+
+ ); +}; diff --git a/packages/fields/src/fields/parent/parent-view.tsx b/packages/fields/src/fields/parent/parent-view.tsx new file mode 100644 index 0000000000000..f0d449db726c3 --- /dev/null +++ b/packages/fields/src/fields/parent/parent-view.tsx @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import type { BasePost } from '../../types'; +import type { DataViewRenderFieldProps } from '@wordpress/dataviews'; +import { getTitleWithFallbackName } from './utils'; +import { __ } from '@wordpress/i18n'; + +export const ParentView = ( { + item, +}: DataViewRenderFieldProps< BasePost > ) => { + const parent = useSelect( + ( select ) => { + const { getEntityRecord } = select( coreStore ); + return item?.parent + ? getEntityRecord( 'postType', item.type, item.parent ) + : null; + }, + [ item.parent, item.type ] + ); + + if ( parent ) { + return <>{ getTitleWithFallbackName( parent ) }; + } + + return <>{ __( 'None' ) }; +}; diff --git a/packages/fields/src/fields/parent/utils.ts b/packages/fields/src/fields/parent/utils.ts new file mode 100644 index 0000000000000..e69fd981305bd --- /dev/null +++ b/packages/fields/src/fields/parent/utils.ts @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { decodeEntities } from '@wordpress/html-entities'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { BasePost } from '../../types'; + +export function getTitleWithFallbackName( post: BasePost ) { + return typeof post.title === 'object' && + 'rendered' in post.title && + post.title.rendered + ? decodeEntities( post.title.rendered ) + : `#${ post?.id } (${ __( 'no title' ) })`; +}