As pointed out by birgire in his answer, WordPress uses AJAX to update metaboxes status and data passed in the AJAX request does not include post id, and that makes it hard to update boxes status on a per-post basis.
Once I found the AJAX action used by WordPress is 'closed-postboxes'
, I searched for this string in the admin js folder to find how WordPress makes the AJAX request.
I found it happens on postbox.js
at line #118.
It looks like so:
save_state : function(page) {
var closed = $('.postbox').filter('.closed').map(function() {
return this.id;
}).get().join(',');
var hidden = $('.postbox').filter(':hidden').map(function() {
return this.id;
}).get().join(',');
$.post(ajaxurl, {
action: 'closed-postboxes',
closed: closed,
hidden: hidden,
closedpostboxesnonce: jQuery('#closedpostboxesnonce').val(),
page: page
});
}
Essentially, WordPress looks at DOM items with class 'postbox' and class 'closed' and creates a comma separated list of their IDs.
The same is done for hidden DOM items with class 'postbox'.
So, my thought was: I can create a fake metabox that has the right classes and that is hidden, setting its id to contain post ID, and in this way I can retrieve it in an AJAX request.
This is what I've done:
add_action( 'dbx_post_sidebar', function() {
global $post;
if ( $post->post_type === 'mycpt' ) {
$id = $post->ID;
$f = '<span id="fakebox_pid_%d" class="postbox closed" style="display:none;"></span>';
printf( $f, $id );
}
});
In this way I created a metabox that is always closed and always hidden, so WordPress will send its ID in as $_POST
var in the AJAX request, and once fake box id contains post ID in a predictable way, I am able to recognize the post.
After that I looked at how WordPress performs the AJAX task.
In admin-ajax.php
at line 72, WordPress hooks 'wp_ajax_closed-postboxes'
with priority 1.
So, to act before WordPress, I could hook same action with priority 0.
add_action( 'wp_ajax_closed-postboxes', function() {
// check if we are in right post type: WordPress passes it in 'page' post var
$page = filter_input( INPUT_POST, 'page', FILTER_SANITIZE_STRING );
if ( $page !== 'mycpt' ) return;
// get post data
$data = filter_input_array( INPUT_POST, array(
'closed' => array( 'filter' => FILTER_SANITIZE_STRING ),
'hidden' => array( 'filter' => FILTER_SANITIZE_STRING )
) );
// search among closed boxes for the "fake" one, and return if not found
$look_for_fake = array_filter( explode( ',', $data[ 'closed' ] ), function( $id ) {
return strpos( $id, 'fakebox_pid_' ) === 0;
} );
if ( empty( $look_for_fake ) ) return;
$post_id = str_replace( 'fakebox_pid_', '', $look_for_fake[0] );
$user_id = get_current_user_id();
// remove fake id from values
$closed = implode(',', array_diff( explode(',', $data['closed'] ), $look_for_fake ) );
$hidden = implode(',', array_diff( explode(',', $data['hidden'] ), $look_for_fake ) );
// save metabox status on a per-post and per-user basis in a post meta
update_post_meta( $post_id, "_mycpt_closed_boxes_{user_id}", $closed );
update_post_meta( $post_id, "_mycpt_hidden_boxes_{user_id}", $hidden );
}, 0 );
Having data saved in a post meta made it possible to filter get_user_option_closedpostboxes_mycpt
and get_user_option_metaboxhidden_mycpt
(both variations of the get_user_option_{$option}
filter) to force WordPress load options from post meta:
add_filter( 'get_user_option_closedpostboxes_mycpt', function ( $result, $key, $user ) {
global $post;
$meta = get_post_meta( $post->ID, "_mycpt_closed_boxes_{$user->ID}", TRUE );
if ( ! empty( $meta ) ) {
$result = $meta;
}
return $result;
}, 10, 3 );
and
add_filter( 'get_user_option_metaboxhidden_mycpt', function ( $result, $key, $user ) {
global $post;
$meta = get_post_meta( $post->ID, "_mycpt_hidden_boxes_{$user->ID}", TRUE );
if ( ! empty( $meta ) ) {
$result = $meta;
}
return $result;
}, 10, 3 );