Question

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.

Was it helpful?

Solution

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.)

Licensed under: CC-BY-SA with attribution
Not affiliated with wordpress.stackexchange
scroll top