Half-Elf on Tech

Thoughts From a Professional Lesbian

Tag: cmb2

  • CMB2: Conditional Meta Fields

    CMB2: Conditional Meta Fields

    Even though Gutenberg is on the rise, and every day gets us closer to using a whole new editor, we still use the ‘classic’ editor and we’re still beholden to it’s space limitations. I have strong feelings about how to properly utilize space when using CMB2, but not included in that specific post is this.

    Fields You Don’t (Always) Need

    There are three types of fields for CMB2.

    1. Fields you always need to use
    2. Fields that are optional
    3. Fields that are needed only in specific situations

    Most of the time we use option 2. Option 3 is the tricky one, though, since most of the time we end up having things show based on actions. That is, I save a post, and new options show up. When it comes to CMB2, we really don’t want to have to save and then edit and save and edit.

    Yuck!

    Thankfully this is all possible.

    Practical Example: Affiliates

    Today we’re making a box to handle affiliate links. There are three types of links: Amazon, Genric, and ‘Unique.’

    The Amazon one will link directly to a signup link and the generic one links to the same thing only on Click Junction. Both of those links will have affiliate details backed in so I don’t have to look them up later. This also means any partners I have on the site don’t need to know all the gory details. Bazinga.

    The last one, though ‘Unique’ is tricky. You see, when someone picks that, I want them to be able to put in a specific URL that may be affiliate linking somewhere else. But let’s start out with how it normally works.

    Make Your Fields

    function my_site_cmb2_metaboxes() {
    	// prefix for all custom fields
    	$prefix = 'my_sites_';
    
    	// Metabox Group: Must See
    	$cmb_affiliate = new_cmb2_box( array(
    		'id'           => 'affiliate_metabox',
    		'title'        => __( 'Affiliate Details', 'my-domain' ),
    		'object_types' => array( 'post_type_shows' ),
    		'show_in_rest' => true,
    	) );
    
    	// Field Box: Affiliate Type
    	$field_affiliatetype = $cmb_affiliate->add_field( array(
    		'name'             => __( 'Type', 'my-domain' ),
    		'id'               => $prefix . 'affiliate',
    		'type'             => 'select',
    		'options'          => array( 
    			'amazon'  => 'Amazon',
    			'generic' => 'Generic',
    			'url'     => 'Unique Link',
    		);
    		'show_option_none' => true,
    	) );
    	// Field Box: Affiliate Links
    	$field_affiliateurl = $cmb_affiliate->add_field( array(
    		'name'    => __( 'Link', 'my-domain' ),
    		'id'      => $prefix . 'affiliateurl',
    		'type'    => 'text_url',
    	) );
    );
    

    That’s pretty normal for CMB2 and looks like this:

    CMB2: Affiliate Details

    Normal, but … I want to hide that Link field unless a specific option is selected. Enter javascript.

    Hide That Field (Conditionally)

    Make a file for your javascript. I’ve called mine cmb2.js and put it in the same folder as my file that will enqueue the scripts.

    // Either create a new empty object, or work with the existing one.
    window.MySite_CMB2 = window.MySite_CMB2 || {};
    
    (function( window, document, $, app, undefined ) {
    	'use strict';
    
    	app.cache = function() {
    		app.$ = {};
    		app.$.select = $( document.getElementById( 'my_sites_affiliate' ) );
    		app.$.field = $( document.getElementById( 'my_sites_affiliateurl' ) );
    		app.$.field_container = app.$.field.closest( '.cmb-row');
    	};
    
    	app.init = function() {
    		app.cache();
    		app.$.select.on( 'change', function( event ) {
    			if ( 'url' === $(this).val() ) {
    				app.$.field_container.show();
    			} else {
    				app.$.field_container.hide();
    			}
    		} ).trigger( 'change' );
    	};
    
    	$( document ).ready( app.init );
    })( window, document, jQuery, MySite_CMB2 );
    

    And then call the enqueue, but only when appropriate (because we want to keep the load low):

    add_action( ‘admin_enqueue_scripts’, ‘my_site_admin_enqueue_scripts’ );

    function my_site_admin_enqueue_scripts( ) {
    $screen = get_current_screen();
    if ( ! isset( $screen->post_type ) || ‘post_type_shows’ !== $screen->post_type ) return;

    wp_enqueue_script( 'custom-js', plugins_url( '/cmb2.js' , __FILE__ ), array( 'jquery' ) );
    

    }

    And that works like this:

    GIF of how it shows and hides things

    A Word of Warning

    While all this is great for the sighted people, hiding things is not actually all that great for those who use screen-readers. For that you’d want to toggle the field to be disabled.

  • CMB2, Select2, and Taxonomies

    CMB2, Select2, and Taxonomies

    I’m going to start with “This is not my best work.”

    In using CMB2, I have created situations where it’s smarter to have the ‘normal’ WordPress taxonomy fields changed. Oh sure, they work most of the time for most things, but most is not all. In my situation, I had some custom taxonomies that I did not want people adding to from the post-edit screen.

    To get around the issue, most of the taxonomies were drop-downs using the taxonomy_select field type. That let me control the display and have the drop-down be the terms they could add. Anyone with admin access could add more, of course, but they’d see special notes about that. It gave me control.

    The problem really arose when I had a multicheck list of terms to add. Yes, I had 1 to 20 terms that might be added. And while I could use taxonomy_multicheck to do that, it wasn’t perfect. It made the screen very large.

    Select2 is Better

    Select2 is a jQuery replacement for select boxes. Using it, you can make a simple dropdown where you can have a single (or even multiple) selections, but also it has a nice interface for multiple selections:

    Select2 example: a single and a multicheck

    That looks much nicer than a list or grid of 20 options. You click on the box and you get a dropdown:

    Select2 - Showing the dropdown

    Select2 and CMB2

    Thankfully there’s already a plugin/add-on for this with CMB2. Phil Wylie made cmb-field-select2 which I pulled into my site and it works quite well. Except… You can’t use it to save Taxonomy data properly!

    This is due to a lot of complicated things, and while my first instinct was to complain to myself that core CMB2 could do it, and thus so could everyone, I know it’s not that simple. All the effort CMB2 put into making that work is little short of phenomenal. It was hard and it’s complex and it’s outright weird. I looked at the code and backed away slowly.

    But that doesn’t mean it’s impossible. It’s just a little weird and it’s not my best work. But it does work.

    Show the Taxonomies

    The first step is that you have to make a function to convert the taxonomy to something that can be used in a selection box. Thankfully Phil already did this and his example code works:

    /**
     * Get a list of terms
     *
     * Generic function to return an array of taxonomy terms formatted for CMB2.
     * Simply pass in your get_terms arguments and get back a beautifully formatted
     * CMB2 options array.
     *
     * @param string|array $taxonomies Taxonomy name or list of Taxonomy names
     * @param  array|string $query_args Optional. Array or string of arguments to get terms
     * @return array CMB2 options array
     */
    function iweb_get_cmb_options_array_tax( $taxonomies, $query_args = '' ) {
    	$defaults = array(
    		'hide_empty' => false
    	);
    	$args = wp_parse_args( $query_args, $defaults );
    	$terms = get_terms( $taxonomies, $args );
    	$terms_array = array();
    	if ( ! empty( $terms ) ) {
    		foreach ( $terms as $term ) {
    			$terms_array[$term->term_id] = $term->name;
    		}
    	}
    	return $terms_array;
    }
    

    Next you call that in your CMB2 code:

    // Field: Genre
    $field_genre = $cmb_notes->add_field( array(
    	'name'              => 'Genre',
    	'desc'              => 'Subject matter.',
    	'id'                => 'theshows_genre',
    	'taxonomy'          => 'my_genres',
    	'type'              => 'pw_multiselect',
    	'select_all_button' => false,
    	'remove_default'    => 'true',
    	'options'           => iweb_get_cmb_options_array_tax( 'my_genres' ),
    	'attributes'        => array(
    		'placeholder' => 'What kind of show...'
    ),
    

    And now you can add taxonomy items via Select2. But … It doesn’t save the taxonomy data.

    Saving The Taxonomy Data

    This is the part of code I’m not thrilled about. You see, the code in the previous section adds a new postmeta field for theshows_genre with an array of the IDs added. And that’s it. That isn’t what I wanted. I certainly could use the postmeta data to generate the output, but I used Taxonomies for a reason. They’re incredibly useful.

    In order to save the data, I needed to take the content from the post meta and copy it into the values for saved taxonomies, but only sometimes. After kicking around the options, I decided that I would give priority to the postmeta, not the taxonomies. That would allow me to have them save the taxonomies all the time unless the post meta was empty.

    function select2_taxonomy_process( $post_id, $postmeta, $taxonomy ) {
    
    	$get_post_meta = get_post_meta( $post_id, $postmeta, true );
    	$get_the_terms = get_the_terms( $post_id, $taxonomy );
    
    	if ( is_array( $get_post_meta ) ) {
    		// If we already have the post meta, then we should set the terms
    		$get_post_meta   = array_map( 'intval', $get_post_meta );
    		$get_post_meta   = array_unique( $get_post_meta );
    		$set_the_terms = array();
    
    		foreach( $get_post_meta as $term_id ) {
    			$term = get_term_by( 'id' , $term_id, $taxonomy );
    			array_push( $set_the_terms, $term->slug );
    		}
    
    		wp_set_object_terms( $post_id, $set_the_terms , $taxonomy );
    
    	} elseif ( $get_the_terms && ! is_wp_error( $get_the_terms ) ) {
    		// If there's no post meta, we force the terms to be the default
    		$get_post_meta = array();
    		foreach( $get_the_terms as $term ) {
    			$term_id = $term->term_id;
    			array_push( $get_post_meta, $term_id );
    		}
    		update_post_meta( $post_id, $postmeta, $get_post_meta );
    	}
    
    }
    

    This is not perfect code. It’s not even very good code, I don’t think. I’m not happy that I had to break the terms out instead of just using the the content from $get_post_meta but for some reason, wp_set_object_terms() wasn’t happy with an array of terms. It was fine with the slugs, so that’s the way I went.

    The logic is basic. If there’s postmeta and it’s an array, it ‘wins.’ If it’s not, take the taxonomy data and push it into the term.

    Triggering The Save

    But how to trigger that code? And where and when?

    I wrote code for my custom post type that triggered a check every time the page was loaded. Which is why I don’t like it.

    add_action( 'init', 'select2_taxonomy_save' );
    function select2_taxonomy_save() {
    	// Force saving data to convert select2 saved data to a taxonomy
    	$post_id   = ( isset( $_GET['post'] ) )? $_GET['post'] : 0 ;
    	
    	if ( $post_id !== 0 && is_admin() ) {
    		$post_type = ( isset( $_GET['post_type'] ) )? $_GET['post_type'] : 0 ;
    		switch ( $post_type ) {
    			case 'post_type_shows':
    				LP_CMB2_Addons::select2_taxonomy_save( $post_id, 'theshows_tropes', 'my_tropes' );
    				LP_CMB2_Addons::select2_taxonomy_save( $post_id, 'theshows_tvgenre', 'my_genres' );	
    				break;
    		}
    	}
    }
    

    Obviously it’s not the best code out there. It runs too often, though at least it’s only on page loads.. It would be better if it only ran on save, however that had a problem with race conditions. I would end up with a case where the postmeta might still be blank. So having it run before the page loaded appeared to be my only hope. I also don’t like having the data stored twice, but there was a limit to how far I wanted to run with this.

    Pull requests welcome!

  • CMB2: Repeatable Groups

    CMB2: Repeatable Groups

    This is something that the plugin does out of the box, but my reason for doing it was a little odd.

    Background

    Originally, I had a set of TV characters as a custom post type and each one had their own TV show. Since the TV shows are a second post type, the data was saved as a number and that number was used to generate data on the show pages. Look for everyone who has a TV show value of the same ID as the post ID. Yay!

    The problem with it was spinoffs and crossovers. As time went on, certain characters began to appear on other shows. And it only got worse, until at length there were 30 characters on more than one show, and the number was only growing.

    The quick fix was to make the shows value a repeatable field in CMB2, where I could add multiple shows. Done and done. But then we reached critical mass with how we were handling character roles. Was the character a main, a recurring, or a guest?

    Shows and Roles

    Breaking down the problem to it’s most simple, we have one data set:

    • Show (stored as an ID in an array)
    • Role Type (stored as plain text)

    Instead of saving it as a data set together, the shows were one field (an array, as I mentioned) and the role types were another (a text field).

    In order to make this work, I would have to:

    1. Create a field ‘group’ in CMB2 that stored both show and role as related to that show
    2. Make that group repeatable for characters on multiple shows
    3. Migrate the data

    Data Migration

    There are a lot of ways around this. I ended up with going for the super simple route. I exported two CSVs from my database: one of the shows and one of the role types. Each one had the Post ID associated with it, so I opened those up in a spreadsheet app and combined them, for all cases where the Post ID was the same.

    This gave me a new table that looked like this: 123, 456, regular

    More or less. The ones where shows were arrays looked like, obviously, arrays. I then converted that into a file with 1500 lines that looked like this:

    wp post meta add 6957 character_tvshow_group '[{"show":"6951","type":"regular"},{"show":"7009","type":"regular"}]' --format=json
    

    I could have done it differently, grabbing a file with the data and parsing it on the fly, but I like to look at my 1500 lines and make sure I don’t have weird extra quotes lying around.

    Once that was done, I ran the file, having it execute every line one at a time. It took about one episode of House Hunters: International.

    The CMB2 Code

    In case you’re wondering the code to do this in CMB2 looks like this:

    		// Field Group: Character Show information
    		// Made repeatable since each show might have a separate role. Yikes...
    		$group_shows = $cmb2->add_field( array(
    			'id'          => $prefix . 'show_group',
    			'type'        => 'group',
    			'repeatable'  => true,
    			'options'     => array(
    				'group_title'   => 'Show #{#}',
    				'add_button'    => 'Add Another Show',
    				'remove_button' => 'Remove Show',
    				'sortable' => true,
    			),
    		) );
    		// Field: Show Name
    		$cmb2->add_group_field( $group_shows, array(
    			'name'             => 'TV Show',
    			'id'               => 'show',
    			'type'             => 'select',
    			'show_option_none' => true,
    			'default'          => 'custom',
    			'options_cb'       => array( $this, 'cmb2_get_shows_options'),
    		) );
    		// Field: Character Type
    		$cmb2->add_group_field( $group_shows, array(
    			'name'             => 'Character Type',
    			'id'               => 'type',
    			'type'             => 'select',
    			'show_option_none' => true,
    			'default'          => 'custom',
    			'options'          => $this->character_roles,
    		) );
    

    You’ll notice the options are a bit extra custom.

    Get Shows

    This is done in two parts:

    	public function Sitename_get_post_options( $query_args ) {
    	    $args = wp_parse_args( $query_args, array(
    	        'post_type'   => 'post',
    	        'numberposts' => wp_count_posts( 'post' )->publish,
    	        'post_status' => array('publish'),
    	    ) );
    
    	    $posts = get_posts( $args );
    
    	    $post_options = array();
    	    if ( $posts ) {
    	        foreach ( $posts as $post ) {
    	          $post_options[ $post->ID ] = $post->post_title;
    	        }
    	    }
    
    	    asort($post_options);
    	    return $post_options;
    	}
    
    	public function cmb2_get_shows_options() {
    		return SiteName_get_post_options( array(
    				'post_type'   => 'post_type_shows',
    				'numberposts' => wp_count_posts( 'post_type_shows' )->publish,
    				'post_status' => array('publish', 'pending', 'draft', 'future'),
    			) );
    	}
    

    The reason we search for all shows, from draft to future, is that sometimes we like to schedule updates.

    Character Roles

    		$this->character_roles = array(
    			'regular'   => 'Regular/Main Character',
    			'recurring'	=> 'Recurring Character',
    			'guest'	 	=> 'Guest Character',
    		);
    
  • Damn You, Autocorrect!

    Damn You, Autocorrect!

    After the seventh time I shouted “stop autocorrecting cmb2 fields!” at my site, I knew I had to do something.

    When you run a website where you enter a lot of people’s names, Autocorrect is a curse more than a blessing. Of course I want it on my post content, but when I get to the field where I enter someone’s name, for crying out loud, some names like Nuñez just don’t meet a spell check. And don’t get me started on my friend’s names or my own. I’ve lost track of the number of times I ended up as “Mike.”

    This issue used to only be on phones and tablets. Then Apple introduced autocorrect to their MacOS, which resulted in a lot of tweets followed up by “Damn you, Autocorrect!”

    HTML Attributes

    If you’ve got a form and you want to tell autocorrect to go away, the code looks like this:

    <input autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
    

    For the most part, this will work. You don’t need all of them all of the time, but in my case I was adding names and “Debbie van Houten” was one problem and “Dr. el Farad” was another. I wanted it to just shut up and let me type the name as their parents intended, no matter what. I went whole hog.

    But as I mentioned, I use CMB2 and I needed to stop my site from autocorrecting CMB2 fields. It was time for some code.

    CMB2 Custom HTML Attributes

    This one is so straightforward I was delighted. When you create a new field, you can set arbitrary attributes.

    // Field: Actor Name
    $cmb_characters->add_field( array(
    	'name'				=> 'Actor Name',
    	'desc'				=> 'Include years (in parens) for multiple actors',
    	'id'				=> $prefix . 'actor',
    	'type'				=> 'text',
    	'repeatable'		=> 'true',
    	'attributes'		=> array(
    		'autocomplete'		=> 'off',
    		'autocorrect'		=> 'off',
    		'autocapitalize'	=> 'off',
    		'spellcheck'		=> 'false',
    	),
    ) );
    

    That was all I needed to do in order to get autocorrect to duck itself. Now I was free to write however weird a name I needed without worrying that autocorrect wanted to call me Mike. Again.

    Thanks, autocorrect. Thanks a lot.