最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

Add settings to menu items in the Customizer

programmeradmin1浏览0评论

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

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.

Update #2

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

/**
 * 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.

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

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.

Update #2

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

/**
 * 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.

Share Improve this question edited Aug 10, 2020 at 14:45 helgatheviking asked Aug 6, 2020 at 16:38 helgathevikinghelgatheviking 14.5k8 gold badges64 silver badges115 bronze badges 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. – Nikola Ivanov Nikolov 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. – helgatheviking 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. – helgatheviking Commented Aug 10, 2020 at 15:24
Add a comment  | 

1 Answer 1

Reset to default 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/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.)

发布评论

评论列表(0)

  1. 暂无评论