diff --git a/assets/js/meta-fields.js b/assets/js/meta-fields.js new file mode 100644 index 0000000..5c48b03 --- /dev/null +++ b/assets/js/meta-fields.js @@ -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(). + * + * 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 } ); + } + + 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, + } ); +} )(); \ No newline at end of file diff --git a/classes/class-starter-plugin-post-type-meta-box.php b/classes/class-starter-plugin-post-type-meta-box.php new file mode 100644 index 0000000..ea39221 --- /dev/null +++ b/classes/class-starter-plugin-post-type-meta-box.php @@ -0,0 +1,159 @@ +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 .= ''; + + 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]; + } + ?> +
+ + + 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} ); + } + + 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 ) ); + } + } + } +} diff --git a/classes/class-starter-plugin-post-type-meta-fields.php b/classes/class-starter-plugin-post-type-meta-fields.php new file mode 100644 index 0000000..d7b1fcd --- /dev/null +++ b/classes/class-starter-plugin-post-type-meta-fields.php @@ -0,0 +1,151 @@ +post_type = $post_type; + $this->fields_callback = $fields_callback; + $this->sections_callback = $sections_callback; + + add_action( 'init', array( $this, 'register' ) ); + + if ( is_admin() ) { + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) ); + } + } + + /** + * Register post meta fields for REST API access and block editor support. + * + * Calls register_post_meta() for each field so that values are readable + * and writable through the REST API and therefore by the block editor sidebar. + * + * @access public + * @since 1.0.0 + * @return void + */ + public function register() { + $fields = call_user_func( $this->fields_callback ); + + foreach ( $fields as $key => $field ) { + $type = isset( $field['type'] ) ? $field['type'] : 'text'; + + register_post_meta( + $this->post_type, + '_' . $key, + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + 'default' => isset( $field['default'] ) ? $field['default'] : '', + 'sanitize_callback' => ( 'url' === $type ) ? 'esc_url_raw' : 'sanitize_text_field', + 'auth_callback' => function ( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ) { + return current_user_can( 'edit_post', $object_id ); + }, + ) + ); + } + } + + /** + * Enqueue block editor assets for the meta fields sidebar panels. + * + * Runs only on the block editor screen for this post type. Enqueues + * meta-fields.js and passes field + section definitions as localised data. + * + * @access public + * @since 1.0.0 + * @return void + */ + public function enqueue_block_editor_assets() { + $screen = get_current_screen(); + if ( ! $screen || $screen->post_type !== $this->post_type ) { + return; + } + + $field_data = call_user_func( $this->fields_callback ); + $sections = call_user_func( $this->sections_callback ); + $fields_json = array(); + + foreach ( $field_data as $key => $field ) { + $fields_json[] = array( + 'key' => $key, + 'name' => isset( $field['name'] ) ? $field['name'] : $key, + 'description' => isset( $field['description'] ) ? $field['description'] : '', + 'type' => isset( $field['type'] ) ? $field['type'] : 'text', + 'default' => isset( $field['default'] ) ? $field['default'] : '', + 'section' => isset( $field['section'] ) ? $field['section'] : 'default', + ); + } + + $handle = 'starter-plugin-' . $this->post_type . '-meta-fields'; + + wp_enqueue_script( + $handle, + plugins_url( '../assets/js/meta-fields.js', __FILE__ ), + array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-components', 'wp-data' ), + Starter_Plugin()->version, + true + ); + + wp_localize_script( + $handle, + 'starterPluginMetaFields', + array( + 'fields' => $fields_json, + 'sections' => $sections, + 'postType' => $this->post_type, + ) + ); + } +} diff --git a/classes/class-starter-plugin-post-type.php b/classes/class-starter-plugin-post-type.php index 585b2e2..a1db079 100644 --- a/classes/class-starter-plugin-post-type.php +++ b/classes/class-starter-plugin-post-type.php @@ -60,11 +60,22 @@ public function __construct( $post_type = 'thing', $singular = '', $plural = '', add_action( 'init', array( $this, 'register_post_type' ) ); + // Delegate meta field registration and block editor sidebar to the dedicated class. + new Starter_Plugin_Post_Type_Meta_Fields( + $post_type, + array( $this, 'get_custom_fields_settings' ), + array( $this, 'get_field_sections' ) + ); + + // Always wire up the meta box class — it guards itself inside setup() once init has run. if ( is_admin() ) { - global $pagenow, $wp_query; + new Starter_Plugin_Post_Type_Meta_Box( + $post_type, + array( $this, 'get_custom_fields_settings' ) + ); + } - add_action( 'admin_menu', array( $this, 'meta_box_setup' ), 20 ); - add_action( 'save_post', array( $this, 'meta_box_save' ) ); + if ( is_admin() ) { add_filter( 'enter_title_here', array( $this, 'enter_title_here' ) ); add_filter( 'post_updated_messages', array( $this, 'updated_messages' ) ); add_filter( 'manage_edit-' . $this->post_type . '_columns', array( $this, 'register_custom_column_headings' ), 10, 1 ); @@ -115,7 +126,7 @@ public function register_post_type () { 'capability_type' => 'post', 'has_archive' => $archive_slug, 'hierarchical' => false, - 'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'page-attributes' ), + 'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'page-attributes', 'custom-fields' ), 'menu_position' => 5, 'menu_icon' => 'dashicons-smiley', ); @@ -224,94 +235,40 @@ public function updated_messages ( $messages ) { } /** - * Setup the meta box. - * @access public + * Whether the block editor is active for this post type. + * + * Returns true when Gutenberg (the block editor) handles editing for this + * post type, false when the classic editor is in use (e.g. the Classic + * Editor plugin is installed and configured to use the old editor). + * + * @access protected * @since 1.0.0 - * @return void + * @return bool */ - public function meta_box_setup () { - add_meta_box( $this->post_type . '-data', __( 'Thing Details', 'starter-plugin' ), array( $this, 'meta_box_content' ), $this->post_type, 'side', 'high' ); - } + protected function is_block_editor_active() { + if ( function_exists( 'use_block_editor_for_post_type' ) ) { + return use_block_editor_for_post_type( $this->post_type ); + } - /** - * The contents of our meta box. - * @access public - * @since 1.0.0 - * @return void - */ - public function meta_box_content () { - global $post_id; - $fields = get_post_custom( $post_id ); - $field_data = $this->get_custom_fields_settings(); - - $html = ''; - - $html .= ''; - - 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]; - } - ?> - - - - 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 ) ) ) ) { - return $post_id; - } - - if ( isset( $_POST['post_type'] ) && 'page' === esc_attr( $_POST['post_type'] ) ) { - 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 = $this->get_custom_fields_settings(); - $fields = array_keys( $field_data ); - - foreach ( $fields as $f ) { - - ${$f} = wp_strip_all_tags( trim( $_POST[ $f ] ) ); - - // Escape the URLs. - if ( 'url' === $field_data[ $f ]['type'] ) { - ${$f} = esc_url( ${$f} ); - } + public function get_field_sections() { + $sections = array( + 'info' => __( 'Details', 'starter-plugin' ), + ); - 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 ) ); - } - } + return apply_filters( 'starter_plugin_field_sections', $sections ); } /** diff --git a/classes/class-starter-plugin.php b/classes/class-starter-plugin.php index 643c297..e2f2eca 100644 --- a/classes/class-starter-plugin.php +++ b/classes/class-starter-plugin.php @@ -106,6 +106,8 @@ public function __construct () { // Admin - End // Post Types - Start + require_once 'class-starter-plugin-post-type-meta-fields.php'; + require_once 'class-starter-plugin-post-type-meta-box.php'; require_once 'class-starter-plugin-post-type.php'; require_once 'class-starter-plugin-taxonomy.php'; diff --git a/tests/test-starter-plugin.php b/tests/test-starter-plugin.php index 7a5fa7c..7de0093 100644 --- a/tests/test-starter-plugin.php +++ b/tests/test-starter-plugin.php @@ -52,4 +52,63 @@ public function test_has_load_plugin_textdomain() { $this->assertTrue( $has_load_plugin_textdomain ); } + + /** + * register_post_meta_fields() should register each field via register_post_meta() + * with show_in_rest enabled so the block editor can read and write the values. + */ + public function test_post_meta_fields_registered_in_rest() { + $post_type_obj = $this->starter_plugin->post_types['thing']; + $post_type_obj->register_post_meta_fields(); + + $registered = get_registered_meta_keys( 'post', 'thing' ); + + $this->assertArrayHasKey( '_url', $registered ); + $this->assertTrue( $registered['_url']['show_in_rest'] ); + } + + /** + * get_field_sections() should return an array that includes the 'info' section. + */ + public function test_get_field_sections_has_info_section() { + $post_type_obj = $this->starter_plugin->post_types['thing']; + $sections = $post_type_obj->get_field_sections(); + + $this->assertIsArray( $sections ); + $this->assertArrayHasKey( 'info', $sections ); + } + + /** + * The 'starter_plugin_field_sections' filter should allow external code + * to add or modify sections. + */ + public function test_get_field_sections_is_filterable() { + add_filter( + 'starter_plugin_field_sections', + function( $sections ) { + $sections['extra'] = 'Extra'; + return $sections; + } + ); + + $post_type_obj = $this->starter_plugin->post_types['thing']; + $sections = $post_type_obj->get_field_sections(); + + $this->assertArrayHasKey( 'extra', $sections ); + + // Clean up. + remove_all_filters( 'starter_plugin_field_sections' ); + } + + /** + * The init action should include a callback for register_post_meta_fields() + * so that meta registration runs at the correct hook. + */ + public function test_register_post_meta_fields_hooked_on_init() { + $post_type_obj = $this->starter_plugin->post_types['thing']; + $priority = has_action( 'init', array( $post_type_obj, 'register_post_meta_fields' ) ); + + $this->assertIsInt( $priority ); + } } +