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( '/