11

WordPress 5.4 added the wp_nav_menu_item_custom_fields_customize_template hook to add custom fields to the Nav Menu Item settings in the customizer. I've figured out how to display the additional fields I would like to add (As part of my Nav Menu Roles plugin), with the following snippet:

/**
* Display the fields in the Customizer.
*/
function kia_customizer_custom_fields() { 

    global $wp_roles;

    /**
    * Pass the menu item to the filter function.
    * This change is suggested as it allows the use of information from the menu item (and
    * by extension the target object) to further customize what filters appear during menu
    * construction.
    */
    $display_roles = apply_filters( 'nav_menu_roles', $wp_roles->role_names );

    if( ! $display_roles ) return;

    ?>
    <p class="field-nav_menu_logged_in_out nav_menu_logged_in_out nav_menu_logged_in_out-thin">
        <fieldset>
            <legend><?php _e( 'Display Mode', 'nav-menu-roles' ); ?></legend>

            <label for="edit-menu-item-role_logged_in-{{ data.menu_item_id }}">
                <input type="radio" id="edit-menu-item-role_logged_in-{{ data.menu_item_id }}" class="edit-menu-item-logged_in_out" value="in" name="menu-item-role_logged_in" />
                    <?php _e( 'Logged In Users', 'nav-menu-roles' ); ?><br/>
            </label>
            <label for="edit-menu-item-role_logged_out-{{ data.menu_item_id }}">
                <input type="radio" id="edit-menu-item-role_logged_out-{{ data.menu_item_id }}" class="edit-menu-item-logged_in_out" value="out" name="menu-item-role_logged_out" />
                    <?php _e( 'Logged Out Users', 'nav-menu-roles' ); ?><br/>
            </label>
            <label for="edit-menu-item-role_everyone-{{ data.menu_item_id }}">
                <input type="radio" id="edit-menu-item-role_everyone-{{ data.menu_item_id }}" class="edit-menu-item-logged_in_out" value="" name="menu-item-role_everyone" />
                    <?php _e( 'Everyone', 'nav-menu-roles' ); ?><br/>
            </label>

        </fieldset>
    </p>
    <p class="field-nav_menu_roles nav_menu_roles nav_menu_roles-thin">
        <fieldset class="roles">
            <legend><?php _e( 'Restrict menu item to a minimum role', 'nav-menu-roles' ); ?></legend>

            <?php

            /* Loop through each of the available roles. */
            foreach ( $display_roles as $role => $name ) : ?>
               
               <label for="edit-menu-item-role_<?php echo $role; ?>-{{ data.menu_item_id }}">
                    <input type="checkbox" id="edit-menu-item-role_<?php echo esc_attr( $role ); ?>-{{ data.menu_item_id }}" class="edit-menu-item-role" value="<?php echo esc_attr( $role ); ?>" name="menu-item-role_<?php echo esc_attr( $role ); ?>" />
                        <?php echo esc_html( $name ); ?><br/>
                </label>

            <?php endforeach; ?>

        </fieldset>
    </p>
    <?php
}
add_action( 'wp_nav_menu_item_custom_fields_customize_template', 'kia_customizer_custom_fields' );

but I'm not sure how to add any data as the default fields are all getting data from some Javascript templating.

For example the description's value is {{ data.description }}.

It looks like the data is being added in the WP_Customize_Manager class and that the data is fetched from each setting's json() method, which has no filters to modify the result.

If I'm tracing it back correctly, the json() method tries to get the values from the WP_Customize_Nav_Menu_Item_Setting class which also has no filter for adding custom data.

So, I'm trying to track down how to 1. pass some additional properties to data and 2. set the radio and checkbox values when loaded in the Customizer.

Update #1

I already was filtering wp_setup_nav_menu_item like so:

/**
* Adds value of new field to nav menu $item object
*/
function kia_setup_nav_item( $menu_item ) {

    if( is_object( $menu_item ) && isset( $menu_item->ID ) ) {
        $roles = get_post_meta( $menu_item->ID, '_nav_menu_role', true );
    }
    return $menu_item;
}
add_filter( 'wp_setup_nav_menu_item', 'kia_setup_nav_item' );

But per @Nikola Ivanov Nikolov's comment I realized that this data is available in the customizer script object... it seems like it's in wp.customize.settings.settings

console log screenshot showing the wp.customize.settings.settings object with roles key on each nav_menu_item array

And I have tracked down the customizer control for wp.customize.Menus.MenuItemControl so now it seems to be an issue of running some script when the menu item control is ready/expanded.

WordPress Customizer screenshot for a menu item named "My Shop" and showing radio buttons for "Display mode" and checkboxes for "restrict menu item to minimum role"

Update #2

I have some Javascript that will at least get the current values checked!

animated gif, where clicking on a menu item reveals additional fields such as display mode and role restrictions. When clicking on "logged in" display mode, checkboxes of roles appear.

/**
 * Customizer Communicator
 */
jQuery( document ).ready(function($) {
    "use strict";

    $( '.customize-control-nav_menu_item' ).on( 'expanded', function() {

        var $control     = $(this);
        var menu_item_id = $(this).find( '.nav_menu_logged_in_out' ).data( 'menu_item_id' );
        var control      = wp.customize.control( 'nav_menu_item[' + menu_item_id + ']' );
        var settings     = control.setting.get();

        if ( 'undefined' === typeof( settings.roles ) || '' === settings.roles ) {
            $control.find( '.edit-menu-item-logged_in_out[value=""]' ).prop( 'checked', true ).change();
        } else if ( 'out' === settings.roles ) {
            $control.find( '.edit-menu-item-logged_in_out[value="out"]' ).prop( 'checked', true ).change();
        } else if ( 'in' === settings.roles ) {
            $control.find( '.edit-menu-item-logged_in_out[value="in"]' ).prop( 'checked', true ).change();
        } else if ( $.isArray( settings.roles ) ) {
            $control.find( '.edit-menu-item-logged_in_out[value="in"]' ).prop( 'checked', true ).change();
            $.each( settings.roles, function( index, role ) {
                $control.find( '.edit-menu-item-role[value="' + role + '"]' ).prop( 'checked', true );
            } );
        }

        $( '.edit-menu-item-logged_in_out' ).on( 'change', function() {
            var $roles = $control.find( '.nav_menu_roles' );
            if ( 'in' === $(this).val() ) {
                $roles.show();
            } else {
                $roles.hide();
            }   
        });

    });

});

So, this still leaves how to ensure SAVING the data.

4
  • 2
    Ok, I don't have a full answer yet, but I'm giving up for tonight :( so here's the small progress I was able to make: by hooking to the wp_setup_nav_menu_item filter, you can pass data to the output of the json() method. HOWEVER this doesn't get passed to the template's data variable for some reason. Try adding this: function kia_filter_nav_menu_item( $menu_item ) {$menu_item->test_setting = 'IT WORKS!';return $menu_item;}add_filter( 'wp_setup_nav_menu_item', 'kia_filter_nav_menu_item', 10 ); and then check the source of the customizer page. You should find "IT WORKS!" there. Commented Aug 6, 2020 at 21:01
  • 1
    You are right! I'm already filtering wp_setup_nav_menu_item and so the data is in the source! Next step is to figure out how to access it via JS. Commented Aug 6, 2020 at 22:02
  • Just to clarify, the only issue you're trying to solve at the moment is saving the menu item metadata, is that correct?
    – Howdy_McGee
    Commented Aug 10, 2020 at 15:07
  • @Howdy_McGee correct, that's the only remaining issue after my 2 updates. Out of curiosity I'd love to know if there was a JS only way to add the fields in the first place, but that was not my original question and isn't critical to resolving this. Commented Aug 10, 2020 at 15:24

1 Answer 1

7
+500

You're running up against an incomplete implementation of modifying nav menus in the Customizer. In particular, inside of the phpdoc for WP_Customize_Nav_Menu_Item_Setting::preview() you can see this comment:

// @todo Add get_post_metadata filters for plugins to add their data.

Nevertheless, even though we didn't implemented core support for previewing changes to nav menu item postmeta, it can still be done. But first of all, let's address the JavaScript.

Extending the Nav Menu Item Controls

The first need is to identify the nav menu item controls that need to be extended, and this is the way to do that with the JS API:

wp.customize.control.bind( 'add', ( control ) => {
    if ( control.extended( wp.customize.Menus.MenuItemControl ) ) {
        control.deferred.embedded.done( () => {
            extendControl( control );
        } );
    }
} );

This extendControl() function needs to augment the control with the behaviors for the new fields you added via kia_customizer_custom_fields():

/**
 * Extend the control with roles information.
 *
 * @param {wp.customize.Menus.MenuItemControl} control
 */
function extendControl( control ) {
    control.authFieldset = control.container.find( '.nav_menu_role_authentication' );
    control.rolesFieldset = control.container.find( '.nav_menu_roles' );

    // Set the initial UI state.
    updateControlFields( control );

    // Update the UI state when the setting changes programmatically.
    control.setting.bind( () => {
        updateControlFields( control );
    } );

    // Update the setting when the inputs are modified.
    control.authFieldset.find( 'input' ).on( 'click', function () {
        setSettingRoles( control.setting, this.value );
    } );
    control.rolesFieldset.find( 'input' ).on( 'click', function () {
        const checkedRoles = [];
        control.rolesFieldset.find( ':checked' ).each( function () {
            checkedRoles.push( this.value );
        } );
        setSettingRoles( control.setting, checkedRoles.length === 0 ? 'in' : checkedRoles );
    } );
}

Here you can see it sets the initial UI state based on the control's setting value, and then updates it as changes are made with two-way data binding.

Here's a function which is responsible for changing the roles in the Customizer's Setting object for the nav menu item:

/**
 * Extend the setting with roles information.
 *
 * @param {wp.customize.Setting} setting
 * @param {string|Array} roles
 */
function setSettingRoles( setting, roles ) {
    setting.set(
        Object.assign(
            {},
            _.clone( setting() ),
            { roles }
        )
    );
}

And then here's how the setting's value is applied to the control's fields:

/**
 * Apply the control's setting value to the control's fields.
 *
 * @param {wp.customize.Menus.MenuItemControl} control
 */
function updateControlFields( control ) {
    const roles = control.setting().roles || '';

    const radioValue = _.isArray( roles ) ? 'in' : roles;
    const checkedRoles = _.isArray( roles ) ? roles : [];

    control.rolesFieldset.toggle( 'in' === radioValue );

    const authRadio = control.authFieldset.find( `input[type=radio][value="${ radioValue }"]` );
    authRadio.prop( 'checked', true );

    control.rolesFieldset.find( 'input[type=checkbox]' ).each( function () {
        this.checked = checkedRoles.includes( this.value );
    } );
}

So that's all the required JS code, and it can be enqueued in PHP via:

add_action(
    'customize_controls_enqueue_scripts',
    static function () {
        wp_enqueue_script(
            'customize-nav-menu-roles',
            plugin_dir_url( __FILE__ ) . '/customize-nav-menu-roles.js',
            [ 'customize-nav-menus' ],
            filemtime( __DIR__ . '/customize-nav-menu-roles.js' ),
            true
        );
    }
);

Now onto the required PHP code...

Implementing Previewing and Saving

Since the WP_Customize_Nav_Menu_Item_Setting class isn't aware of the custom fields, it will just strip them out. So what we need to implement our own previewing method. Here's how to wire that up:

add_action(
    'customize_register',
    static function( WP_Customize_Manager $wp_customize ) {
        if ( $wp_customize->settings_previewed() ) {
            foreach ( $wp_customize->settings() as $setting ) {
                if ( $setting instanceof WP_Customize_Nav_Menu_Item_Setting ) {
                    preview_nav_menu_setting_postmeta( $setting );
                }
            }
        }
    },
    1000
);

This loops over each registered nav menu item setting and calls preview_nav_menu_setting_postmeta():

/**
 * Preview changes to the nav menu item roles.
 *
 * Note the unimplemented to-do in the doc block for the setting's preview method.
 *
 * @see WP_Customize_Nav_Menu_Item_Setting::preview()
 *
 * @param WP_Customize_Nav_Menu_Item_Setting $setting Setting.
 */
function preview_nav_menu_setting_postmeta( WP_Customize_Nav_Menu_Item_Setting $setting ) {
    $roles = get_sanitized_roles_post_data( $setting );
    if ( null === $roles ) {
        return;
    }

    add_filter(
        'get_post_metadata',
        static function ( $value, $object_id, $meta_key ) use ( $setting, $roles ) {
            if ( $object_id === $setting->post_id && '_nav_menu_role' === $meta_key ) {
                return [ $roles ];
            }
            return $value;
        },
        10,
        3
    );
}

You can see here it adds a filter to the underlying get_post_meta() call. The roles value being previewed is obtained via get_sanitized_roles_post_data():

/**
 * Save changes to the nav menu item roles.
 *
 * Note the unimplemented to-do in the doc block for the setting's preview method.
 *
 * @see WP_Customize_Nav_Menu_Item_Setting::update()
 *
 * @param WP_Customize_Nav_Menu_Item_Setting $setting Setting.
 */
function save_nav_menu_setting_postmeta( WP_Customize_Nav_Menu_Item_Setting $setting ) {
    $roles = get_sanitized_roles_post_data( $setting );
    if ( null !== $roles ) {
        update_post_meta( $setting->post_id, '_nav_menu_role', $roles );
    }
}

Now, for saving we do something similar. First we loop over all nav menu item settings at customize_save_after:

add_action(
    'customize_save_after',
    function ( WP_Customize_Manager $wp_customize ) {
        foreach ( $wp_customize->settings() as $setting ) {
            if ( $setting instanceof WP_Customize_Nav_Menu_Item_Setting && $setting->check_capabilities() ) {
                save_nav_menu_setting_postmeta( $setting );
            }
        }
    }
);

Where save_nav_menu_setting_postmeta() gets the previewed setting value and then saves it into postmeta:

/**
 * Save changes to the nav menu item roles.
 *
 * Note the unimplemented to-do in the doc block for the setting's preview method.
 *
 * @see WP_Customize_Nav_Menu_Item_Setting::update()
 *
 * @param WP_Customize_Nav_Menu_Item_Setting $setting Setting.
 */
function save_nav_menu_setting_postmeta( WP_Customize_Nav_Menu_Item_Setting $setting ) {
    $roles = get_sanitized_roles_post_data( $setting );
    if ( null !== $roles ) {
        update_post_meta( $setting->post_id, '_nav_menu_role', $roles );
    }
}

The get_sanitized_roles_post_data() function looks like this:

/**
 * Sanitize roles value.
 *
 * @param string|array $value Roles.
 * @return array|string Sanitized roles.
 */
function sanitize_roles_value( $value ) {
    global $wp_roles;
    if ( is_array( $value ) ) {
        return array_intersect( $value, array_keys( $wp_roles->role_names ) );
    } elseif ( in_array( $value, [ '', 'in', 'out' ], true ) ) {
        return $value;
    }
    return '';
}

And that does it.

Here's a complete working plugin that puts all these pieces together, once activated along with the Nav Menu Roles plugin: https://gist.github.com/westonruter/7f2b9c18113f0576a72e0aca3ce3dbcb


The JS logic for doing the data binding between the Customizer setting and the control's fields could be made a bit more elegant. It could use React for example. Or it could use wp.customize.Element which the other controls use. But this gets the job done with good ol' jQuery.

One caveat about this implementation: due to how the Customizer deals with previewing nav menu items that have not been saved yet, you won't be able to preview changes to the roles value for such nav menu items. (Under the hood, the Customizer creates a negative post ID to represent nav menu items that haven't been saved to the DB yet.)

5
  • Thanks so much Weston! A couple of follow up questions... 1 when you say the binding could be React ... you mean this part? control.authFieldset.find( 'input' ).on( 'click', function () { and 2. Does this script (As is) need to be transpiled or can it be served as is? Commented Aug 22, 2020 at 13:42
  • 1
    1. It would be instead of using wp_nav_menu_item_custom_fields_customize_template to add the fields via Mustache templating, and then most of the logic in extendControl would be replaced with just adding a div to the container in the desired place, and then re-render when a change to the setting happens. But nevertheless, React is not used anywhere else in the Customizer currently, so using React is not necessary. Commented Aug 23, 2020 at 4:39
  • 1
    2. It can be served as-is, although there are some “modern” JS features which I've used like arrow functions which won't work in IE11. So you can transpile it with Babel if you want to maximize compatibility with older browsers. Or you can eliminate the use of those modern syntax features. Commented Aug 23, 2020 at 4:41
  • Thanks for the clarification! I thought there were some modern features as my JS linter was yelling at me. :) Commented Aug 23, 2020 at 15:23
  • Is there any workaround for showing the preview for items that are added in the customizer before they are saved (those with a placeholder negative post ID)? I understand the caveat, but there must be some trickery that can be implemented even in pure javascript?
    – noahmason
    Commented Nov 26, 2022 at 15:06

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.