diff --git a/admin/class-admin.php b/admin/class-admin.php index a5d09b29689..15d7f559f75 100644 --- a/admin/class-admin.php +++ b/admin/class-admin.php @@ -5,6 +5,7 @@ * @package WPSEO\Admin */ +use Yoast\WP\SEO\Helpers\Primary_Term_Helper; use Yoast\WP\SEO\Integrations\Settings_Integration; /** @@ -89,7 +90,7 @@ public function __construct() { ]; if ( WPSEO_Metabox::is_post_overview( $pagenow ) || WPSEO_Metabox::is_post_edit( $pagenow ) ) { - $this->admin_features['primary_category'] = new WPSEO_Primary_Term_Admin(); + $this->admin_features['primary_category'] = new WPSEO_Primary_Term_Admin( new Primary_Term_Helper() ); } $integrations[] = new WPSEO_Yoast_Columns(); diff --git a/admin/class-primary-term-admin.php b/admin/class-primary-term-admin.php index 93cfc481f40..c7c8e7e2577 100644 --- a/admin/class-primary-term-admin.php +++ b/admin/class-primary-term-admin.php @@ -5,11 +5,29 @@ * @package WPSEO\Admin */ +use Yoast\WP\SEO\Helpers\Primary_Term_Helper; + /** * Adds the UI to change the primary term for a post. */ class WPSEO_Primary_Term_Admin implements WPSEO_WordPress_Integration { + /** + * Primary term helper + * + * @var Primary_Term_Helper + */ + private $primary_term_helper; + + /** + * Constructor. + * + * @param Primary_Term_Helper $primary_term_helper Primary term helper. + */ + public function __construct( Primary_Term_Helper $primary_term_helper ) { + $this->primary_term_helper = $primary_term_helper; + } + /** * Register hooks. * @@ -168,7 +186,7 @@ protected function get_primary_term_taxonomies( $post_id = null ) { return $taxonomies; } - $taxonomies = YoastSEO()->helpers->primary_term->get_primary_term_taxonomies( $post_id ); + $taxonomies = $this->primary_term_helper->get_primary_term_taxonomies( $post_id ); wp_cache_set( 'primary_term_taxonomies_' . $post_id, $taxonomies, 'wpseo' ); @@ -189,7 +207,7 @@ protected function include_js_templates() { * * @param array $taxonomies The taxonomies that should be mapped. * - * @return array>> The mapped taxonomies. + * @return array>> The mapped taxonomies. */ protected function get_mapped_taxonomies_for_js( $taxonomies ) { return array_map( [ $this, 'map_taxonomies_for_js' ], $taxonomies ); @@ -200,7 +218,7 @@ protected function get_mapped_taxonomies_for_js( $taxonomies ) { * * @param stdClass $taxonomy The taxonomy to map. * - * @return array> The mapped taxonomy. + * @return array> The mapped taxonomy. */ private function map_taxonomies_for_js( $taxonomy ) { $primary_term = $this->get_primary_term( $taxonomy->name ); @@ -230,20 +248,9 @@ private function map_taxonomies_for_js( $taxonomy ) { 'name' => $taxonomy->name, 'primary' => $primary_term, 'singularLabel' => $taxonomy->labels->singular_name, - 'fieldId' => $this->generate_field_id( $taxonomy->name ), + 'fieldId' => WPSEO_Meta::$form_prefix . 'primary_' . $taxonomy->name, 'restBase' => ( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name, 'terms' => $mapped_terms_for_js, ]; } - - /** - * Returns whether or not a taxonomy is hierarchical. - * - * @param stdClass $taxonomy Taxonomy object. - * - * @return bool - */ - private function filter_hierarchical_taxonomies( $taxonomy ) { - return (bool) $taxonomy->hierarchical; - } } diff --git a/composer.json b/composer.json index 6010fd20cbb..6c9c3ad468b 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,7 @@ "Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards" ], "check-cs-thresholds": [ - "@putenv YOASTCS_THRESHOLD_ERRORS=2518", + "@putenv YOASTCS_THRESHOLD_ERRORS=2517", "@putenv YOASTCS_THRESHOLD_WARNINGS=253", "Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds" ], diff --git a/inc/class-wpseo-meta.php b/inc/class-wpseo-meta.php index 8c88c57df74..0d5fe208c66 100644 --- a/inc/class-wpseo-meta.php +++ b/inc/class-wpseo-meta.php @@ -302,19 +302,6 @@ public static function init() { } unset( $extra_fields ); - // register meta data for taxonomies. - self::$meta_fields['primary_terms'] = []; - - $taxonomies = get_taxonomies( [ 'hierarchical' => true ], 'names' ); - foreach ( $taxonomies as $taxonomy_name ) { - self::$meta_fields['primary_terms'][ 'primary_' . $taxonomy_name ] = [ - 'type' => 'hidden', - 'title' => '', - 'default_value' => '', - 'description' => '', - ]; - } - foreach ( self::$meta_fields as $subset => $field_group ) { foreach ( $field_group as $key => $field_def ) { diff --git a/packages/js/src/helpers/fields/blockEditorSync.js b/packages/js/src/helpers/fields/blockEditorSync.js index 3ec2169959d..ddbd4d31b85 100644 --- a/packages/js/src/helpers/fields/blockEditorSync.js +++ b/packages/js/src/helpers/fields/blockEditorSync.js @@ -1,10 +1,14 @@ import { dispatch, select, subscribe } from "@wordpress/data"; -import { debounce, reduce, mapKeys, forEach } from "lodash"; +import { debounce, reduce, forEach } from "lodash"; import { createWatcher, createCollectorFromObject } from "../create-watcher"; import { STORES, META_FIELDS, SYNC_TIME, POST_META_KEY_PREFIX } from "../../shared-admin/constants"; -import { getPrimaryTerms } from "./primaryTaxonomiesFieldsStore"; import { transformMetaValue } from "./transform-meta-value"; +META_FIELDS.primaryTerms = { + key: "primary_terms", + get: "getPrimaryTaxonomies", +}; + /** * Creates an updater. * @returns {function} The updater. @@ -21,9 +25,9 @@ const createUpdater = () => { */ return ( data ) => { const { type, id } = getCurrentPost(); - const metadata = getEditedEntityRecord( "postType", type, id ).meta; + const postData = getEditedEntityRecord( "postType", type, id ); - if ( ! metadata || ! data ) { + if ( ! postData.meta || ! data ) { return; } @@ -31,17 +35,35 @@ const createUpdater = () => { forEach( data, ( value, key ) => { const fieldKey = key.replace( POST_META_KEY_PREFIX, "" ); + if ( fieldKey === "primary_terms" ) { + return; + } const transformedValue = transformMetaValue( fieldKey, value ); - if ( transformedValue !== metadata[ key ] ) { - changedData[ key ] = transformedValue; + if ( transformedValue !== postData.meta[ key ] ) { + if ( ! changedData.meta ) { + changedData.meta = {}; + } + changedData.meta[ key ] = transformedValue; } } ); - if ( changedData ) { - editPost( { - meta: changedData, + const primaryTerms = data[ `${POST_META_KEY_PREFIX}primary_terms` ]; + if ( primaryTerms ) { + forEach( primaryTerms, ( value, key ) => { + const fieldKey = `primary_${key}`; + const transformedValue = transformMetaValue( fieldKey, value ); + if ( transformedValue !== postData[ `${POST_META_KEY_PREFIX}primary_terms` ][ key ] ) { + if ( ! changedData[ `${POST_META_KEY_PREFIX}primary_terms` ] ) { + changedData[ `${POST_META_KEY_PREFIX}primary_terms` ] = {}; + } + changedData[ `${POST_META_KEY_PREFIX}primary_terms` ][ key ] = transformedValue; + } } ); } + + if ( changedData ) { + editPost( changedData ); + } }; }; @@ -50,8 +72,6 @@ const createUpdater = () => { * @returns {function} The un-subscriber. */ export const blockEditorSync = () => { - const primaryTaxonomiesGetters = mapKeys( getPrimaryTerms(), ( value, key ) => POST_META_KEY_PREFIX + key ); - const getters = reduce( META_FIELDS, ( acc, value ) => { // check if value.get is a function in select( STORES.editor ) store if ( typeof select( STORES.editor )[ value.get ] === "function" ) { @@ -63,7 +83,6 @@ export const blockEditorSync = () => { return subscribe( debounce( createWatcher( createCollectorFromObject( { ...getters, - ...primaryTaxonomiesGetters, } ), createUpdater() ), SYNC_TIME.wait, { maxWait: SYNC_TIME.max, leading: true } ), STORES.editor ); diff --git a/packages/js/src/redux/initial-state/primaryTaxonomies.js b/packages/js/src/redux/initial-state/primaryTaxonomies.js index 2bea02d4272..938fa775ad9 100644 --- a/packages/js/src/redux/initial-state/primaryTaxonomies.js +++ b/packages/js/src/redux/initial-state/primaryTaxonomies.js @@ -2,8 +2,8 @@ import { get, reduce } from "lodash"; const primaryTerms = get( window, "wpseoPrimaryCategoryL10n.taxonomies", {} ); -export const primaryTaxonomies = reduce( primaryTerms, ( acc, value, key ) => { - acc[ key ] = value.primary || -1; +export const primaryTaxonomies = reduce( primaryTerms, ( acc, value ) => { + acc[ value.name ] = value.primary || -1; return acc; }, {} ); diff --git a/src/initializers/primary-term-metadata.php b/src/initializers/primary-term-metadata.php new file mode 100644 index 00000000000..502b18f5624 --- /dev/null +++ b/src/initializers/primary-term-metadata.php @@ -0,0 +1,148 @@ +primary_term_helper = $primary_term_helper; + } + + /** + * Initializes the integration. + * + * This is the place to register hooks and filters. + * + * @return void + */ + public function initialize() { + \add_action( 'rest_api_init', [ $this, 'register_primary_terms_field' ] ); + } + + /** + * Register primary term meta for taxonomies that are added through the 'wpseo_primary_term_taxonomies' filter. + * + * @return void + */ + + /** + * Add rest fields for words for linking. + * + * @return void + */ + public function register_primary_terms_field() { + + \register_rest_field( + 'post', + WPSEO_Meta::$meta_prefix . 'primary_terms', + [ + 'get_callback' => [ $this, 'get_primary_terms' ], + 'update_callback' => [ $this, 'save_primary_terms' ], + 'schema' => [ + 'arg_options' => [ + 'sanitize_callback' => [ $this, 'sanitize_primary_terms' ], + ], + 'type' => 'object', + 'properties' => $this->get_property_types(), + 'context' => [ 'edit' ], + ], + ] + ); + } + + /** + * Get property types. + * + * @return array> The property types. + */ + private function get_property_types() { + $post_id = \get_the_ID(); + $taxonomies = $this->primary_term_helper->get_primary_term_taxonomies( $post_id ); + $primary_terms = []; + foreach ( $taxonomies as $taxonomy ) { + $primary_terms[ $taxonomy->name ] = [ 'type' => 'string' ]; + } + return $primary_terms; + } + + /** + * Save the primary terms. + * + * @param array $primary_terms The primary terms to be saved. + * @param WP_Post $post The post object. + * + * @return void + */ + public function save_primary_terms( $primary_terms, $post ) { + foreach ( $primary_terms as $taxonomy => $term_id ) { + $meta_key = WPSEO_Meta::$meta_prefix . 'primary_' . $taxonomy; + if ( $term_id ) { + \update_post_meta( $post->ID, $meta_key, $term_id ); + } + else { + \delete_post_meta( $post->ID, $meta_key ); + } + } + } + + /** + * Sanitize primary terms. + * + * @param array $primary_terms The value to sanitize. + * + * @return array The sanitized value. + */ + public function sanitize_primary_terms( $primary_terms ) { + if ( ! \is_array( $primary_terms ) ) { + return []; + } + $clean = []; + foreach ( $primary_terms as $taxonomy => $term_id ) { + $int = WPSEO_Utils::validate_int( $term_id ); + $clean[ $taxonomy ] = ( $int !== false && $int > 0 ) ? \strval( $int ) : ''; + } + return $clean; + } + + /** + * Get primary terms. + * + * phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification + * + * @param array $post The post data. + * + * @return array + */ + public function get_primary_terms( $post ) { + $taxonomies = $this->primary_term_helper->get_primary_term_taxonomies( $post['id'] ); + $primary_terms = []; + foreach ( $taxonomies as $taxonomy ) { + $meta_key = WPSEO_Meta::$meta_prefix . 'primary_' . $taxonomy->name; + $primary_term = \get_post_meta( $post['id'], $meta_key, true ); + $primary_terms[ $taxonomy->name ] = ( $primary_term ?? '' ); + } + return $primary_terms; + } +} diff --git a/src/integrations/watchers/primary-term-watcher.php b/src/integrations/watchers/primary-term-watcher.php index 140fc0cbde3..55869ca7848 100644 --- a/src/integrations/watchers/primary-term-watcher.php +++ b/src/integrations/watchers/primary-term-watcher.php @@ -50,7 +50,7 @@ class Primary_Term_Watcher implements Integration_Interface { /** * Returns the conditionals based on which this loadable should be active. * - * @return array + * @return array */ public static function get_conditionals() { return [ Migrations_Conditional::class ]; diff --git a/tests/Unit/Admin/Admin_Features_Test.php b/tests/Unit/Admin/Admin_Features_Test.php index 91eeabe8f7e..5f9caf0b5ce 100644 --- a/tests/Unit/Admin/Admin_Features_Test.php +++ b/tests/Unit/Admin/Admin_Features_Test.php @@ -9,6 +9,7 @@ use WPSEO_Admin; use WPSEO_Primary_Term_Admin; use Yoast\WP\SEO\Helpers\Current_Page_Helper; +use Yoast\WP\SEO\Helpers\Primary_Term_Helper; use Yoast\WP\SEO\Helpers\Product_Helper; use Yoast\WP\SEO\Helpers\Short_Link_Helper; use Yoast\WP\SEO\Helpers\Url_Helper; @@ -101,7 +102,7 @@ public function test_get_admin_features_ON_post_edit() { $class_instance = $this->get_admin_with_expectations(); $admin_features = [ - 'primary_category' => new WPSEO_Primary_Term_Admin(), + 'primary_category' => new WPSEO_Primary_Term_Admin( new Primary_Term_Helper() ), 'dashboard_widget' => new Yoast_Dashboard_Widget(), 'wincher_dashboard_widget' => new Wincher_Dashboard_Widget(), ]; diff --git a/tests/Unit/Initializers/Primary_Term_Metadata_Test.php b/tests/Unit/Initializers/Primary_Term_Metadata_Test.php new file mode 100644 index 00000000000..73745122447 --- /dev/null +++ b/tests/Unit/Initializers/Primary_Term_Metadata_Test.php @@ -0,0 +1,225 @@ +primary_term_helper = Mockery::mock( Primary_Term_Helper::class ); + + $this->instance = new Primary_Term_Metadata( $this->primary_term_helper ); + } + + /** + * Tests the initialization. + * + * @covers ::initialize + * + * @return void + */ + public function test_initialize() { + $this->instance->initialize(); + + $this->assertNotFalse( \has_action( 'rest_api_init', [ $this->instance, 'register_primary_terms_field' ] ), 'Does not have expected rest_api_init action' ); + } + + /** + * Tests the register_primary_terms_field method. + * + * @covers ::register_primary_terms_field + * + * @return void + */ + public function test_register_primary_terms_field() { + Monkey\Functions\expect( 'get_the_ID' ) + ->once() + ->andReturn( 1 ); + + $this->primary_term_helper->expects( 'get_primary_term_taxonomies' ) + ->once() + ->with( 1 ) + ->andReturn( [ (object) [ 'name' => 'category' ] ] ); + + Monkey\Functions\expect( 'register_rest_field' ) + ->once() + ->with( + 'post', + '_yoast_wpseo_primary_terms', + [ + 'get_callback' => [ $this->instance, 'get_primary_terms' ], + 'update_callback' => [ $this->instance, 'save_primary_terms' ], + 'schema' => [ + 'arg_options' => [ + 'sanitize_callback' => [ $this->instance, 'sanitize_primary_terms' ], + ], + 'type' => 'object', + 'properties' => [ 'category' => [ 'type' => 'string' ] ], + 'context' => [ 'edit' ], + ], + ] + ); + $this->instance->register_primary_terms_field(); + } + + /** + * Tests the save_primary_terms method. + * + * @covers ::save_primary_terms + * + * @return void + */ + public function test_save_primary_terms_update() { + $post = Mockery::mock( 'WP_Post' ); + $post->ID = 1; + + $primary_terms = [ 'category' => '5' ]; + + Monkey\Functions\expect( 'update_post_meta' ) + ->once() + ->with( 1, '_yoast_wpseo_primary_category', '5' ); + + $this->instance->save_primary_terms( $primary_terms, $post ); + } + + /** + * Tests the save_primary_terms method. + * + * @covers ::save_primary_terms + * + * @return void + */ + public function test_save_primary_terms_delete_row() { + $post = Mockery::mock( 'WP_Post' ); + $post->ID = 1; + + $primary_terms = [ 'category' => '' ]; + + Monkey\Functions\expect( 'delete_post_meta' ) + ->once() + ->with( 1, '_yoast_wpseo_primary_category' ); + + $this->instance->save_primary_terms( $primary_terms, $post ); + } + + /** + * Data provider for the sanitize_primary_terms method. + * + * @return array|null + */ + public static function data_provider_sanitize_primary_terms() { + return [ + 'empty' => [ + 'input' => [], + 'expected' => [], + ], + 'null' => [ + 'input' => null, + 'expected' => [], + ], + 'minus and zero values' => [ + 'input' => [ + 'category' => '0', + 'tags' => -1, + 'category2' => 0, + 'tags2' => '-2', + ], + 'expected' => [ + 'category' => '', + 'tags' => '', + 'category2' => '', + 'tags2' => '', + ], + ], + ]; + } + + /** + * Tests the sanitize_primary_terms method. + * + * @dataProvider data_provider_sanitize_primary_terms + * + * @covers ::sanitize_primary_terms + * + * @param array|null $input The input to sanitize. + * @param array $expected The expected output. + * + * @return void + */ + public function test_sanitize_primary_terms( $input, $expected ) { + $this->assertEquals( $expected, $this->instance->sanitize_primary_terms( $input ) ); + } + + /** + * Tests the get_primary_terms method. + * + * @covers ::get_primary_terms + * + * @return void + */ + public function test_get_primary_terms() { + $this->primary_term_helper->expects( 'get_primary_term_taxonomies' ) + ->once() + ->with( 1 ) + ->andReturn( [ (object) [ 'name' => 'category' ] ] ); + + Monkey\Functions\expect( 'get_post_meta' ) + ->once() + ->with( 1, '_yoast_wpseo_primary_category', true ) + ->andReturn( '5' ); + + $this->assertEquals( [ 'category' => '5' ], $this->instance->get_primary_terms( [ 'id' => 1 ] ) ); + } + + /** + * Tests the get_primary_terms method. + * + * @covers ::get_primary_terms + * + * @return void + */ + public function test_get_primary_terms_false() { + $this->primary_term_helper->expects( 'get_primary_term_taxonomies' ) + ->once() + ->with( 1 ) + ->andReturn( [ (object) [ 'name' => 'category' ] ] ); + + Monkey\Functions\expect( 'get_post_meta' ) + ->once() + ->with( 1, '_yoast_wpseo_primary_category', true ) + ->andReturn( false ); + + $this->assertEquals( [ 'category' => '' ], $this->instance->get_primary_terms( [ 'id' => 1 ] ) ); + } +} diff --git a/tests/WP/Admin/Primary_Term_Admin_Test.php b/tests/WP/Admin/Primary_Term_Admin_Test.php index 29728945774..5894f1d4589 100644 --- a/tests/WP/Admin/Primary_Term_Admin_Test.php +++ b/tests/WP/Admin/Primary_Term_Admin_Test.php @@ -4,6 +4,7 @@ use WPSEO_Admin_Asset_Manager; use WPSEO_Primary_Term_Admin; +use Yoast\WP\SEO\Helpers\Primary_Term_Helper; use Yoast\WP\SEO\Tests\WP\TestCase; /** @@ -25,9 +26,10 @@ final class Primary_Term_Admin_Test extends TestCase { */ public function set_up() { parent::set_up(); - + $primary_term_helper = new Primary_Term_Helper(); $this->class_instance = $this->getMockBuilder( WPSEO_Primary_Term_Admin::class ) ->setMethods( [ 'get_primary_term_taxonomies', 'include_js_templates', 'save_primary_term', 'get_primary_term' ] ) + ->setConstructorArgs( [ $primary_term_helper ] ) ->getMock(); }