diff --git a/fieldmanager.php b/fieldmanager.php index 64b166b6a9..20fa2faa79 100644 --- a/fieldmanager.php +++ b/fieldmanager.php @@ -3,7 +3,7 @@ * Fieldmanager Base Plugin File. * * @package Fieldmanager - * @version 1.2.6 + * @version 1.3.0 */ /* @@ -11,14 +11,14 @@ Plugin URI: https://github.com/alleyinteractive/wordpress-fieldmanager Description: Add fields to WordPress programatically. Author: Alley -Version: 1.2.6 +Version: 1.3.0 Author URI: https://www.alley.co/ */ /** * Current version of Fieldmanager. */ -define( 'FM_VERSION', '1.2.6' ); +define( 'FM_VERSION', '1.3.0' ); /** * Filesystem path to Fieldmanager. @@ -358,6 +358,10 @@ function fm_calculate_context() { $calculated_context = array( 'term', sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) ); // WPCS: input var okay. } break; + // Context = "nav-menu". + case 'nav-menus.php': + $calculated_context = array( 'nav_menu', null ); + break; } } } diff --git a/package.json b/package.json index 6a035fe220..04a998dec1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "Fieldmanager", "description": "Fieldmanager is a comprehensive toolkit for building forms, metaboxes, and custom admin screens for WordPress.", - "version": "1.2.6", + "version": "1.3.0", "repository": { "type": "git", "url": "https://github.com/alleyinteractive/wordpress-fieldmanager" diff --git a/php/class-fieldmanager-field.php b/php/class-fieldmanager-field.php index ca6c7f32c8..6c8bf55dcc 100644 --- a/php/class-fieldmanager-field.php +++ b/php/class-fieldmanager-field.php @@ -1293,6 +1293,14 @@ public function add_quickedit_box( $title, $post_types, $column_display_callback return new Fieldmanager_Context_QuickEdit( $title, $post_types, $column_display_callback, $column_title, $this ); } + /** + * Add this group to an nav menu. + */ + public function add_nav_menu_fields() { + $this->require_base(); + return new Fieldmanager_Context_MenuItem( $this ); + } + /** * Add this group to an options page. * diff --git a/php/context/class-fieldmanager-context-menuitem.php b/php/context/class-fieldmanager-context-menuitem.php new file mode 100644 index 0000000000..9ddaf07f3e --- /dev/null +++ b/php/context/class-fieldmanager-context-menuitem.php @@ -0,0 +1,237 @@ +fm = $fm; + + $this->fm->data_type = 'post'; + + // Save the original form name. + $this->original_form_name = $this->fm->name; + + add_filter( 'wp_nav_menu_item_custom_fields', array( $this, 'add_fields' ), 10, 5 ); + add_action( 'wp_update_nav_menu_item', array( $this, 'save_fields' ), 10, 3 ); + } + + /** + * Get the menu item form name given the menu ID. This allows FM to save meta + * data to each menu item within the same POST request. + * + * @param int $menu_id The menu ID. + * @return string The form name. + */ + public function get_menu_item_form_name( $menu_id ) { + return $this->original_form_name . '_fm-menu-item-id-' . absint( $menu_id ); + } + + /** + * Parse the form name, assuming it already contains the menu ID, into its + * original form name. + * + * @param string $form_name The form name. + * @return mixed False if form name does not exist or an array of menu ID and name. + */ + public function parse_form_name( $form_name ) { + // Not a menu item form name. + if ( false === strpos( $form_name, '_fm-menu-item-id-', true ) ) { + return false; + } + + // Break out the original name from the menu item ID. + $parts = explode( '_fm-menu-item-id-', $form_name ); + + if ( ! empty( $parts[0] ) && ! empty( $parts[1] ) ) { + return array( + 'name' => $parts[0], + 'id' => absint( $parts[1] ), + ); + } + + return false; + } + + + /** + * Add fields to the editor of a nav menu item. + * + * @param int $item_id Menu item ID. + */ + public function add_fields( $item_id ) { + // Set the ID. + $this->fm->data_id = $item_id; + + // Ensure the ID is part of the name. + $this->fm->name = $this->get_menu_item_form_name( $item_id ); + + // Render the field. + $this->render_field(); + } + + /** + * Save post meta for nav menu items. + * + * @param int $menu_id The ID of the menu. + * @param int $menu_item_db_id The ID of the menu item. + * @param array $menu_item_args Menu item args. + */ + public function save_fields( $menu_id, $menu_item_db_id, $menu_item_args ) { + // Ensure the ID is part of the name. + $this->fm->name = $this->get_menu_item_form_name( $menu_item_db_id ); + + // Ensure that the nonce is set and valid. + if ( ! $this->is_valid_nonce() ) { + return; + } + + // Make sure the current user can save this post. + if ( ! current_user_can( 'edit_theme_options' ) ) { + $this->fm->_unauthorized_access( __( 'User cannot edit this menu item', 'fieldmanager' ) ); + return; + } + + $this->save_to_post_meta( $menu_item_db_id ); + } + + /** + * Helper to save an array of data to post meta. + * + * @param int $post_id The post ID. + * @param array $data The post data. + */ + public function save_to_post_meta( $post_id, $data = null ) { + $this->fm->data_id = $post_id; + $this->fm->data_type = 'post'; + + $this->save( $data ); + } + + /** + * Get post meta. + * + * @see get_post_meta(). + * + * @param int $post_id Post ID. + * @param string $meta_key Optional. The meta key to retrieve. By default, returns + * data for all keys. Default empty. + * @param bool $single Optional. Whether to return a single value. Default false. + */ + protected function get_data( $post_id, $meta_key, $single = false ) { + $parts = $this->parse_form_name( $meta_key ); + + if ( ! empty( $parts['name'] ) && ! empty( $parts['id'] ) ) { + $post_id = $parts['id']; + $meta_key = $parts['name']; + } + + return get_post_meta( $post_id, $meta_key, $single ); + } + + /** + * Add post meta. + * + * @see add_post_meta(). + * + * @param int $post_id Post ID. + * @param string $meta_key Metadata name. + * @param mixed $meta_value Metadata value. Must be serializable if non-scalar. + * @param bool $unique Optional. Whether the same key should not be added. + * Default false. + */ + protected function add_data( $post_id, $meta_key, $meta_value, $unique = false ) { + $parts = $this->parse_form_name( $meta_key ); + + if ( ! empty( $parts['name'] ) && ! empty( $parts['id'] ) ) { + $post_id = $parts['id']; + $meta_key = $parts['name']; + } + + return add_post_meta( $post_id, $meta_key, $meta_value, $unique ); + } + + /** + * Update post meta. + * + * @see update_post_meta(). + * + * @param int $post_id Post ID. + * @param string $meta_key Metadata key. + * @param mixed $meta_value Metadata value. Must be serializable if non-scalar. + * @param mixed $data_prev_value Optional. Previous value to check before removing. + * Default empty. + */ + protected function update_data( $post_id, $meta_key, $meta_value, $data_prev_value = '' ) { + $parts = $this->parse_form_name( $meta_key ); + + if ( ! empty( $parts['name'] ) && ! empty( $parts['id'] ) ) { + $post_id = $parts['id']; + $meta_key = $parts['name']; + } + + $meta_value = $this->sanitize_scalar_value( $meta_value ); + return update_post_meta( $post_id, $meta_key, $meta_value, $data_prev_value ); + } + + /** + * Delete post meta. + * + * @see delete_post_meta(). + * + * @param int $post_id Post ID. + * @param string $meta_key Metadata name. + * @param mixed $meta_value Optional. Metadata value. Must be serializable if + * non-scalar. Default empty. + */ + protected function delete_data( $post_id, $meta_key, $meta_value = '' ) { + $parts = $this->parse_form_name( $meta_key ); + + if ( ! empty( $parts['name'] ) && ! empty( $parts['id'] ) ) { + $post_id = $parts['id']; + $meta_key = $parts['name']; + } + + return delete_post_meta( $post_id, $meta_key, $meta_value ); + } +} diff --git a/tests/php/test-fieldmanager-context-menuitem.php b/tests/php/test-fieldmanager-context-menuitem.php new file mode 100644 index 0000000000..3857790b8e --- /dev/null +++ b/tests/php/test-fieldmanager-context-menuitem.php @@ -0,0 +1,141 @@ +post = self::factory()->post->create_and_get( array( + 'post_type' => 'nav_menu_item', + 'post_status' => 'publish', + ) ); + } + + /** + * Get valid test data. + * Several tests transform this data to somehow be invalid. + * + * @return array valid test data + */ + private function _get_valid_test_data() { + return array( + 'base_group' => array( + 'test_basic' => 'lorem ipsum', + 'test_textfield' => 'alley interactive', + 'test_htmlfield' => 'Hello world', + 'test_extended' => array( + array( + 'extext' => array( 'first' ), + ), + array( + 'extext' => array( 'second1', 'second2', 'second3' ), + ), + array( + 'extext' => array( 'third' ), + ), + array( + 'extext' => array( 'fourth' ), + ), + ), + ), + ); + } + + /** + * Get a set of elements + * + * @return Fieldmanager_Group + */ + private function _get_elements() { + return new Fieldmanager_Group( + array( + 'name' => 'base_group', + 'children' => array( + 'test_basic' => new Fieldmanager_TextField(), + 'test_textfield' => new Fieldmanager_TextField( + array( + 'index' => '_test_index', + ) + ), + 'test_htmlfield' => new Fieldmanager_Textarea( + array( + 'sanitize' => 'wp_kses_post', + ) + ), + 'test_extended' => new Fieldmanager_Group( + array( + 'limit' => 4, + 'children' => array( + 'extext' => new Fieldmanager_TextField( + array( + 'limit' => 0, + 'name' => 'extext', + 'one_label_per_item' => false, + 'sortable' => true, + 'index' => '_extext_index', + ) + ), + ), + ) + ), + ), + ) + ); + } + + public function test_context_render() { + global $wp_version; + + // Only run these tests for WP versions above 5.4.0. + if ( version_compare( $wp_version, '5.4.0', '<' ) ) { + return; + } + + $base = $this->_get_elements(); + ob_start(); + $base->add_nav_menu_fields()->add_fields( $this->post->ID ); + $str = ob_get_clean(); + // we can't really care about the structure of the HTML, but we can make sure that all fields are here + $this->assertRegExp( '/]+type="hidden"[^>]+name="fieldmanager-base_group_fm-menu-item-id-' . $this->post->ID . '-nonce"/', $str ); + $this->assertRegExp( '/]+type="text"[^>]+name="base_group_fm-menu-item-id-' . $this->post->ID . '\[test_basic\]"/', $str ); + $this->assertRegExp( '/]+type="text"[^>]+name="base_group_fm-menu-item-id-' . $this->post->ID . '\[test_textfield\]"/', $str ); + $this->assertRegExp( '/]+name="base_group_fm-menu-item-id-' . $this->post->ID . '\[test_htmlfield\]"/', $str ); + $this->assertContains( 'name="base_group_fm-menu-item-id-' . $this->post->ID . '[test_extended][0][extext][proto]"', $str ); + $this->assertContains( 'name="base_group_fm-menu-item-id-' . $this->post->ID . '[test_extended][0][extext][0]"', $str ); + } + + public function test_context_save() { + global $wp_version; + + // Only run these tests for WP versions above 5.4.0. + if ( version_compare( $wp_version, '5.4.0', '<' ) ) { + return; + } + + $base = $this->_get_elements(); + $test_data = $this->_get_valid_test_data(); + + $base->add_nav_menu_fields()->save_to_post_meta( $this->post->ID, $test_data['base_group'] ); + + $saved_value = get_post_meta( $this->post->ID, 'base_group', true ); + $saved_index = get_post_meta( $this->post->ID, '_test_index', true ); + + $this->assertEquals( $saved_value['test_basic'], 'lorem ipsum' ); + $this->assertEquals( $saved_index, $saved_value['test_textfield'] ); + $this->assertEquals( $saved_value['test_textfield'], 'alley interactive' ); + $this->assertEquals( $saved_value['test_htmlfield'], 'Hello world' ); + $this->assertEquals( count( $saved_value['test_extended'] ), 4 ); + $this->assertEquals( count( $saved_value['test_extended'][0]['extext'] ), 1 ); + $this->assertEquals( count( $saved_value['test_extended'][1]['extext'] ), 3 ); + $this->assertEquals( count( $saved_value['test_extended'][2]['extext'] ), 1 ); + $this->assertEquals( count( $saved_value['test_extended'][3]['extext'] ), 1 ); + $this->assertEquals( $saved_value['test_extended'][1]['extext'], array( 'second1', 'second2', 'second3' ) ); + $this->assertEquals( $saved_value['test_extended'][3]['extext'][0], 'fourth' ); + } +}