Skip to content

feat(taxonomy): add value_field arg to save term IDs - #1571

Closed
faisalahammad wants to merge 5 commits into
CMB2:developfrom
faisalahammad:enhancement/1517-taxonomy-term-id-value
Closed

faisalahammad wants to merge 5 commits into
CMB2:developfrom
faisalahammad:enhancement/1517-taxonomy-term-id-value

Conversation

@faisalahammad

@faisalahammad faisalahammad commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds a value_field field arg to all taxonomy_* field types. Set it to 'term_id' and the field uses term IDs as the option and checkbox values instead of slugs. The default stays 'slug'.

$cmb->add_field( array(
	'name'        => 'Category',
	'id'          => 'my_category',
	'type'        => 'taxonomy_select',
	'taxonomy'    => 'category',
	'value_field' => 'term_id',
) );

Changes:

  • CMB2_Type_Taxonomy_Base gets three helpers: value_field(), get_term_value() and get_term_by_value(). Only 'slug' and 'term_id' are supported. Any other value falls back to slugs and logs a debug message once per field. The select, radio and multicheck types and the default display in CMB2_Field_Display use them, so the hierarchical and inline variants are covered too.
  • In term ID mode on post, user and comment boxes, CMB2_Sanitize::taxonomy() resolves each posted value to a term with get_term_by_value() and passes int term IDs to wp_set_object_terms(). Values that match no term are dropped. Without this, WordPress reads "14" as a term name and creates a new term called "14". A posted slug still sets its term, and '-1' is not read as term ID 1.
  • In term ID mode, only values made of digits are read as term IDs. Other values are still looked up as slugs, so most old slug values keep loading after a field switches to IDs.
  • Slug mode keeps the strict compare in the select, so existing fields render the same as before.
  • Added a CONVENTIONS.md entry (C14) for the above, plus a changelog line and a commented example in example-functions.php.

Motivation and Context

On options pages and term boxes the field value is what gets saved. With slugs, renaming a term slug breaks the saved value, and code reading the option has to look the term up by slug every time. The arg name matches value_field in wp_dropdown_categories().

Fixes #1517.

Risk Level

Low. It is opt-in and the default path is unchanged. One known limit: if a field switches to term_id while it has a saved slug made of only digits (like 2024), that value is read as a term ID. It can select the wrong term or none, and a re-save then stores the wrong term or clears the value. So convert saved slugs to term IDs before you switch an existing options-page or term field. The changelog has an example:

$options = get_option( 'yourprefix_options' );
$term    = get_term_by( 'slug', $options['yourprefix_category'], 'category' );
if ( $term ) {
	$options['yourprefix_category'] = $term->term_id;
	update_option( 'yourprefix_options', $options );
}

Testing procedure

  • PHPUnit in wp-env (PHP 8.3, WP latest): 342 tests, 1116 assertions, all pass. 12 new tests cover select, hierarchical select, radio and multicheck output, saving post terms by ID (no new numeric terms), options page save and render, a legacy slug still loading in term ID mode, defaults by ID in the display, slug mode keeping the strict match, save_fields() round trips for a post box (ID, slug, '-1' and a nested array) and an options page, the all-digit slug limits, and an unsupported value_field falling back to slugs. The term ID tests fail without the fix. The slug mode test is a guard and passes on develop too.
  • composer phpcs is clean. CodeRabbit review has no findings.
  • Manual test on a post box (hierarchical category select and tag multicheck) and an options page with all 8 taxonomy types:
    1. Option values are term IDs, and the options page saves IDs.
    2. Post terms are set by ID, and no terms named after a number are created.
    3. "None" clears the term.
    4. A slug saved before switching to term_id still shows as selected.
    5. Fields without value_field still use slugs.

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist:

Taxonomy fields always used the term slug as the field value. On
options-page and term boxes that slug is what gets saved, so renaming
a term slug breaks the saved value.

Fields can now set 'value_field' => 'term_id' to use term IDs. The
default stays 'slug', so existing fields don't change.

- Option and checkbox values, saved value lookup and default display
  all go through new helpers on CMB2_Type_Taxonomy_Base.
- Posted IDs are cast to int before wp_set_object_terms(), which would
  otherwise create a new term named after the ID.
- In term ID mode a saved slug still resolves, so values saved before
  the switch keep loading.

Fixes CMB2#1517

@jtsternberg jtsternberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.

Review: #1571 (head f4472bf)

Briefing. #1517 asks for taxonomy_select to store term IDs instead of slugs. For post boxes the value is never stored (terms go to wp_set_object_terms()); for options-page and term boxes the value is the stored data (5dee3d9 / #1077). The PR adds a value_field field arg ('slug' default, 'term_id' opt-in), routes value↔term conversion through three helpers on CMB2_Type_Taxonomy_Base, casts posted IDs with absint() before wp_set_object_terms(), and in ID mode falls back to a slug lookup for non-numeric saved values. Trade-off: an all-digit (or scientific-notation) legacy slug is ambiguous with an ID once a field switches modes.

Verdict: REQUEST CHANGES — one data-loss path on post boxes (F2), plus the migration hazard is understated in the PR and untested (F3), and a mechanical C14 collision with #1574 (F5).

Verified: default (slug) mode is unchanged — select keeps its strict ===, radio/multicheck keep their loose compares, the 5dee3d9 options-page/term branch is intact. Full suite on PR head: OK (336 tests, 1097 assertions) (PHP 8.3, WP latest, own wp-env). With includes/ reverted to develop, 7 of the 8 new tests fail (1 error, 6 failures); the slug-mode guard passes on both, as the PR says. No PHP 7.4-incompatible syntax in the diff (by eye; CI matrix is the real gate, C7). REST: get_rest_value() returns raw get_data(), so REST output changes only for opted-in options/term fields (string IDs instead of slugs); post-box taxonomy fields still have no stored value to return. No JS reads taxonomy values. No hook/filter fires a different number of times.

Findings

F1 — Silent fallback for unsupported value_field values · low · nit

includes/types/CMB2_Type_Taxonomy_Base.php:140

The PR cites wp_dropdown_categories()'s value_field as the naming precedent, but core accepts any term field there (term_id, name, slug, …). Here anything other than 'term_id' silently means slug — including the plausible 'id' spelling that get_term_by() itself accepts. Probe test_probe_value_field_id_spelling: ['id' spelling] option values: slugs (silent fallback). Name and shape otherwise fit C8 (declared in the registration array, reads as plain English).

Recommendation: CMB2_Utils::log_if_debug() on an unrecognized value (same pattern taxonomy() uses for a missing taxonomy arg), and say "only 'slug' and 'term_id'" in the docs. Accepting 'id' as an alias is optional.

F2 — ID mode on post boxes wipes the object's terms when a non-numeric value arrives · medium · blocking

includes/CMB2_Sanitize.php:158

array_filter( array_map( 'absint', … ) ) turns any slug into 0, drops it, and hands wp_set_object_terms() an empty array — which removes every term in that taxonomy from the post. The read side does the opposite ("a non-numeric saved value still resolves as a slug"), and the PR's own C14 says value→term conversion goes through get_term_by_value(); this path doesn't. Who sends a slug to an ID-mode post field: REST writes from clients built while the field was in slug mode, $cmb->save_fields() callers, front-end forms pre-filled from old data. Same applies to user/comment boxes (same else branch).

Probe test_probe_post_box_slug_value_in_id_mode (real save_fields() path, post had test_category):

[post slug mode] posted 'number_2' (term2=14); categories after save=[14]
[post term_id mode] posted 'number_2' (term2=14); categories after save=[]

Related nit on the same line: absint() maps -1 to 1. wp_dropdown_categories() defaults option_none_value to -1, so a dev who filters cmb2_taxonomy_select_default_value / cmb2_taxonomy_radio_default_value to -1 (the core convention the arg name points at) gets "None" assigning term ID 1.

Recommendation: resolve each value through get_term_by_value() and pass $term->term_id, dropping only values that resolve to nothing; use (int) with a > 0 filter rather than absint(). Add a test that a slug posted to an ID-mode post field sets that term rather than clearing.

F3 — Switching an existing options/term field to IDs reinterprets numeric slugs; the PR's "re-save fixes it" is wrong · medium · should-fix

includes/types/CMB2_Type_Taxonomy_Base.php:168, PR description "Risk Level", CONVENTIONS.md:430-431

Confirmed in both cases. Numeric slugs are real-world data, not a corner: #477 was filed by a site with terms 0, 1, 2, 3… (it's why the select uses ===).

Probe test_probe_legacy_numeric_slug_names_existing_other_term — saved slug "3" belongs to term 4 (Legacy); term 3 (number_2) exists:

[wrong-id control slug-mode] selected='3'      ← slug mode: Legacy selected
[wrong-id] legacy term ID=4 slug=3; term2 ID=3; selected option value='3'   ← ID mode: number_2 selected
[wrong-id] saved after normal re-save='3' (legacy term ID 4)

The stored string doesn't change, its meaning does: every reader of the option now gets number_2, and a re-save confirms it.

Probe test_probe_legacy_numeric_slug_absent_id — saved slug "987654", no term with that ID:

[absent-id] legacy term ID=7; selected=''; php errors=array ( 0 => 'Attempt to read property "term_id" on false', )
[absent-id] saved after normal re-save=false

Nothing selected, the original selection is erased on save. (The warning is pre-existing: slug mode with a deleted term emits Attempt to read property "slug" on false — test_probe_slug_mode_deleted_term_warning_baseline. Not this PR's to fix, but get_term_value() is a natural place to guard it.)

is_numeric() also widens the collision set: probe test_probe_scientific_notation_slug — term 12 with slug 1e3: get_term_by_value('1e3') => false (looked up as ID 1000).

Recommendation: use ctype_digit( (string) $value ) (or is_int || ctype_digit) instead of is_numeric(). Replace "Re-save fixes it" with accurate guidance: before switching an existing field, convert stored slugs to IDs (a short snippet in the docs/changelog), because the UI cannot recover them. Add the two cases above as tests pinning the known limit so a future change to the fallback is deliberate.

F4 — New tests call CMB2_Sanitize directly; the options-page save path isn't exercised · low · should-fix

tests/test-cmb-types.php:1506, tests/test-cmb-types.php:1532

Both save tests construct CMB2_Sanitize and call taxonomy(), then write the option by hand with cmb2_options()->update(). Nothing goes through CMB2::save_fields() → process_fields() → CMB2_Options, which is where the opt-in actually lands for the boxes the PR targets. The probes above show the real path works for IDs; the suite should too.

Recommendation: replace or add a $cmb->save_fields( $key, 'options-page', array( … ) ) round trip (save ID → render selected → stored value), and the same for a post box (covers F2).

F5 — C14 collides with #1574, and the entry cites a review that didn't happen · low · should-fix

CONVENTIONS.md:415, CONVENTIONS.md:425

Confirmed: #1574 adds ## C14 — \0` and `'0'` are field values, not empties. Whichever merges second must renumber. Also: heading uses :where every other entry uses—; "(both caught in review)" — the PR has no reviews or comments, so this records an unseen review as history. The entry's rule ("never get_term_by( 'slug', ... )directly … goes throughget_term_by_value()`") is contradicted by the PR's own sanitize path (F2).

Recommendation: coordinate numbering with #1574 (merge order decides); use —; drop "(both caught in review)"; after F2, the "Saving" paragraph should describe resolution through get_term_by_value().

F6 — example-functions.php comment and changelog omit the switch hazard · low · nit

example-functions.php:388, CHANGELOG.md:8

"The default stays 'slug', so existing fields are not affected" is true for the default; the risk is for developers who flip an existing field, which is the main audience for #1517. One clause pointing to the migration note (F3) covers it. User-facing field docs on cmb2.io (Field-Types) also need the arg — follow-up for the wiki, not this PR.

Claims from an earlier private review, checked

  • 5dee3d9 branch preserved, slug default, strict select compare in slug mode — confirmed (code read + guard test passes on both develop and PR).
  • Migration hazard: wrong-existing-ID commits wrong selection; absent-ID loses it; "re-save fixes it" misleading — confirmed by probes (F3).
  • is_numeric() accepts 1e3 — confirmed (F3).
  • Duplicate C14 with #1574 — confirmed (F5).
  • Display path — CMB2_Field_Display radio/multicheck only use get_term_by_value() for defaults; post terms come from get_the_terms() objects. Correct; covered by test_taxonomy_value_field_term_id_display_default.
  • REST — no code change; stored shape for opted-in options/term fields becomes string IDs.
  • Non-term object types — value_field does affect the wp_set_object_terms() path (the absint() cast); that's where F2 lives.
  • C8 fit — fits (field arg in the registration array, plain-English value); F1 nit on silent fallback.

PR description claims checked: "336 tests, 1097 assertions, all pass" — reproduced. "Term ID tests fail without the fix; slug-mode test passes on develop" — reproduced. "Re-save fixes it" — refuted (F3). "Default path unchanged" — confirmed.

Fences checked

  • Select strict === (CMB2_Type_Taxonomy_Select::loop_terms) → 85c0813, #477 (numeric slugs 0/1 made "None" and "0" both selected) → kept in slug mode; ID mode uses string-cast strict compare with is_scalar(), which preserves #477's protection (IDs are never 0).
  • options-page/term → store values, not relationships (CMB2_Sanitize::taxonomy) → 5dee3d9, #1077 → kept.
  • wp_set_object_terms( …, $this->value, … ) raw pass-through → 5dee3d9 → changed in ID mode only (F2).
  • get_term_by( 'slug', … ) in options_terms() → 8086310 (2016, non-post objects) → changed to get_term_by_value(); slug mode identical.
  • array_shift( $saved_terms )->slug → 519e809 (2017, hierarchical types) → changed to get_term_value(); slug mode identical.
  • Radio loose $saved_term == $term->slug → 4d11c11 (2014) → kept loose; ID mode compares ints; checked PHP 7.4 '' == 0 coercion — not reachable (no term ID 0, none-option value is '').
  • Multicheck loose in_array( $term->slug, … ) → 4d11c11 → kept loose, same reasoning.
  • Display get_term_by( 'slug', $default, … ) → 974c7cf (2016, field display) → changed to get_term_by_value(); slug mode identical.

Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.

- Resolve each posted value to a term before wp_set_object_terms(),
  so a posted slug sets its term and '-1' is not read as term 1.
- Read only digit-only values as term IDs, not is_numeric().
- Return '' from get_term_value() when no term is found.
- Log an unsupported value_field once per field.
- Add save_fields() tests for post box and options page, and pin
  the all-digit slug limits.
- Update C14, changelog and example with convert-first guidance.

Addresses PR feedback.

Refs CMB2#1571
@faisalahammad

Copy link
Copy Markdown
Contributor Author

Thanks @jtsternberg for the review. All points are fixed in a4e2462. I also merged develop to fix the CHANGELOG conflict.

1. Unsupported value_field falls back silently
It now calls CMB2_Utils::log_if_debug(), once per field, since value_field() runs for every term. The docblock, C14 and the changelog say only 'slug' and 'term_id' are supported. I did not add an 'id' alias.

2. absint() wipes terms when a slug is posted in ID mode
CMB2_Sanitize::taxonomy() now resolves each posted value with get_term_by_value() and passes int term IDs to wp_set_object_terms(). Values that match no term are dropped. So a posted slug sets its term, and '-1' no longer becomes term ID 1. The new post box test covers ID, slug, '-1' and a nested array. It fails without the fix.

3. is_numeric() and the "re-save fixes it" claim
Only digit-only values are read as IDs now (preg_match( '/^\d+$/D' ), so we don't need the ctype extension). '1e3' and '-1' go to the slug lookup. get_term_value() returns '' for a missing term, so there is no more "Attempt to read property on false" warning. A new test pins the known limits: an all-digit slug that matches another term's ID, an all-digit slug with no matching term, and '1e3'. I removed "re-save fixes it" from the PR body, C14 and the changelog. They now say to convert saved slugs to IDs before switching, with a short snippet.

4. Tests call CMB2_Sanitize directly
Added save_fields() round trips for a post box and an options page. The options page test also renders the field and checks the saved ID is selected.

5. C14 wording
The heading uses the em dash like the other entries. "(both caught in review)" is gone, and the Saving paragraph now matches the new code. I kept the number C14 because #1574 is still open. I will renumber if #1574 merges first.

6. Example and changelog miss the switch hazard
The example-functions.php comment and the changelog entry now say to convert saved slugs before switching an existing field.

Tests: 342 tests, 1116 assertions pass in wp-env. composer phpcs is clean.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.10%. Comparing base (c244c31) to head (17681a3).

Additional details and impacted files
@@              Coverage Diff              @@
##             develop    #1571      +/-   ##
=============================================
+ Coverage      72.16%   73.10%   +0.93%     
- Complexity      1911     1927      +16     
=============================================
  Files             53       53              
  Lines           5034     5067      +33     
=============================================
+ Hits            3633     3704      +71     
+ Misses          1401     1363      -38     
Flag Coverage Δ
phpunit 73.10% <100.00%> (+0.93%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Covers the non-array default branch in CMB2_Display_Taxonomy_Multicheck with value_field term_id.
@jtsternberg

Copy link
Copy Markdown
Member

Justin's agent here (Claude Code), on behalf of @jtsternberg.

@faisalahammad, thank you for working through both review rounds. Justin has decided not to add this to core, and here is why.

CMB2's existing hooks can already store term IDs on options-page and term boxes. Those are the only boxes where a taxonomy field stores a value; post, user, and comment boxes set term relationships. We tested two ways against current develop. Both save through save_fields() and render with the right option selected:

  • Store the ID in place of the slug: a sanitization_cb converts the posted slug to an ID. A cmb2_render_class_taxonomy_select filter then swaps in a small subclass that looks the saved term up by ID.
  • Store the ID next to the slug: a cmb2_save_field_{$field_id} callback saves {field_id}_term_id, the same way the file field saves {field_id}_id.

Because the hooks already cover it, a new field arg would mean two ways to do the same thing, and core would maintain it for good. #1517 is a single request with no follow-up, and that isn't enough demand to carry that cost.

Would you be willing to submit this workaround to the CMB2 Snippet Library? Either approach (ID in place of the slug, or the ID stored alongside it), or both. Justin would be happy to merge it there. The drafts we tested are below as a starting point. They were checked on PHP 8.3 only, and only the taxonomy_select subclass is written for the first one; radio and multicheck need the same small subclass.

One trap we found: use the cmb2_render_class_taxonomy_select filter, not the render_class field arg. The taxonomy select renders through the select type, so render_class gets applied to that inner render too and recurses forever.

Draft 1: store the term ID instead of the slug (taxonomy_select, options page)
<?php
/**
 * Store term IDs instead of slugs for a `taxonomy_select` field on an options page (or term box).
 *
 * On `options-page` and `term` boxes, CMB2's taxonomy fields store the selected term slug(s).
 * This keeps the field's UI untouched (the select still posts slugs), converts the posted slug
 * to a term ID on save, and teaches the field to find the saved term by ID when it renders.
 *
 * Only for `options-page` and `term` boxes. On post/user/comment boxes taxonomy fields store
 * nothing; they set the object's terms with `wp_set_object_terms()`, so there is no slug to swap.
 * A custom `sanitization_cb` replaces CMB2's taxonomy sanitizer, which is where those terms get set.
 *
 * Already have slugs saved? Values that aren't all digits keep loading (they're looked up as slugs)
 * and become IDs the next time the page is saved. A saved slug made only of digits is read as an ID.
 *
 * For `taxonomy_radio` / `taxonomy_multicheck`, use the same `sanitization_cb`, hook
 * `cmb2_render_class_taxonomy_radio` / `cmb2_render_class_taxonomy_multicheck`, and extend
 * `CMB2_Type_Taxonomy_Radio` / `CMB2_Type_Taxonomy_Multicheck` instead.
 *
 * @see https://github.com/CMB2/CMB2/issues/1517
 */

/**
 * Hook in and register a metabox to handle a theme options page.
 */
function yourprefix_register_term_id_options_metabox() {
	$cmb = new_cmb2_box( array(
		'id'           => 'yourprefix_term_id_options_page',
		'title'        => esc_html__( 'Theme Options', 'cmb2' ),
		'object_types' => array( 'options-page' ),
		'option_key'   => 'yourprefix_term_id_options',
	) );

	$cmb->add_field( array(
		'name'            => esc_html__( 'Default category', 'cmb2' ),
		'id'              => 'default_category',
		'type'            => 'taxonomy_select',
		'taxonomy'        => 'category',
		'remove_default'  => 'true',
		// Opts the field in to term IDs, on save and (via the render filter below) on render.
		'sanitization_cb' => 'yourprefix_cmb2_taxonomy_slugs_to_term_ids',
	) );
}
add_action( 'cmb2_admin_init', 'yourprefix_register_term_id_options_metabox' );

/**
 * Converts the posted term slug(s) to term ID(s) before CMB2 stores them.
 *
 * @param mixed      $value      The posted value (a slug, or an array of slugs for multicheck).
 * @param array      $field_args The field's arguments.
 * @param CMB2_Field $field      The field object.
 *
 * @return string|array Term ID(s). Slugs that match no term are dropped.
 */
function yourprefix_cmb2_taxonomy_slugs_to_term_ids( $value, $field_args, $field ) {
	$ids = array();
	foreach ( (array) $value as $slug ) {
		$term = is_scalar( $slug ) ? get_term_by( 'slug', (string) $slug, $field->args( 'taxonomy' ) ) : false;
		if ( $term ) {
			$ids[] = (string) $term->term_id;
		}
	}

	if ( is_array( $value ) ) {
		return $ids;
	}

	return $ids ? $ids[0] : '';
}

/**
 * Renders opted-in taxonomy_select fields with the class below.
 *
 * Use this filter, not the `render_class` field arg: taxonomy_select renders its markup through
 * the `select` type, and `render_class` would be picked up there too and recurse forever.
 *
 * @param string     $class The render class.
 * @param CMB2_Types $types The field's types object.
 *
 * @return string
 */
function yourprefix_cmb2_taxonomy_select_term_id_render_class( $class, $types ) {
	return 'yourprefix_cmb2_taxonomy_slugs_to_term_ids' === $types->field->args( 'sanitization_cb' )
		? 'Yourprefix_CMB2_Type_Taxonomy_Select_Term_ID'
		: $class;
}
add_filter( 'cmb2_render_class_taxonomy_select', 'yourprefix_cmb2_taxonomy_select_term_id_render_class', 10, 2 );

if ( class_exists( 'CMB2_Type_Taxonomy_Select' ) ) {

	/**
	 * Looks up the saved term by ID (falling back to slug for values saved before the switch),
	 * so the right option is selected. Everything else is CMB2's own taxonomy_select.
	 */
	class Yourprefix_CMB2_Type_Taxonomy_Select_Term_ID extends CMB2_Type_Taxonomy_Select {

		public function options_terms() {
			if ( empty( $this->field->value ) ) {
				return array();
			}

			$taxonomy = $this->field->args( 'taxonomy' );
			$terms    = array();
			foreach ( (array) $this->field->value as $value ) {
				$terms[] = ctype_digit( (string) $value )
					? get_term_by( 'id', (int) $value, $taxonomy )
					: get_term_by( 'slug', $value, $taxonomy );
			}

			return $terms;
		}
	}
}
Draft 2: save {field_id}_term_id alongside the field
<?php
/**
 * Save the selected term ID(s) next to a taxonomy field, in a `{field_id}_term_id` key.
 *
 * The taxonomy field keeps working exactly as before. After it saves, this stores the
 * term ID(s) in a second key, the same way the `file` field stores `{field_id}_id`:
 *
 * - options-page / term boxes: `{field_id}_term_id` holds the ID of the saved slug.
 * - post / user / comment boxes: `{field_id}_term_id` meta holds the IDs of the object's terms
 *   in that taxonomy (the field itself stores nothing there; it sets the object's terms).
 *
 * Single-term fields (select, radio) store one ID; multicheck fields store an array of IDs.
 * The extra key updates only when the box is saved, so a term deleted elsewhere
 * leaves its ID behind until the next save.
 *
 * Not for taxonomy fields inside a group field.
 *
 * @see https://github.com/CMB2/CMB2/issues/1517
 */

/**
 * Hook in and register a metabox with a taxonomy field that also saves term IDs.
 */
function yourprefix_register_term_id_sidecar_metabox() {
	$cmb = new_cmb2_box( array(
		'id'           => 'yourprefix_term_id_sidecar_options_page',
		'title'        => esc_html__( 'Theme Options', 'cmb2' ),
		'object_types' => array( 'options-page' ),
		'option_key'   => 'yourprefix_term_id_sidecar_options',
	) );

	$cmb->add_field( array(
		'name'           => esc_html__( 'Default category', 'cmb2' ),
		'id'             => 'default_category',
		'type'           => 'taxonomy_select',
		'taxonomy'       => 'category',
		'remove_default' => 'true',
	) );

	yourprefix_cmb2_save_term_id_alongside( 'default_category' );
}
add_action( 'cmb2_admin_init', 'yourprefix_register_term_id_sidecar_metabox' );

/**
 * Saves `{field_id}_term_id` every time the given taxonomy field is saved.
 *
 * @param string $field_id The taxonomy field's id.
 */
function yourprefix_cmb2_save_term_id_alongside( $field_id ) {
	add_action( "cmb2_save_field_{$field_id}", 'yourprefix_cmb2_save_term_id_value', 10, 3 );
}

/**
 * Hooked to `cmb2_save_field_{$field_id}`. Runs after the field saved (or didn't change).
 *
 * @param bool       $updated Whether the field's value changed.
 * @param string     $action  'updated', 'removed', 'repeatable', or ''.
 * @param CMB2_Field $field   The taxonomy field.
 */
function yourprefix_cmb2_save_term_id_value( $updated, $action, $field ) {
	$taxonomy = $field->args( 'taxonomy' );

	if ( in_array( $field->object_type, array( 'options-page', 'term' ), true ) ) {
		$ids = array();
		foreach ( (array) $field->get_data() as $slug ) {
			$term = is_scalar( $slug ) ? get_term_by( 'slug', (string) $slug, $taxonomy ) : false;
			if ( $term ) {
				$ids[] = (string) $term->term_id;
			}
		}
	} else {
		$ids = wp_get_object_terms( $field->object_id, $taxonomy, array( 'fields' => 'ids' ) );
		$ids = is_wp_error( $ids ) ? array() : array_map( 'strval', $ids );
	}

	$single = false === strpos( $field->type(), 'multicheck' );
	$value  = $single ? ( $ids ? $ids[0] : '' ) : $ids;

	// Same as CMB2's own supporting fields (CMB2_Sanitize::_new_supporting_field()):
	// a clone of this field under the new id, with no sanitizing. Empty values remove the key.
	$field->get_field_clone( array(
		'id'              => $field->_id( '', false ) . '_term_id',
		'sanitization_cb' => false,
	) )->save_field( $value );
}

Closing this PR. #1517 stays open so it can point at the snippet once it exists.

Justin's agent here (Claude Code), on behalf of @jtsternberg.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

I want to save the id instead of saving the slug in the taxonomy select field type in CMB2

2 participants