diff --git a/assets/src/block-editor/AssignOnlyFlatTermSelector/index.js b/assets/src/block-editor/AssignOnlyFlatTermSelector/index.js new file mode 100644 index 0000000000..f17041f8a0 --- /dev/null +++ b/assets/src/block-editor/AssignOnlyFlatTermSelector/index.js @@ -0,0 +1,187 @@ +/** + * The logic of this component was copied from https://github.com/WordPress/gutenberg/blob/master/packages/editor/src/components/post-taxonomies/flat-term-selector.js + * initially, then the functionality of creating non-existing terms was removed from it. + */ + +/** + * WordPress dependencies + */ +import {useEffect, useMemo, useState} from '@wordpress/element'; +import {FormTokenField, withFilters} from '@wordpress/components'; +import {useSelect, useDispatch} from '@wordpress/data'; +import {store as coreStore} from '@wordpress/core-data'; +import {useDebounce} from '@wordpress/compose'; +import {decodeEntities} from '@wordpress/html-entities'; + +const {__, _x, sprintf} = wp.i18n; + +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation. + * + * @type {Array} + */ +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) => + decodeEntities(termA).toLowerCase() === + decodeEntities(termB).toLowerCase(); + +const termNamesToIds = (names, terms) => names.map( + termName => terms.find(term => isSameTermName(term.name, termName)).id +); + +export const AssignOnlyFlatTermSelector = ({slug}) => { + const [values, setValues] = useState([]); + const [search, setSearch] = useState(''); + const debouncedSearch = useDebounce(setSearch, 500); + + const { + terms, + taxonomy, + hasAssignAction, + hasResolvedTerms, + } = useSelect( + select => { + const {getCurrentPost, getEditedPostAttribute} = select('core/editor'); + const {getEntityRecords, getTaxonomy, hasFinishedResolution} = select(coreStore); + const post = getCurrentPost(); + const _taxonomy = getTaxonomy(slug); + const _termIds = _taxonomy ? getEditedPostAttribute(_taxonomy.rest_base) : EMPTY_ARRAY; + + const query = { + ...DEFAULT_QUERY, + include: _termIds.join(','), + per_page: -1, + }; + + return { + 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] + ); + + 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 => + decodeEntities(term.name) + ); + + setValues(newValues); + } + }, [terms, hasResolvedTerms]); + + const suggestions = useMemo(() => { + return (searchResults ?? []).map(term => + decodeEntities(term.name) + ); + }, [searchResults]); + + const {editPost} = useDispatch('core/editor'); + + if (!hasAssignAction) { + return null; + } + + function onUpdateTerms(newTermIds) { + editPost({[taxonomy.rest_base]: 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; + }, []); + + // Filter to remove new terms since we don't allow creation. + const allowedTerms = uniqueTerms.filter( + termName => availableTerms.find(term => isSameTermName(term.name, termName)) + ); + + setValues(allowedTerms); + + return onUpdateTerms(termNamesToIds(allowedTerms, availableTerms)); + } + + const newTermLabel = + taxonomy?.labels?.add_new_item ?? + (slug === 'post_tag' ? __('Add new tag') : __('Add new Term')); + 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 ( + + ); +}; + +export default withFilters('editor.PostTaxonomyType')(AssignOnlyFlatTermSelector); diff --git a/assets/src/block-editor/BlockFilters/ImageBlockEdit.js b/assets/src/block-editor/BlockFilters/ImageBlockEdit.js new file mode 100644 index 0000000000..7b0839be1d --- /dev/null +++ b/assets/src/block-editor/BlockFilters/ImageBlockEdit.js @@ -0,0 +1,57 @@ +/** + * Return an editable image block + * + * - display image credits in caption during edition + * + * @param {Object} BlockEdit + * @return {Object} interface to edit images on the Editor + */ +export const ImageBlockEdit = BlockEdit => props => { + if ('core/image' !== props.name) { + return ; + } + + const {attributes, clientId} = props; + const {id, className = ''} = attributes; + + // Get image data + const image = wp.data.useSelect(select => id ? select('core').getMedia(id) : null); + const credits = image?.meta?._credit_text; + const captionText = image?.caption?.raw; + // Compile data for insertion + let image_credits = null; + if (credits && credits.length > 0 && (!captionText || !captionText.includes(credits))) { + image_credits = credits.includes('©') ? credits : `© ${credits}`; + } + + const block_id = clientId ? `block-${clientId}` : null; + + // Update width and height when sized rounded styles are selected + if (className.includes('is-style-rounded-')) { + const classes = className.split(' '); + const size = classes.find(c => c.includes('is-style-rounded-')).replace('is-style-rounded-', '') || 180; + attributes.width = parseInt(size); + attributes.height = parseInt(size); + } + + // Force to use square images when the class `square-*` is added + if (className.includes('square-')) { + const size = className.slice(className.search('square-') + 'square-'.length).split(' ')[0] || 180; + attributes.width = parseInt(size); + attributes.height = parseInt(size); + } + + return ( + <> + + {block_id && image_credits && ( + captionText ? + : +
{image_credits}
+ )} + + ); +}; diff --git a/assets/src/block-editor/BlockFilters/index.js b/assets/src/block-editor/BlockFilters/index.js new file mode 100644 index 0000000000..321c91d32d --- /dev/null +++ b/assets/src/block-editor/BlockFilters/index.js @@ -0,0 +1,40 @@ +const {addFilter} = wp.hooks; + +import {ImageBlockEdit} from './ImageBlockEdit'; + +export const addBlockFilters = () => { + addFileBlockFilter(); + addImageBlockFilter(); + addGravityFormsBlockFilter(); +}; + +const addFileBlockFilter = () => { + const setDownloadButtonToggleDefualtFalse = (settings, name) => { + if ('core/file' !== name) { + return settings; + } + + settings.attributes.showDownloadButton.default = false; + + return settings; + }; + + addFilter('blocks.registerBlockType', 'planet4-master-theme/filters/file', setDownloadButtonToggleDefualtFalse); +}; + +const addImageBlockFilter = () => addFilter('editor.BlockEdit', 'core/image/edit', ImageBlockEdit); + +// Enforce "AJAX" toggle setting enabled by default, on Gravity form block. +const addGravityFormsBlockFilter = () => { + const setAJAXToggleDefaultTrue = (settings, name) => { + if ('gravityforms/form' !== name) { + return settings; + } + + settings.attributes.ajax.default = true; + + return settings; + }; + + addFilter('blocks.registerBlockType', 'planet4-master-theme/filters/file', setAJAXToggleDefaultTrue); +}; diff --git a/assets/src/block-editor/QueryLoopBlockExtension/index.js b/assets/src/block-editor/QueryLoopBlockExtension/index.js index adc5ac53d8..314b07d83b 100644 --- a/assets/src/block-editor/QueryLoopBlockExtension/index.js +++ b/assets/src/block-editor/QueryLoopBlockExtension/index.js @@ -15,7 +15,7 @@ export const setupQueryLoopBlockExtension = () => { addFilter( 'editor.BlockEdit', - 'planet4-blocks/overrides/query-loop-layout', + 'planet4-master-theme/overrides/query-loop-layout', createHigherOrderComponent( BlockEdit => props => { const {attributes, setAttributes} = props; diff --git a/assets/src/block-editor/TermSelector/index.js b/assets/src/block-editor/TermSelector/index.js new file mode 100644 index 0000000000..b4187d150c --- /dev/null +++ b/assets/src/block-editor/TermSelector/index.js @@ -0,0 +1,369 @@ +// This component is based on the one used by WordPress for categories (without the hierarchical aspect, since we don't need it): +// https://github.com/WordPress/gutenberg/blob/trunk/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js + +/** + * WordPress dependencies + */ +import {useMemo, useState} from '@wordpress/element'; +import { + Button, + CheckboxControl, + TextControl, + withFilters, +} from '@wordpress/components'; +import {useDispatch, useSelect} from '@wordpress/data'; +import {useDebounce} from '@wordpress/compose'; +import {store as coreStore} from '@wordpress/core-data'; +import {decodeEntities} from '@wordpress/html-entities'; + +const {__, _n, _x, sprintf} = wp.i18n; +const {speak} = wp.a11y; + +/** + * Module Constants + */ +const DEFAULT_QUERY = { + per_page: -1, + orderby: 'name', + order: 'asc', + _fields: 'id,name', + context: 'view', +}; + +const MIN_TERMS_COUNT_FOR_FILTER = 8; + +const EMPTY_ARRAY = []; + +/** + * Sort Terms by Selected. + * + * @param {Object[]} termsTree Array of terms in tree format. + * @param {number[]} terms Selected terms. + * + * @return {Object[]} Sorted array of terms. + */ +export function sortBySelected(termsTree, terms) { + const treeHasSelection = termTree => { + return terms.indexOf(termTree.id) !== -1; + }; + + const termIsSelected = (termA, termB) => { + const termASelected = treeHasSelection(termA); + const termBSelected = treeHasSelection(termB); + + if (termASelected === termBSelected) { + return 0; + } + + if (termASelected && !termBSelected) { + return -1; + } + + if (!termASelected && termBSelected) { + return 1; + } + + return 0; + }; + const newTermTree = [...termsTree]; + newTermTree.sort(termIsSelected); + return newTermTree; +} + +/** + * Find term by name. + * + * @param {Object[]} terms Array of Terms. + * @param {string} name Term name. + * + * @return {Object} Term object. + */ +export function findTerm(terms, name) { + return terms.find(terms, term => term.name.toLowerCase() === name.toLowerCase()); +} + +/** + * Get filter matcher function. + * + * @param {string} filterValue Filter value. + * @return {(function(Object): (Object|boolean))} Matcher function. + */ +export function getFilterMatcher(filterValue) { + const matchTermsForFilter = originalTerm => { + if ('' === filterValue) { + return originalTerm; + } + + // If the term's name contains the filterValue then return it. + if (-1 !== originalTerm.name.toLowerCase().indexOf(filterValue.toLowerCase())) { + return originalTerm; + } + + // Otherwise, return false. After mapping, the list of terms will need + // to have false values filtered out. + return false; + }; + return matchTermsForFilter; +} + +/** + * Term selector. + * + * @param {Object} props Component props. + * @param {string} props.slug Taxonomy slug. + * @return {WPElement} Term selector component. + */ +export function TermSelector({slug}) { + const [adding, setAdding] = useState(false); + const [formName, setFormName] = useState(''); + /** + * @type {*} + */ + const [showForm, setShowForm] = useState(false); + const [filterValue, setFilterValue] = useState(''); + const [filteredTermsTree, setFilteredTermsTree] = useState([]); + const debouncedSpeak = useDebounce(speak, 500); + + const { + hasCreateAction, + hasAssignAction, + terms, + loading, + availableTerms, + taxonomy, + isUserAdmin, + } = useSelect( + select => { + const {getCurrentPost, getEditedPostAttribute} = select('core/editor'); + const {getTaxonomy, getEntityRecords, isResolving, canUser} = select(coreStore); + const _taxonomy = getTaxonomy(slug); + const post = getCurrentPost(); + + return { + isUserAdmin: canUser('create', 'users') ?? false, + hasCreateAction: _taxonomy ? + post._links?.[ + 'wp:action-create-' + _taxonomy.rest_base + ] ?? false : + false, + hasAssignAction: _taxonomy ? + post._links?.[ + 'wp:action-assign-' + _taxonomy.rest_base + ] ?? false : + false, + terms: _taxonomy ? + getEditedPostAttribute(_taxonomy.rest_base) : + EMPTY_ARRAY, + loading: isResolving('getEntityRecords', [ + 'taxonomy', + slug, + DEFAULT_QUERY, + ]), + availableTerms: + getEntityRecords('taxonomy', slug, DEFAULT_QUERY) || + EMPTY_ARRAY, + taxonomy: _taxonomy, + }; + }, + [slug] + ); + + const {saveEntityRecord} = useDispatch(coreStore); + const {editPost} = useDispatch('core/editor'); + + const availableTermsTree = useMemo( + () => sortBySelected(availableTerms, terms), + // Remove `terms` from the dependency list to avoid reordering every time + // checking or unchecking a term. + [availableTerms] + ); + + if (!hasAssignAction) { + return null; + } + + /** + * Append new term. + * + * @param {Object} term Term object. + * @return {Promise} A promise that resolves to save term object. + */ + const addTerm = term => { + return saveEntityRecord('taxonomy', slug, term); + }; + + /** + * Update terms for post. + * + * @param {number[]} termIds Term ids. + */ + const onUpdateTerms = termIds => { + editPost({[taxonomy.rest_base]: termIds}); + }; + + /** + * Handler for checking term. + * + * @param {number} termId + */ + const onChange = termId => { + const hasTerm = terms.includes(termId); + const newTerms = hasTerm ? + terms.filter(id => id !== termId) : + [...terms, termId]; + onUpdateTerms(newTerms); + }; + + const onChangeFormName = value => { + setFormName(value); + }; + + const onToggleForm = () => { + setShowForm(!showForm); + }; + + const onAddTerm = async event => { + event.preventDefault(); + if (formName === '' || adding) { + return; + } + + // Check if the term we are adding already exists. + const existingTerm = findTerm(availableTerms, formName); + if (existingTerm) { + // If the term we are adding exists but is not selected select it. + if (!terms.some(term => term === existingTerm.id)) { + onUpdateTerms([...terms, existingTerm.id]); + } + + setFormName(''); + + return; + } + setAdding(true); + + const newTerm = await addTerm({ + name: formName, + }); + + const defaultName = slug === 'category' ? __('Category') : __('Term'); + const termAddedMessage = sprintf( + /* translators: %s: taxonomy name */ + _x('%s added', 'term'), + taxonomy?.labels?.singular_name ?? defaultName + ); + speak(termAddedMessage, 'assertive'); + setAdding(false); + setFormName(''); + onUpdateTerms([...terms, newTerm.id]); + }; + + const setFilter = value => { + const newFilteredTermsTree = availableTermsTree + .map(getFilterMatcher(value)) + .filter(term => term); + + setFilterValue(value); + setFilteredTermsTree(newFilteredTermsTree); + + const resultCount = newFilteredTermsTree.length; + const resultsFoundMessage = sprintf( + /* translators: %d: number of results */ + _n('%d result found.', '%d results found.', resultCount), + resultCount + ); + + debouncedSpeak(resultsFoundMessage, 'assertive'); + }; + + const renderTerms = renderedTerms => renderedTerms.map(term => ( +
+ { + const termId = parseInt(term.id, 10); + onChange(termId); + }} + label={decodeEntities(term.name)} + /> +
+ )); + + const labelWithFallback = ( + labelProperty, + fallbackIsTag, + fallbackIsNotTag + ) => + taxonomy?.labels?.[labelProperty] ?? + (slug === 'post_tag' ? fallbackIsTag : fallbackIsNotTag); + const newTermButtonLabel = labelWithFallback( + 'add_new_item', + __('Add new tag'), + __('Add new term') + ); + const newTermLabel = labelWithFallback( + 'new_item_name', + __('Add new tag'), + __('Add new term') + ); + const newTermSubmitLabel = newTermButtonLabel; + const filterLabel = taxonomy?.labels?.search_items ?? __('Search Terms'); + const groupLabel = taxonomy?.name ?? __('Terms'); + const showFilter = availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER; + + return ( + <> + { showFilter && ( + + ) } +
+ { renderTerms( + '' !== filterValue ? filteredTermsTree : availableTermsTree + ) } +
+ {/* Only admins should be allowed to create new tags */} + { !loading && hasCreateAction && (isUserAdmin || slug !== 'post_tag') && ( + + ) } + {!isUserAdmin && slug === 'post_tag' &&

{__('New tags can only be created by an administrator', 'planet4-blocks-backend')}

} + { showForm && ( +
+ + + + ) } + + ); +} + +export default withFilters('editor.PostTaxonomyType')(TermSelector); diff --git a/assets/src/block-editor/addButtonLinkPasteWarning.js b/assets/src/block-editor/addButtonLinkPasteWarning.js new file mode 100644 index 0000000000..fe8a114dfd --- /dev/null +++ b/assets/src/block-editor/addButtonLinkPasteWarning.js @@ -0,0 +1,18 @@ +export const addButtonLinkPasteWarning = () => document.addEventListener('DOMContentLoaded', () => { + document.onpaste = event => { + const {target} = event; + if (!target.matches('.wp-block-button__link, .wp-block-button__link *')) { + return; + } + + const aTags = target.querySelectorAll('a'); + if (aTags.length) { + const {__} = wp.i18n; + // eslint-disable-next-line no-alert + alert(__( + 'You are pasting a link into the button text. Please ensure your clipboard only has text in it. Alternatively you can press control/command + SHIFT + V to paste only the text in your clipboard.', + 'planet4-blocks-backend' + )); + } + }; +}); diff --git a/assets/src/block-editor/replaceTaxonomyTermSelectors.js b/assets/src/block-editor/replaceTaxonomyTermSelectors.js new file mode 100644 index 0000000000..6be2757a68 --- /dev/null +++ b/assets/src/block-editor/replaceTaxonomyTermSelectors.js @@ -0,0 +1,25 @@ +import {AssignOnlyFlatTermSelector} from './AssignOnlyFlatTermSelector'; +import {TermSelector} from './TermSelector'; + +const customizeTaxonomySelectors = OriginalComponent => props => { + // For following taxonomies it should not be possible to create new terms on the post edit page + const isCustomComponent = ['p4-page-type', 'post_tag'].includes(props.slug); + + let component = OriginalComponent; + if (isCustomComponent) { + component = props.slug === 'post_tag' ? TermSelector : AssignOnlyFlatTermSelector; + } + + return wp.element.createElement( + component, + props + ); +}; + +export const replaceTaxonomyTermSelectors = () => { + wp.hooks.addFilter( + 'editor.PostTaxonomyType', + 'planet4-master-theme', + customizeTaxonomySelectors + ); +}; diff --git a/assets/src/editorIndex.js b/assets/src/editorIndex.js index 728bc7a7c7..cb2ce482cd 100644 --- a/assets/src/editorIndex.js +++ b/assets/src/editorIndex.js @@ -12,6 +12,9 @@ import {registerColumnsBlock} from './blocks/Columns/ColumnsBlock'; import {registerBlockStyles} from './block-styles'; import {registerBlockVariations} from './block-variations'; import {registerActionButtonTextBlock} from './blocks/ActionCustomButtonText'; +import {addButtonLinkPasteWarning} from './block-editor/addButtonLinkPasteWarning'; +import {addBlockFilters} from './block-editor/BlockFilters'; +import {replaceTaxonomyTermSelectors} from './block-editor/replaceTaxonomyTermSelectors'; wp.domReady(() => { // Blocks @@ -37,10 +40,11 @@ wp.domReady(() => { // Block variations registerBlockVariations(); -}); - -setupCustomSidebar(); + setupQueryLoopBlockExtension(); -// Setup new attributes to the core/query. -// It should be executed after the DOM is ready -setupQueryLoopBlockExtension(); + // Editor behaviour. + setupCustomSidebar(); + addButtonLinkPasteWarning(); + addBlockFilters(); + replaceTaxonomyTermSelectors(); +}); diff --git a/assets/src/scss/blocks.scss b/assets/src/scss/blocks.scss index 404dd70073..c813c161c9 100644 --- a/assets/src/scss/blocks.scss +++ b/assets/src/scss/blocks.scss @@ -29,6 +29,7 @@ @import "blocks/core-overrides/Columns"; @import "blocks/core-overrides/Heading"; @import "blocks/core-overrides/Embed"; +@import "blocks/core-overrides/File"; // Other @import "blocks/WideBlocks"; diff --git a/assets/src/scss/blocks/core-overrides/File.scss b/assets/src/scss/blocks/core-overrides/File.scss new file mode 100644 index 0000000000..165644cfc5 --- /dev/null +++ b/assets/src/scss/blocks/core-overrides/File.scss @@ -0,0 +1,6 @@ +.wp-block-file .wp-block-file__button { + overflow: unset; + padding: 0 1em; + margin-inline-start: 1em; + font-size: .8em; +}