Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion build/dlx-pw-categories-view.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-a11y', 'wp-api-fetch', 'wp-blob', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-escape-html', 'wp-i18n', 'wp-url'), 'version' => '31beeb53ad90ae5cb2d2');
<?php return array('dependencies' => array('react', 'react-dom', 'wp-a11y', 'wp-api-fetch', 'wp-blob', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-escape-html', 'wp-i18n', 'wp-url'), 'version' => 'df5a2d007e68fd89f734');
2 changes: 1 addition & 1 deletion build/dlx-pw-categories-view.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/dlx-pw-patterns-view.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-a11y', 'wp-api-fetch', 'wp-blob', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-date', 'wp-element', 'wp-escape-html', 'wp-i18n', 'wp-primitives', 'wp-private-apis', 'wp-url', 'wp-warning'), 'version' => '7c462d7029c15b12caa0');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-a11y', 'wp-api-fetch', 'wp-blob', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-date', 'wp-element', 'wp-escape-html', 'wp-i18n', 'wp-primitives', 'wp-private-apis', 'wp-url', 'wp-warning'), 'version' => '3c7f9bfaf7c367224e12');
2 changes: 1 addition & 1 deletion build/dlx-pw-patterns-view.js

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions php/Rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,21 @@ public function rest_api_register() {
)
);

/**
* For assigning a category to a pattern.
*/
register_rest_route(
'dlxplugins/pattern-wrangler/v1',
'/patterns/tag',
array(
'methods' => 'POST',
'callback' => array( $this, 'rest_tag_pattern' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);

/**
* For retrieving a pattern by ID. Useful when importing patterns via json.
*/
Expand Down Expand Up @@ -677,6 +692,84 @@ public function rest_publish_pattern( $request ) {
return rest_ensure_response( array( 'success' => true ) );
}

/**
* Assign a category to a pattern.
*
* @param WP_REST_Request $request The REST request.
*
* @return WP_REST_Response The REST response.
*/
public function rest_tag_pattern( $request ) {

$items = $request->get_param( 'items' );
$pattern_categories = $request->get_param( 'patternCategories' );

// Set categories.
$terms_to_add = array();
foreach ( $pattern_categories as $category ) {
if ( is_numeric( $category['id'] ) && 0 !== $category['id'] ) {
$terms_to_add[] = absint( $category['id'] );
} else {
$terms_to_add[] = sanitize_text_field( $category['name'] );
}
}

$terms_affected = array();
$items_affected = array();
foreach ( $items as $item ) {
$pattern_id = $item['id'];
$nonce = $item['nonce'];

if ( ! wp_verify_nonce( $nonce, 'dlx-pw-patterns-view-edit-pattern-' . $pattern_id ) ) {
return rest_ensure_response( array( 'error' => 'Invalid nonce for pattern ' . $pattern_id ) );
}

if ( is_numeric( $pattern_id ) && 0 !== $pattern_id ) {
if ( ! current_user_can( 'edit_post', $pattern_id ) ) {
return rest_ensure_response( array( 'error' => 'User does not have permission to publish pattern ' . $pattern_id ) );
}

// Clear post terms.
wp_delete_object_term_relationships( $pattern_id, 'wp_pattern_category' );

$items_affected[] = array(
'patternId' => $pattern_id,
'patternTitle' => sanitize_text_field( get_the_title( $pattern_id ) ),
);

// Add terms.
$terms_affected_ids = wp_set_post_terms( $pattern_id, $terms_to_add, 'wp_pattern_category' );

// Get terms from IDs.
foreach ( $terms_affected_ids as $term_id ) {
$category_term = get_term( $term_id, 'wp_pattern_category' );
if ( $category_term ) {
// Decode HTML entities to prevent double encoding in React.
$category_name = wp_specialchars_decode( $category_term->name, ENT_QUOTES );
$terms_affected[ sanitize_title( $category_term->slug ) ] = array(
'label' => sanitize_text_field( $category_name ),
'customLabel' => sanitize_text_field( $category_name ),
'slug' => sanitize_title( $category_term->slug ),
'enabled' => true,
'count' => absint( $category_term->count ),
'mappedTo' => false,
'registered' => false,
'id' => absint( $category_term->term_id ),
);
}
}
}
}

// Return the new categories.
return rest_ensure_response(
array(
'newCategories' => $terms_affected,
'itemsAffected' => $items_affected,
)
);
}

/**
* Create a pattern.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const CategoryCard = ( props ) => {
props.onEditCategory( category );
} }
>
{ __( 'Quick Edit', 'pattern-wrangler' ) }
{ __( 'Edit Category', 'pattern-wrangler' ) }
</Button>
)
}
Expand All @@ -123,7 +123,7 @@ const CategoryCard = ( props ) => {
props.onEditRegisteredCategory( category );
} }
>
{ __( 'Quick Edit', 'pattern-wrangler' ) }
{ __( 'Edit Label', 'pattern-wrangler' ) }
</Button>
)
}
Expand Down
225 changes: 225 additions & 0 deletions src/js/react/views/patterns/components/PatternTagModal/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// eslint-disable-next-line no-unused-vars
import React, { Suspense, useState, useEffect, useMemo } from 'react';
import {
ToggleControl,
TextControl,
Modal,
Button,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
FormTokenField,
} from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { AlertTriangle } from 'lucide-react';
import { escapeHTML } from '@wordpress/escape-html';

import { __, _n } from '@wordpress/i18n';
import { useForm, Controller, useWatch, useFormState } from 'react-hook-form';
import { cleanForSlug } from '@wordpress/url';

// Local imports.
import Notice from '../../../../components/Notice';

/**
* Pattern Create Modal.
*
* @param {Object} props The props.
* @param {string} props.title The title of the modal.
* @param {string} props.patternId The id of the pattern.
* @param {string} props.patternNonce The nonce of the pattern.
* @param {string} props.patternTitle The title of the pattern.
* @param {Array} props.patternCategories The categories of the pattern in label arrays.
* @param {string} props.patternSyncStatus The sync status of the pattern.
* @param {string} props.patternCopyId The id of the pattern to copy.
* @param {Object} props.categories The categories of all the patterns..
* @param {Function} props.onRequestClose The function to call when the modal is closed.
* @param {string} props.syncedDefaultStatus The default sync status of the pattern. Values are 'synced' or 'unsynced'.
* @param {boolean} props.syncedDisabled Whether the synced status is disabled.
* @param {Function} props.onEdit The function to call when the pattern is edited.
* @return {Object} The rendered component.
*/
const PatternTagModal = ( props ) => {
const originalCategories = props.categories || [];
const categories = ( props.categories || [] ).map( ( category ) => {
return category.label || category.name;
} );
const localIntersectedCategories = useMemo( () => {
const items = props.items || [];
if ( items.length === 0 ) {
return [];
}

// Get categories from all items.
const allItemCategories = items.map( ( item ) => item.categories || [] );

// If no items or no categories, return empty array.
if ( allItemCategories.length === 0 || allItemCategories[ 0 ].length === 0 ) {
return [];
}

// Start with categories from the first item.
let commonCategories = allItemCategories[ 0 ];

// For each subsequent item, filter to only keep categories that exist in that item too.
for ( let i = 1; i < allItemCategories.length; i++ ) {
const currentItemCategories = allItemCategories[ i ];
commonCategories = commonCategories.filter( ( category ) => {
return currentItemCategories.some( ( currentCat ) => currentCat === category );
} );
}
// Filter to only include categories that exist in originalCategories, and return labels.
return commonCategories
.filter( ( category ) =>
originalCategories.some( ( originalCategory ) => {
return originalCategory.label === category;
} )
);
}, [ props.items, originalCategories ] );

const [ isSaving, setIsSaving ] = useState( false );

const {
control,
handleSubmit,
} = useForm( {
defaultValues: {
items: props.items || [],
patternCategories: localIntersectedCategories || [],
},
} );
const formValues = useWatch( { control } );
const { errors } = useFormState( {
control,
} );

/**
* Get the label id by value.
*
* @param {string} labelValue The label value.
*
* @return {string|null} The label id.
*/
const getIdByValue = ( labelValue ) => {
const label = originalCategories.find(
( findLabel ) => {
const findNewLabel = findLabel.label || findLabel.name;
return findNewLabel.toLowerCase() === labelValue.toLowerCase();
}
);
return label ? label.id : 0;
};

const onSubmit = async( formData ) => {
setIsSaving( true );

const newCategories = formData.patternCategories.map( ( category ) => {
return {
name: category,
id: getIdByValue( category ),
};
} );

const itemIdsAndNonces = formData.items.map( ( item ) => {
return {
id: item.id,
nonce: item.editNonce,
};
} );
const path = '/dlxplugins/pattern-wrangler/v1/patterns/tag/';

const response = await apiFetch( {
path,
method: 'POST',
data: {
items: itemIdsAndNonces,
patternCategories: newCategories,
},
} );
const responseNewCategories = response.newCategories || {};
const affectedSlugs = Object.values( responseNewCategories ).map( ( category ) => category.slug );
props.onTag( response, itemIdsAndNonces, response.itemsAffected, responseNewCategories, affectedSlugs );
setIsSaving( false );
};

/**
* Get the button text.
*
* @return {string} The button text.
*/
const getButtonText = () => {
let buttonText = _n( 'Assign Category to Pattern', 'Assign Categories to Pattern', props.items.length, 'pattern-wrangler' );
if ( isSaving ) {
buttonText = _n( 'Saving Category…', 'Saving Categories…', props.items.length, 'pattern-wrangler' );
}
return buttonText;
};

return (
<>
<Modal
title={ _n( 'Assign Category to Pattern', 'Assign Categories to Pattern', props.items.length, 'pattern-wrangler' ) }
onRequestClose={ props.onRequestClose }
focusOnMount="firstContentElement"
>
<div className="dlx-pw-modal-content">
<form onSubmit={ handleSubmit( onSubmit ) }>
<div className="dlx-pw-modal-admin-row">
<Controller
control={ control }
name="patternCategories[]"
render={ ( { field } ) => (
<>
<FormTokenField
label={ __( 'Categories', 'pattern-wrangler' ) }
help={ __(
'Enter the categories to assign to the pattern.',
'pattern-wrangler'
) }
value={ field.value }
onChange={ ( tokens ) => {
field.onChange( tokens );
} }
tokenizeOnSpace={ false }
allowMultiple={ true }
placeholder={ __( 'Add a category', 'pattern-wrangler' ) }
suggestions={ categories }
disabled={ isSaving }
__experimentalShowHowTo={ false }
/>
<p className="description">
{ __( 'Separate with commas or press the Enter key.', 'pattern-wrangler' ) }
</p>
</>
) }
/>
</div>
<div className="dlx-pw-modal-admin-row dlx-pw-modal-admin-row-buttons">
<Button variant="primary" type="submit" disabled={ isSaving }>
{ getButtonText() }
</Button>
<Button
variant="secondary"
onClick={ props.onRequestClose }
disabled={ isSaving }
>
{ __( 'Cancel', 'pattern-wrangler' ) }
</Button>
</div>
{ errors?.patternCategories && (
<Notice
className="dlx-pw-admin-notice"
status="error"
inline={ true }
icon={ () => <AlertTriangle /> }
>
{ errors.patternCategories.message }
</Notice>
) }
</form>
</div>
</Modal>
</>
);
};

export default PatternTagModal;
Loading