Add settings to menu items in the Customizer

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

Leave a Comment