Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions assets/js/meta-fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Block Editor Meta Fields Sidebar
*
* Registers a PluginDocumentSettingPanel for each field section defined by
* the PHP class. Field definitions are passed via the `starterPluginMetaFields`
* global object that is localised by Starter_Plugin_Post_Type::enqueue_block_editor_assets().

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

The header comment says data is localized by Starter_Plugin_Post_Type::enqueue_block_editor_assets(), but the code localizes/enqueues it from Starter_Plugin_Post_Type_Meta_Fields::enqueue_block_editor_assets(). Update the comment to match the current implementation to avoid confusion.

Suggested change
* global object that is localised by Starter_Plugin_Post_Type::enqueue_block_editor_assets().
* global object that is localised by Starter_Plugin_Post_Type_Meta_Fields::enqueue_block_editor_assets().

Copilot uses AI. Check for mistakes.
*
* When the classic editor is active for a post type this script is never
* enqueued, so legacy meta boxes remain in use instead.
*
* @package Starter_Plugin
* @since 1.0.0
*/
( function () {
var fieldData = window.starterPluginMetaFields;

if ( ! fieldData || ! fieldData.fields || ! fieldData.fields.length ) {
return;
}

var el = wp.element.createElement;
var Fragment = wp.element.Fragment;
var registerPlugin = wp.plugins.registerPlugin;
var PluginDocumentSettingPanel = wp.editor.PluginDocumentSettingPanel;
var TextControl = wp.components.TextControl;
var useSelect = wp.data.useSelect;
var useDispatch = wp.data.useDispatch;

if ( ! PluginDocumentSettingPanel ) {
return;
}

// Group fields by their declared section key.
var fieldsBySection = {};
fieldData.fields.forEach( function ( field ) {
var section = field.section || 'default';
if ( ! fieldsBySection[ section ] ) {
fieldsBySection[ section ] = [];
}
fieldsBySection[ section ].push( field );
} );

var sectionKeys = Object.keys( fieldsBySection );

/**
* Renders one sidebar panel for each field section.
*
* Meta values are read from and written to the block editor's post entity
* through the `core/editor` data store, so they are saved automatically
* when the editor saves the post.
*/
function MetaFieldsPanels() {
var meta = useSelect( function ( select ) {
return select( 'core/editor' ).getEditedPostAttribute( 'meta' ) || {};
} );

var { editPost } = useDispatch( 'core/editor' );

function handleChange( metaKey, value ) {
var update = {};
update[ metaKey ] = value;
editPost( { meta: update } );

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

editPost( { meta: update } ) replaces the entire meta object rather than merging, which can wipe other meta edits/values when changing a single field. Merge with the current meta object (e.g., editPost({ meta: { ...meta, [metaKey]: value } })) so updates are additive.

Suggested change
editPost( { meta: update } );
editPost( { meta: Object.assign( {}, meta, update ) } );

Copilot uses AI. Check for mistakes.
}

return el(
Fragment,
null,
sectionKeys.map( function ( sectionKey ) {
var sectionLabel =
fieldData.sections && fieldData.sections[ sectionKey ]
? fieldData.sections[ sectionKey ]
: sectionKey;

return el(
PluginDocumentSettingPanel,
{
key: sectionKey,
name: fieldData.postType + '-' + sectionKey,
title: sectionLabel,
className: 'starter-plugin-meta-panel',
},
fieldsBySection[ sectionKey ].map( function ( field ) {
var metaKey = '_' + field.key;
var value =
meta[ metaKey ] !== undefined
? meta[ metaKey ]
: field.default || '';

return el( TextControl, {
key: field.key,
label: field.name,
help: field.description,
value: value,
type: field.type === 'url' ? 'url' : 'text',
onChange: function ( newValue ) {
handleChange( metaKey, newValue );
},
} );
} )
);
} )
);
}

registerPlugin( 'starter-plugin-meta-fields-' + fieldData.postType, {
render: MetaFieldsPanels,
} );
} )();
159 changes: 159 additions & 0 deletions classes/class-starter-plugin-post-type-meta-box.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}

/**
* Starter Plugin Post Type Meta Box Class (Legacy)
*
* Provides the classic-editor meta box fallback for post types that are not
* using the block editor. When Gutenberg is active this class is not
* instantiated.
Comment on lines +10 to +11

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

Docblock says this class “is not instantiated” when Gutenberg is active, but it is instantiated in Starter_Plugin_Post_Type and instead self-disables in setup(). Update the docblock to reflect the actual lifecycle (instantiated in admin, no-op when block editor is active).

Suggested change
* using the block editor. When Gutenberg is active this class is not
* instantiated.
* using the block editor. This class is instantiated in the admin for
* supported post types, but its setup routine is a no-op when the block
* editor (Gutenberg) is active for the post type.

Copilot uses AI. Check for mistakes.
*
* @package WordPress
* @subpackage Starter_Plugin
* @category Plugin
* @author Matty
* @since 1.0.0
*/
class Starter_Plugin_Post_Type_Meta_Box {

/**
* The post type this instance manages a meta box for.
* @access protected
* @since 1.0.0
* @var string
*/
protected $post_type;

/**
* Callable that returns the field definitions array.
* @access protected
* @since 1.0.0
* @var callable
*/
protected $fields_callback;

/**
* Constructor.
*
* @access public
* @since 1.0.0
* @param string $post_type The post type slug.
* @param callable $fields_callback Returns the field definitions.
*/
public function __construct( $post_type, callable $fields_callback ) {
$this->post_type = $post_type;
$this->fields_callback = $fields_callback;

add_action( 'admin_menu', array( $this, 'setup' ), 20 );
add_action( 'save_post', array( $this, 'save' ) );
}

/**
* Register the meta box.
*
* Only runs when the classic editor is active for this post type. This check
* is intentionally deferred to the admin_menu hook so that init has already
* fired and the post type object exists when use_block_editor_for_post_type()
* is called.
*
* @access public
* @since 1.0.0
* @return void
*/
public function setup() {
if ( function_exists( 'use_block_editor_for_post_type' ) && use_block_editor_for_post_type( $this->post_type ) ) {
return;
}

add_meta_box(
$this->post_type . '-data',
__( 'Thing Details', 'starter-plugin' ),
array( $this, 'render' ),
$this->post_type,
'side',
'high'
);
}

/**
* Render the meta box contents.
*
* @access public
* @since 1.0.0
* @return void
*/
public function render() {
global $post_id;
$fields = get_post_custom( $post_id );
$field_data = call_user_func( $this->fields_callback );

$html = '';

$html .= '<input type="hidden" name="starter_plugin_' . $this->post_type . '_noonce" id="starter-plugin_' . $this->post_type . '_noonce" value="' . wp_create_nonce( plugin_basename( dirname( Starter_Plugin()->plugin_path ) ) ) . '" />';

Comment on lines +92 to +95

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

The nonce field is built into $html but never printed, so the hidden input won’t be present in the meta box and save() will always fail the nonce check. Output a nonce field directly (e.g., via wp_nonce_field(...)) or echo $html before rendering inputs; also remove $html if it’s no longer needed.

Suggested change
$html = '';
$html .= '<input type="hidden" name="starter_plugin_' . $this->post_type . '_noonce" id="starter-plugin_' . $this->post_type . '_noonce" value="' . wp_create_nonce( plugin_basename( dirname( Starter_Plugin()->plugin_path ) ) ) . '" />';
wp_nonce_field(
plugin_basename( dirname( Starter_Plugin()->plugin_path ) ),
'starter_plugin_' . $this->post_type . '_noonce'
);

Copilot uses AI. Check for mistakes.
if ( 0 < count( $field_data ) ) :
foreach ( $field_data as $k => $v ) :
$data = $v['default'];
if ( isset( $fields[ '_' . $k ] ) && isset( $fields[ '_' . $k ][0] ) ) {
$data = $fields[ '_' . $k ][0];
}
?>
<p><label for="<?php echo esc_attr( $k ); ?>"><?php echo esc_html( $v['name'] ); ?></label></p>
<p><input name="<?php echo esc_attr( $k ); ?>" type="text" id="<?php echo esc_attr( $k ); ?>" value="<?php echo esc_attr( $data ); ?>" /></p>
<p class="description"><?php echo esc_html( $v['description'] ); ?></p>
<?php
endforeach;
endif;
}

/**
* Save meta box data.
*
* @access public
* @since 1.0.0
* @param int $post_id
* @return int|void
*/
public function save( $post_id ) {
global $post, $messages;

if ( get_post_type() !== $this->post_type ) {
return $post_id;
}

if ( ! isset( $_POST[ 'starter_plugin_' . $this->post_type . '_noonce' ] ) || ! wp_verify_nonce( $_POST[ 'starter_plugin_' . $this->post_type . '_noonce' ], plugin_basename( dirname( Starter_Plugin()->plugin_path ) ) ) ) { // phpcs:ignore
return $post_id;
}

if ( isset( $_POST['post_type'] ) && 'page' === esc_attr( $_POST['post_type'] ) ) { // phpcs:ignore
if ( ! current_user_can( 'edit_page', $post_id ) ) {
return $post_id;
}
} else {
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return $post_id;
}
}

$field_data = call_user_func( $this->fields_callback );
$fields = array_keys( $field_data );

foreach ( $fields as $f ) {
${$f} = wp_strip_all_tags( trim( $_POST[ $f ] ) ); // phpcs:ignore

if ( 'url' === $field_data[ $f ]['type'] ) {
${$f} = esc_url( ${$f} );

Check warning on line 147 in classes/class-starter-plugin-post-type-meta-box.php

View workflow job for this annotation

GitHub Actions / test

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound

Variable variable which could potentially override an imported global variable detected. Global variables defined by a theme/plugin should start with the theme/plugin prefix. Found: "${$f}".
}

if ( '' === get_post_meta( $post_id, '_' . $f ) ) {
add_post_meta( $post_id, '_' . $f, ${$f}, true );
} elseif ( get_post_meta( $post_id, '_' . $f, true ) !== ${$f} ) {
update_post_meta( $post_id, '_' . $f, ${$f} );
} elseif ( '' === ${$f} ) {
delete_post_meta( $post_id, '_' . $f, get_post_meta( $post_id, '_' . $f, true ) );
}
}
}
}
Loading
Loading