From 5c22ceb0737c777835102c99c31f4f2a8f76a6cb Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Tue, 17 Jun 2025 17:38:12 -0500 Subject: [PATCH 1/8] Basic post type registering --- includes/classes/PostType.php | 100 ++++++++++++++++++++++++++ includes/classes/PostType/Bot.php | 61 ++++++++++++++++ includes/classes/PostTypes.php | 114 ++++++++++++++++++++++++++++++ includes/functions/core.php | 18 +++++ 4 files changed, 293 insertions(+) create mode 100644 includes/classes/PostType.php create mode 100644 includes/classes/PostType/Bot.php create mode 100644 includes/classes/PostTypes.php diff --git a/includes/classes/PostType.php b/includes/classes/PostType.php new file mode 100644 index 00000000..19b3166f --- /dev/null +++ b/includes/classes/PostType.php @@ -0,0 +1,100 @@ +slug = 'ai-bot'; + $this->icon = 'dashicons-nametag'; + $this->order = 20; + + parent::__construct(); + } + + /** + * Setup search functionality. + * + * @return void + */ + public function setup() { + } + + /** + * Sets i18n strings. + * + * @return void + * @since 5.3.0 + */ + public function set_i18n_strings(): void { + $this->name_plural = esc_html__( 'AI Bots', 'elasticpress' ); + $this->name_single = esc_html__( 'AI Bot', 'elasticpress' ); + } + + /** + * Set the `settings_schema` attribute + * + * @since 5.0.0 + */ + protected function set_settings_schema() { + $this->settings_schema = [ + [], + ]; + } +} diff --git a/includes/classes/PostTypes.php b/includes/classes/PostTypes.php new file mode 100644 index 00000000..1e8e5e3f --- /dev/null +++ b/includes/classes/PostTypes.php @@ -0,0 +1,114 @@ +registered_post_types as $post_type_slug => $post_type ) { + $post_type->set_i18n_strings(); + + $name_single = $post_type->name_single ?? ''; + $name_plural = $post_type->name_plural ?? ''; + + register_post_type( + $post_type_slug, + [ + 'labels' => [ + 'name' => $post_type->name_plural, + 'singular_name' => $post_type->name_single, + 'menu_name' => $post_type->name_plural, + 'name_admin_bar' => $post_type->name_single, + 'add_new' => 'Add New', + 'add_new_item' => "Add New $post_type->name_single", + 'new_item' => "New $post_type->name_single", + 'edit_item' => "Edit $post_type->name_single", + 'view_item' => "View $post_type->name_single", + 'all_items' => "All $post_type->name_plural", + 'search_items' => "Search $post_type->name_plural", + 'not_found' => "No $post_type->name_plural found.", + 'not_found_in_trash' => "No $post_type->name_plural found in Trash.", + ], + 'public' => true, + 'has_archive' => true, + 'show_in_rest' => true, + 'supports' => [ 'title', 'editor', 'thumbnail' ], + 'menu_position' => $post_type->order ?? '', + 'menu_icon' => $post_type->icon ?? '', + ] + ); + } + } + + /** + * Registers a Post Type for use in ElasticPress + * + * @param PostType $post_type An instance of the PostType class + * @since 5.3.0 + * @return boolean + */ + public function register_post_type( PostType $post_type ) { + $this->registered_post_types[ $post_type->slug ] = $post_type; + return true; + } + + /** + * Return singleton instance of class + * + * @return object + * @since 2.1 + */ + public static function factory() { + static $instance = false; + + if ( ! $instance ) { + $instance = new self(); + $instance->setup(); + } + + return $instance; + } +} diff --git a/includes/functions/core.php b/includes/functions/core.php index 2befaa21..d42b5d1e 100644 --- a/includes/functions/core.php +++ b/includes/functions/core.php @@ -37,6 +37,8 @@ function setup() { do_action( 'elasticpress_labs_loaded' ); + add_action( 'elasticpress_loaded', $n( 'setup_post_types' ) ); + setup_updater(); } @@ -317,3 +319,19 @@ function ( $plugin_info ) { } ); } + + +/** + * Setup the post types + * + * @since 2.1.1 + * @return void + */ +function setup_post_types() { + /** + * Handle Bots Post Type + */ + \ElasticPressLabs\PostTypes::factory()->register_post_type( + new \ElasticPressLabs\PostType\Bot() + ); +} From f3020aae4e5acb1b1f927b8550cfd285498c7db8 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Tue, 17 Jun 2025 18:02:43 -0500 Subject: [PATCH 2/8] Add option to disable gutenberg --- includes/classes/PostType.php | 8 ++++++++ includes/classes/PostType/Bot.php | 2 ++ includes/classes/PostTypes.php | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/includes/classes/PostType.php b/includes/classes/PostType.php index 19b3166f..ed16a88c 100644 --- a/includes/classes/PostType.php +++ b/includes/classes/PostType.php @@ -58,6 +58,14 @@ abstract class PostType { */ public $name_singular; + /** + * The order in the WordPress admin bar + * + * @var int + * @since 5.3.0 + */ + public $classic_editor_only; + /** * Settings description * diff --git a/includes/classes/PostType/Bot.php b/includes/classes/PostType/Bot.php index 05875ca2..530eac96 100644 --- a/includes/classes/PostType/Bot.php +++ b/includes/classes/PostType/Bot.php @@ -26,6 +26,8 @@ public function __construct() { $this->icon = 'dashicons-nametag'; $this->order = 20; + $this->classic_editor_only = true; + parent::__construct(); } diff --git a/includes/classes/PostTypes.php b/includes/classes/PostTypes.php index 1e8e5e3f..29daaa88 100644 --- a/includes/classes/PostTypes.php +++ b/includes/classes/PostTypes.php @@ -32,6 +32,7 @@ class PostTypes { */ public function setup() { add_action( 'init', array( $this, 'setup_post_types' ), 0 ); + add_filter( 'use_block_editor_for_post_type', array( $this, 'maybe_disable_gutenberg' ), 10, 2 ); } /** @@ -95,6 +96,25 @@ public function register_post_type( PostType $post_type ) { return true; } + /** + * Determines whether to disable the Gutenberg block editor for specific post types. + * + * @since 5.3.0 + * + * @param bool $use_block_editor Whether the block editor is enabled for this post type. + * @param string $current_post_type The post type being checked. + * @return bool False if the block editor should be disabled for the post type, otherwise the original value. + */ + public function maybe_disable_gutenberg( $use_block_editor, $current_post_type ) { + $disabled_post_types = []; + foreach ( $this->registered_post_types as $post_type_slug => $post_type ) { + if ( $post_type->classic_editor_only ) { + $disabled_post_types[] = $post_type_slug; + } + } + return in_array( $current_post_type, $disabled_post_types, true ) ? false : $use_block_editor; + } + /** * Return singleton instance of class * From 3e415a7221d525f2e17b7afadee4ffbc556ae0f8 Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Thu, 19 Jun 2025 10:53:18 -0500 Subject: [PATCH 3/8] Merge 442ba9 --- assets/js/post-types/components/control.js | 160 +++++++++++++++++++++ assets/js/post-types/config.js | 6 + assets/js/post-types/index.js | 74 ++++++++++ assets/js/post-types/style.css | 23 +++ includes/classes/PostType.php | 48 +++++++ includes/classes/PostType/Bot.php | 148 ++++++++++++++++++- includes/classes/PostTypes.php | 125 +++++++++++++++- package.json | 2 +- 8 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 assets/js/post-types/components/control.js create mode 100644 assets/js/post-types/config.js create mode 100644 assets/js/post-types/index.js create mode 100644 assets/js/post-types/style.css diff --git a/assets/js/post-types/components/control.js b/assets/js/post-types/components/control.js new file mode 100644 index 00000000..c13af0b4 --- /dev/null +++ b/assets/js/post-types/components/control.js @@ -0,0 +1,160 @@ +/** + * WordPress dependencies. + */ +import { + CheckboxControl, + FormTokenField, + RadioControl, + SelectControl, + TextControl, + TextareaControl, + ToggleControl, +} from '@wordpress/components'; +import { safeHTML } from '@wordpress/dom'; +import { RawHTML } from '@wordpress/element'; + +const Control = ({ type, settings, value, onChange }) => { + /** + * Handle change to checkbox values. + * + * @param {boolean} checked Whether checkbox is checked. + */ + const onChangeCheckbox = (checked) => { + const value = checked ? '1' : '0'; + + onChange(value); + }; + + /** + * Handle change to token field values. + * + * The FormTokenField control does not support separate values and labels, + * so whenever a change is made we need to set the field value based on the + * selected label. + * + * @param {string[]} values Selected values. + */ + const onChangeFormTokenField = (values) => { + const value = values + .map((v) => settings.options.find((o) => o.label === v)?.value) + .filter(Boolean) + .join(','); + + onChange(value); + }; + + return ( +
+ {(() => { + switch (type) { + case 'checkbox': { + return ( + + ); + } + case 'hidden': { + return null; + } + case 'markup': { + return {safeHTML(settings.label)}; + } + case 'multiple': { + const suggestions = settings.options.map((o) => o.label); + const values = value + .split(',') + .map((v) => settings.options.find((o) => o.value === v)?.label) + .filter(Boolean); + + return ( + + ); + } + case 'radio': { + return ( + + ); + } + case 'select': { + return ( + + ); + } + case 'toggle': { + return ( + + ); + } + case 'textarea': { + return ( + + ); + } + default: { + return ( + + ); + } + } + })()} +
+ ); +}; + +export default Control; diff --git a/assets/js/post-types/config.js b/assets/js/post-types/config.js new file mode 100644 index 00000000..4ea8c001 --- /dev/null +++ b/assets/js/post-types/config.js @@ -0,0 +1,6 @@ +/** + * Window dependencies. + */ +const { activePostType, postTypes, metaFields } = window.epPostTypes; + +export { activePostType, postTypes, metaFields }; diff --git a/assets/js/post-types/index.js b/assets/js/post-types/index.js new file mode 100644 index 00000000..8751b96b --- /dev/null +++ b/assets/js/post-types/index.js @@ -0,0 +1,74 @@ +/** + * WordPress dependencies. + */ +import { createRoot, WPElement, useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies. + */ +import { activePostType, postTypes, metaFields } from './config'; +import Control from './components/control'; + +/** + * Styles. + */ +import './style.css'; +// eslint-disable-next-line import/no-relative-packages +import '../../../../elasticpress/assets/css/dashboard.css'; + +/** + * App component. + * + * @returns {WPElement} App component. + */ +const App = () => { + const [values, setValues] = useState(metaFields); + + const currentPostType = postTypes.find((t) => t.slug === activePostType); + + useEffect(() => { + const form = document.querySelector('form#post'); + const submit = () => { + const nonce = window.epPostTypes?.nonce; + const nonceInput = document.createElement('input'); + nonceInput.type = 'hidden'; + nonceInput.name = 'ep_post_type_nonce'; + nonceInput.value = nonce; + form.appendChild(nonceInput); + + currentPostType.settingsSchema.forEach((settings) => { + const existing = form.querySelector(`input[name="${settings.key}"]`); + if (existing) { + existing.remove(); + } + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = settings.key; + input.value = values[settings.key] || ''; + form.appendChild(input); + }); + }; + form.addEventListener('submit', submit); + return () => form.removeEventListener('submit', submit); + }, [currentPostType.settingsSchema, values]); + + return ( +
+ {currentPostType.settingsSchema.map((schema) => { + return ( + setValues((prev) => ({ ...prev, [schema.key]: val }))} + /> + ); + })} +
+ ); +}; + +const rootEl = document.getElementById('ep-post-types-dashboard'); +if (rootEl) { + createRoot(rootEl).render(); +} diff --git a/assets/js/post-types/style.css b/assets/js/post-types/style.css new file mode 100644 index 00000000..0438d4ef --- /dev/null +++ b/assets/js/post-types/style.css @@ -0,0 +1,23 @@ +#ep-post-types-dashboard { + + & > div { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + } +} + +/* stylelint-disable-next-line selector-id-pattern */ +#custom_fields_app .ep-header-menu { + margin-left: 0; +} + +#poststuff .inside { + margin: 0; + padding: 0; +} + +.postbox-header { + display: none; +} diff --git a/includes/classes/PostType.php b/includes/classes/PostType.php index ed16a88c..39e4b37b 100644 --- a/includes/classes/PostType.php +++ b/includes/classes/PostType.php @@ -105,4 +105,52 @@ public function __construct() { */ public function set_i18n_strings(): void { } + + /** + * Get a JSON representation of the feature + * + * @since 5.3.0 + * @return string + */ + public function get_json() { + $feature_desc = [ + 'slug' => $this->slug, + 'settingsSchema' => $this->get_settings_schema(), + ]; + + return $feature_desc; + } + + /** + * Return the feature settings schema + * + * @since 5.3.0 + * @return array + */ + public function get_settings_schema() { + // Settings were not set yet. + if ( [] === $this->settings_schema ) { + $this->set_settings_schema(); + } + + /** + * Filter the settings schema of a feature + * + * @hook ep_post_type_settings_schema + * @since 5.3.0 + * @param {array} $settings_schema True if the feature is available + * @param {string} $feature_slug Feature slug + * @param {Feature} $feature Feature object + * @return {array} New $settings_schema value + */ + return apply_filters( 'ep_post_type_settings_schema', $this->settings_schema, $this ); + } + + /** + * Sets the settings_schema + * + * @since 5.3.0 + */ + protected function set_settings_schema() { + } } diff --git a/includes/classes/PostType/Bot.php b/includes/classes/PostType/Bot.php index 530eac96..9714651c 100644 --- a/includes/classes/PostType/Bot.php +++ b/includes/classes/PostType/Bot.php @@ -53,11 +53,155 @@ public function set_i18n_strings(): void { /** * Set the `settings_schema` attribute * - * @since 5.0.0 + * @since 5.3.0 */ protected function set_settings_schema() { $this->settings_schema = [ - [], + [ + 'key' => 'search_term_embed_method', + 'label' => __( 'Search Term Embedding Method', 'elasticpress-labs' ), + 'help' => __( 'The method to use to vectorize the search term. The model used here should match the one used to vectorize your content.', 'elasticpress-labs' ), + 'options' => [ + [ + 'label' => __( 'Client side', 'elasticpress-labs' ), + 'value' => 'client-side', + ], + [ + 'label' => __( 'Server side', 'elasticpress-labs' ), + 'value' => 'server-side', + ], + ], + 'type' => 'radio', + ], + [ + 'key' => 'api_key', + 'label' => __( 'OpenAI API Key', 'elasticpress-labs' ), + 'help' => sprintf( + wp_kses( + /* translators: %1$s: OpenAI sign up URL */ + __( 'Don\'t have an OpenAI account yet? Sign up for one in order to get your API key.', 'elasticpress-labs' ), + [ + 'a' => [ + 'href' => [], + 'title' => [], + ], + ] + ), + esc_url( 'https://platform.openai.com/signup' ) + ), + 'type' => 'text', + // 'default' => $this->default_settings['api_key'], + ], + [ + 'key' => 'api_url', + 'help' => __( 'OpenAI Chat Completion API Url', 'elasticpress-labs' ), + 'label' => __( 'OpenAI Chat Completion API Url', 'elasticpress-labs' ), + 'type' => 'text', + // 'default' => $this->default_settings['api_url'], + ], + [ + 'key' => 'chat_model', + 'help' => __( 'OpenAI Chat model', 'elasticpress-labs' ), + 'label' => __( 'The name of the chat model to use', 'elasticpress-labs' ), + 'type' => 'text', + // 'default' => $this->default_settings['chat_model'], + ], + [ + 'key' => 'number_of_posts', + 'label' => __( 'Number of posts', 'elasticpress-labs' ), + 'help' => __( 'Number of posts to be used in the context building', 'elasticpress-labs' ), + 'type' => 'number', + // 'default' => $this->default_settings['number_of_posts'], + ], + [ + 'key' => 'prompt', + 'label' => __( 'AI Prompt', 'elasticpress-labs' ), + 'help' => __( 'The {posts} string will be replaced.', 'elasticpress-labs' ), + 'type' => 'textarea', + // 'default' => $this->default_settings['prompt'], + ], + [ + 'default' => '0', + 'key' => 'trigger_ga_event', + 'help' => __( 'Enable to fire a gtag tracking event when an autosuggest result is clicked.', 'elasticpress' ), + 'label' => __( 'Trigger Google Analytics events', 'elasticpress' ), + 'type' => 'checkbox', + ], + [ + 'key' => 'epio', + 'label' => sprintf( + /* translators: 1: tag (ElasticPress.io); 2. ; 3: tag (KB article); 4. ; 5: tag (Site Health Debug Section); 6. ; */ + __( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ), + '', + '', + '', + '', + '', + '' + ), + 'type' => 'markup', + ], + [ + 'default' => 'post_type,tax-category,tax-post_tag', + 'key' => 'facets', + 'label' => __( 'Filters', 'elasticpress' ), + 'options' => [ + [ + 'label' => 'Post type', + 'value' => 'post_type', + ], + [ + 'label' => 'Category (category)', + 'value' => 'tax-category', + ], + [ + 'label' => 'Tag (post_tag)', + 'value' => 'tax-post_tag', + ], + ], + 'type' => 'multiple', + ], + [ + 'default' => 'mark', + 'help' => __( 'Select the HTML tag used to highlight search terms.', 'elasticpress' ), + 'key' => 'highlight_tag', + 'label' => __( 'Highlight tag', 'elasticpress' ), + 'options' => [ + [ + 'label' => __( 'None', 'elasticpress' ), + 'value' => '', + ], + [ + 'label' => 'mark', + 'value' => 'mark', + ], + [ + 'label' => 'span', + 'value' => 'span', + ], + [ + 'label' => 'strong', + 'value' => 'strong', + ], + [ + 'label' => 'em', + 'value' => 'em', + ], + [ + 'label' => 'i', + 'value' => 'i', + ], + ], + 'type' => 'select', + ], + [ + 'default' => false, + 'key' => 'active', + 'label' => __( 'Enable', 'elasticpress' ), + // 'requires_feature' => $this->requires_feature, + // 'requires_sync' => $this->requires_install_reindex, + 'type' => 'toggle', + ], ]; } } diff --git a/includes/classes/PostTypes.php b/includes/classes/PostTypes.php index 29daaa88..536c81e5 100644 --- a/includes/classes/PostTypes.php +++ b/includes/classes/PostTypes.php @@ -8,6 +8,9 @@ namespace ElasticPressLabs; +use ElasticPress\PostTypes as FeaturesStore; +use ElasticPress\Screen; + if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } @@ -32,7 +35,61 @@ class PostTypes { */ public function setup() { add_action( 'init', array( $this, 'setup_post_types' ), 0 ); + add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] ); add_filter( 'use_block_editor_for_post_type', array( $this, 'maybe_disable_gutenberg' ), 10, 2 ); + add_action( 'add_meta_boxes', array( $this, 'setup_meta_fields' ) ); + add_action( 'init', array( $this, 'register_meta_fields' ) ); + add_action( 'save_post', array( $this, 'save_meta_fields' ) ); + } + + /** + * Enqueue script. + * + * @since 5.3.0 + * @return void + */ + public function admin_enqueue_scripts() { + + if ( ! in_array( get_post_type(), array_keys( $this->registered_post_types ), true ) ) { + return; + } + + wp_enqueue_script( + 'ep_post_types_script', + ELASTICPRESS_LABS_URL . 'dist/js/post-types-script.js', + Utils\get_asset_info( 'post-types-script', 'dependencies' ), + Utils\get_asset_info( 'post-types-script', 'version' ), + true + ); + + wp_set_script_translations( 'ep_post_types_script', 'elasticpress' ); + + wp_enqueue_style( + 'ep_post_types_script', + ELASTICPRESS_LABS_URL . 'dist/css/post-types-script.css', + [ 'wp-components', 'wp-edit-post' ], + Utils\get_asset_info( 'features-script', 'version' ) + ); + + $post_types = $this->registered_post_types; + $post_types = array_map( fn( $post_type ) => $post_type->get_json(), $post_types ); + $post_types = array_values( $post_types ); + + $meta_fields = array_map( + function ( $value ) { + return is_array( $value ) && count( $value ) === 1 ? $value[0] : $value; + }, + get_post_meta( get_the_ID() ) + ); + + $data = [ + 'activePostType' => get_post_type(), + 'postTypes' => $post_types, + 'metaFields' => $meta_fields, + 'nonce' => wp_create_nonce( 'ep_post_type_save' ), + ]; + + wp_localize_script( 'ep_post_types_script', 'epPostTypes', $data ); } /** @@ -76,7 +133,7 @@ public function setup_post_types() { 'public' => true, 'has_archive' => true, 'show_in_rest' => true, - 'supports' => [ 'title', 'editor', 'thumbnail' ], + 'supports' => [ 'title' ], 'menu_position' => $post_type->order ?? '', 'menu_icon' => $post_type->icon ?? '', ] @@ -84,6 +141,72 @@ public function setup_post_types() { } } + /** + * Registers meta fields + */ + public function register_meta_fields() { + foreach ( $this->registered_post_types as $slug => $post_type ) { + foreach ( $post_type->get_settings_schema() as $settings ) { + register_post_meta( + $slug, + $settings['key'], + [ + 'show_in_rest' => true, + 'type' => 'string', + 'single' => true, + 'auth_callback' => '__return_true', + ] + ); + } + } + } + + /** + * Registers a meta box for custom fields rendered by a React app. + * + * @since 5.3.0 + * + * @return void + */ + public function setup_meta_fields() { + add_meta_box( + 'custom_fields_app', + 'Custom Fields (React)', + function ( $post ) { + include EP_PATH . '/includes/partials/header.php'; + echo '
'; + } + ); + } + + /** + * Registers a meta box for custom fields rendered by a React app. + * + * @since 5.3.0 + * @param int $post_id - id of the current post + * + * @return void + */ + public function save_meta_fields( $post_id ) { + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( ! isset( $_POST['ep_post_type_nonce'] ) || ! wp_verify_nonce( $_POST['ep_post_type_nonce'], 'ep_post_type_save' ) ) { + return; + } + $post_type = get_post_type( $post_id ); + if ( isset( $this->registered_post_types[ $post_type ] ) ) { + $settings_schema = $this->registered_post_types[ $post_type ]->get_settings_schema(); + foreach ( $settings_schema as $settings ) { + $key = $settings['key']; + if ( isset( $_POST[ $key ] ) ) { + update_post_meta( $post_id, $key, sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) ); + } + } + } + } + /** * Registers a Post Type for use in ElasticPress * diff --git a/package.json b/package.json index f6b5483e..34d8a009 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "geo-location-editor-script": "./assets/js/geo-location/editor.js", "geo-location-script": "./assets/js/geo-location/index.js", "search-templates-script": "./assets/js/search-templates/index.js", - + "post-types-script": "./assets/js/post-types/index.js", "blocks-styles": "./assets/css/blocks.css" }, "wpDependencyExternals": true From 5e40473b6f31a5a8d056be94f5664d978371663a Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 18 Jun 2025 21:12:59 -0500 Subject: [PATCH 4/8] Add selectable feature settings --- assets/js/post-types/index.js | 57 ++++++++- includes/classes/PostType/Bot.php | 185 ++++++++---------------------- 2 files changed, 101 insertions(+), 141 deletions(-) diff --git a/assets/js/post-types/index.js b/assets/js/post-types/index.js index 8751b96b..c0bf2944 100644 --- a/assets/js/post-types/index.js +++ b/assets/js/post-types/index.js @@ -52,14 +52,69 @@ const App = () => { return () => form.removeEventListener('submit', submit); }, [currentPostType.settingsSchema, values]); + /** + * Determines whether a control should be rendered based on its requirements. + * + * @param {object} requires_fields An object representing the required field values for rendering. + * Can contain 'conditions' object with field requirements and 'relationship' key ('AND' or 'OR'). + * @returns {boolean} Returns `true` if the control should be rendered, otherwise `false`. + */ + const shouldRenderControl = (requires_fields) => { + if (!requires_fields || Object.keys(requires_fields).length === 0) { + return true; + } + + // Get field requirements from 'conditions' key + let fieldRequirements; + + if (requires_fields.conditions) { + fieldRequirements = Object.entries(requires_fields.conditions); + } + + // If no actual field requirements, return true + if (fieldRequirements.length === 0) { + return true; + } + + // Define the condition check function + const checkCondition = ([fieldKey, requiredValue]) => { + const actualValue = values[fieldKey]; + // const defaultValue = defaultSettings[fieldKey] ?? false; + return actualValue === requiredValue; + }; + + // Extract relationship type, default to 'AND' + const relationship = (requires_fields.relationship || 'AND').toUpperCase(); + + // Apply the appropriate logic based on relationship type + switch (relationship) { + case 'OR': + return fieldRequirements.some(checkCondition); + case 'AND': + default: + // Default to AND for any unexpected values + return fieldRequirements.every(checkCondition); + } + }; + return (
{currentPostType.settingsSchema.map((schema) => { + /** + * Skip rendering if the control should not be rendered based on requires_fields. + */ + if (!shouldRenderControl(schema.requires_fields)) { + return null; + } + + const value = + typeof values[schema.key] !== 'undefined' ? values[schema.key] : schema.default; + return ( setValues((prev) => ({ ...prev, [schema.key]: val }))} /> ); diff --git a/includes/classes/PostType/Bot.php b/includes/classes/PostType/Bot.php index 9714651c..cb06979b 100644 --- a/includes/classes/PostType/Bot.php +++ b/includes/classes/PostType/Bot.php @@ -11,6 +11,8 @@ use ElasticPressLabs\PostTypes; use ElasticPressLabs\PostType; +use ElasticPress\Features; + /** * Comments feature class */ @@ -56,152 +58,55 @@ public function set_i18n_strings(): void { * @since 5.3.0 */ protected function set_settings_schema() { - $this->settings_schema = [ - [ - 'key' => 'search_term_embed_method', - 'label' => __( 'Search Term Embedding Method', 'elasticpress-labs' ), - 'help' => __( 'The method to use to vectorize the search term. The model used here should match the one used to vectorize your content.', 'elasticpress-labs' ), - 'options' => [ - [ - 'label' => __( 'Client side', 'elasticpress-labs' ), - 'value' => 'client-side', - ], - [ - 'label' => __( 'Server side', 'elasticpress-labs' ), - 'value' => 'server-side', - ], - ], - 'type' => 'radio', - ], - [ - 'key' => 'api_key', - 'label' => __( 'OpenAI API Key', 'elasticpress-labs' ), - 'help' => sprintf( - wp_kses( - /* translators: %1$s: OpenAI sign up URL */ - __( 'Don\'t have an OpenAI account yet? Sign up for one in order to get your API key.', 'elasticpress-labs' ), - [ - 'a' => [ - 'href' => [], - 'title' => [], - ], - ] - ), - esc_url( 'https://platform.openai.com/signup' ) - ), - 'type' => 'text', - // 'default' => $this->default_settings['api_key'], - ], - [ - 'key' => 'api_url', - 'help' => __( 'OpenAI Chat Completion API Url', 'elasticpress-labs' ), - 'label' => __( 'OpenAI Chat Completion API Url', 'elasticpress-labs' ), - 'type' => 'text', - // 'default' => $this->default_settings['api_url'], - ], - [ - 'key' => 'chat_model', - 'help' => __( 'OpenAI Chat model', 'elasticpress-labs' ), - 'label' => __( 'The name of the chat model to use', 'elasticpress-labs' ), - 'type' => 'text', - // 'default' => $this->default_settings['chat_model'], - ], - [ - 'key' => 'number_of_posts', - 'label' => __( 'Number of posts', 'elasticpress-labs' ), - 'help' => __( 'Number of posts to be used in the context building', 'elasticpress-labs' ), - 'type' => 'number', - // 'default' => $this->default_settings['number_of_posts'], - ], - [ - 'key' => 'prompt', - 'label' => __( 'AI Prompt', 'elasticpress-labs' ), - 'help' => __( 'The {posts} string will be replaced.', 'elasticpress-labs' ), - 'type' => 'textarea', - // 'default' => $this->default_settings['prompt'], - ], - [ - 'default' => '0', - 'key' => 'trigger_ga_event', - 'help' => __( 'Enable to fire a gtag tracking event when an autosuggest result is clicked.', 'elasticpress' ), - 'label' => __( 'Trigger Google Analytics events', 'elasticpress' ), - 'type' => 'checkbox', - ], + $store = Features::factory(); + $features = $store->registered_features; + + $selectable_features = [ 'search', 'instant-results', 'autosuggest', 'did-you-mean', 'facets' ]; + + $options = [ [ - 'key' => 'epio', - 'label' => sprintf( - /* translators: 1: tag (ElasticPress.io); 2. ; 3: tag (KB article); 4. ; 5: tag (Site Health Debug Section); 6. ; */ - __( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ), - '', - '', - '', - '', - '', - '' - ), - 'type' => 'markup', + 'label' => __( 'None', 'elasticpress' ), + 'value' => '', ], - [ - 'default' => 'post_type,tax-category,tax-post_tag', - 'key' => 'facets', - 'label' => __( 'Filters', 'elasticpress' ), - 'options' => [ - [ - 'label' => 'Post type', - 'value' => 'post_type', - ], - [ - 'label' => 'Category (category)', - 'value' => 'tax-category', - ], - [ - 'label' => 'Tag (post_tag)', - 'value' => 'tax-post_tag', + ]; + + foreach ( $selectable_features as $slug ) { + $feature = $features[ $slug ]; + $feature_settings = $feature->get_settings_schema(); + + $options[] = [ + 'label' => $feature->title, + 'value' => $feature->slug, + ]; + } + + $feature_settings_all = []; + foreach ( $selectable_features as $slug ) { + $feature = $features[ $slug ]; + $feature_settings = array_slice( $feature->get_settings_schema(), 1 ); + + foreach ( $feature_settings as &$setting ) { + $setting['requires_fields'] = [ + 'conditions' => [ + 'feature_selection' => $slug, ], - ], - 'type' => 'multiple', - ], + ]; + } + unset( $setting ); + + $feature_settings_all = array_merge( $feature_settings_all, $feature_settings ); + } + + $this->settings_schema = [ [ - 'default' => 'mark', - 'help' => __( 'Select the HTML tag used to highlight search terms.', 'elasticpress' ), - 'key' => 'highlight_tag', - 'label' => __( 'Highlight tag', 'elasticpress' ), - 'options' => [ - [ - 'label' => __( 'None', 'elasticpress' ), - 'value' => '', - ], - [ - 'label' => 'mark', - 'value' => 'mark', - ], - [ - 'label' => 'span', - 'value' => 'span', - ], - [ - 'label' => 'strong', - 'value' => 'strong', - ], - [ - 'label' => 'em', - 'value' => 'em', - ], - [ - 'label' => 'i', - 'value' => 'i', - ], - ], + 'help' => __( 'Select an AI-enabled ElasticPress Feature to override its configuration.', 'elasticpress' ), + 'key' => 'feature_selection', + 'label' => __( 'Feature Selection', 'elasticpress' ), + 'options' => $options, 'type' => 'select', ], - [ - 'default' => false, - 'key' => 'active', - 'label' => __( 'Enable', 'elasticpress' ), - // 'requires_feature' => $this->requires_feature, - // 'requires_sync' => $this->requires_install_reindex, - 'type' => 'toggle', - ], ]; + + $this->settings_schema = array_merge( $this->settings_schema, $feature_settings_all ); } } From 7902b0ddbf4fd30009c391b59ccabb84e680f7fd Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 25 Jun 2025 15:08:20 -0500 Subject: [PATCH 5/8] Meta saving for field groups --- assets/js/post-types/components/control.js | 100 +++++++++++++++- assets/js/post-types/index.js | 132 +++++++++++++++++---- assets/js/post-types/provider.js | 5 + assets/js/post-types/style.css | 11 +- includes/classes/PostType.php | 4 + includes/classes/PostType/Bot.php | 106 +++++++++++++---- includes/classes/PostTypes.php | 65 +++++++--- 7 files changed, 354 insertions(+), 69 deletions(-) create mode 100644 assets/js/post-types/provider.js diff --git a/assets/js/post-types/components/control.js b/assets/js/post-types/components/control.js index c13af0b4..925c774c 100644 --- a/assets/js/post-types/components/control.js +++ b/assets/js/post-types/components/control.js @@ -9,10 +9,18 @@ import { TextControl, TextareaControl, ToggleControl, + Card, + CardHeader, + CardBody, } from '@wordpress/components'; import { safeHTML } from '@wordpress/dom'; import { RawHTML } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { usePostTypeSettings } from '../provider'; + const Control = ({ type, settings, value, onChange }) => { /** * Handle change to checkbox values. @@ -43,6 +51,8 @@ const Control = ({ type, settings, value, onChange }) => { onChange(value); }; + const { values } = usePostTypeSettings(); + return (
{(() => { @@ -51,8 +61,8 @@ const Control = ({ type, settings, value, onChange }) => { return ( { /> ); } + case 'field_group': { + const shouldRenderField = (requires_fields) => { + if (!requires_fields || Object.keys(requires_fields).length === 0) { + return true; + } + + // Get field requirements from 'conditions' key + let fieldRequirements; + + if (requires_fields.conditions) { + fieldRequirements = Object.entries(requires_fields.conditions); + } + + // If no actual field requirements, return true + if (fieldRequirements.length === 0) { + return true; + } + + // Define the condition check function + const checkCondition = ([fieldKey, requiredValue]) => { + const actualValue = values[fieldKey]; + // const defaultValue = defaultSettings[fieldKey] ?? false; + return actualValue === requiredValue; + }; + + // Extract relationship type, default to 'AND' + const relationship = ( + requires_fields.relationship || 'AND' + ).toUpperCase(); + + // Apply the appropriate logic based on relationship type + switch (relationship) { + case 'OR': + return fieldRequirements.some(checkCondition); + case 'AND': + default: + // Default to AND for any unexpected values + return fieldRequirements.every(checkCondition); + } + }; + + return ( +
+ + {settings.label && ( + + {settings.label} + + )} + + {settings.fields.map((field) => { + const shouldRender = shouldRenderField( + field.requires_fields, + ); + + // Skip rendering if the field's requirements aren't met + if (!shouldRender) { + return null; + } + // Get the current value for this field + const fieldValue = value?.[field.key] ?? field.default; + + return ( + { + // Create a new object with the updated field value + const updatedValue = { + ...value, + [field.key]: newValue, + }; + + // Call the parent onChange with the updated nested value + onChange(updatedValue); + }} + /> + ); + })} + + +
+ ); + } default: { return ( { const [values, setValues] = useState(metaFields); - const currentPostType = postTypes.find((t) => t.slug === activePostType); + const contextValue = useMemo(() => ({ values, setValues }), [values]); + useEffect(() => { const form = document.querySelector('form#post'); const submit = () => { @@ -44,13 +46,55 @@ const App = () => { const input = document.createElement('input'); input.type = 'hidden'; input.name = settings.key; - input.value = values[settings.key] || ''; + + if (settings.type === 'field_group') { + input.value = JSON.stringify(values[settings.key] || {}); + } else { + input.value = values[settings.key] || ''; + } + + if ( + input.value.length === 0 && + currentPostType.defaultSettings[settings.key]?.length > 0 + ) { + input.value = currentPostType.defaultSettings[settings.key]; + } + + const defaults = currentPostType?.defaultSettings?.[settings.key]; + + if ( + input.value === '{}' && + defaults && + typeof defaults === 'object' && + Object.keys(defaults).length + ) { + input.value = JSON.stringify(defaults); + } + + if ( + (input.value === '{}' || + input.value === 'undefined' || + input.value.length === 0) && + settings?.type === 'field_group' && + Array.isArray(settings?.fields) && + settings.fields.some((f) => 'default' in f) + ) { + const defaultMeta = settings.fields.reduce((acc, field) => { + if ('default' in field && 'key' in field) { + acc[field.key] = field.default; + } + return acc; + }, {}); + + input.value = JSON.stringify(defaultMeta); + } + form.appendChild(input); }); }; form.addEventListener('submit', submit); return () => form.removeEventListener('submit', submit); - }, [currentPostType.settingsSchema, values]); + }, [currentPostType.settingsSchema, values, currentPostType.defaultSettings]); /** * Determines whether a control should be rendered based on its requirements. @@ -99,26 +143,66 @@ const App = () => { return (
- {currentPostType.settingsSchema.map((schema) => { - /** - * Skip rendering if the control should not be rendered based on requires_fields. - */ - if (!shouldRenderControl(schema.requires_fields)) { - return null; - } - - const value = - typeof values[schema.key] !== 'undefined' ? values[schema.key] : schema.default; - - return ( - setValues((prev) => ({ ...prev, [schema.key]: val }))} - /> - ); - })} + + {currentPostType.settingsSchema.map((schema) => { + /** + * Skip rendering if the control should not be rendered based on requires_fields. + */ + if (!shouldRenderControl(schema.requires_fields)) { + return null; + } + + let value; + if (typeof values[schema.key] !== 'undefined') { + value = values[schema.key]; + // For field_group, if value is a string, parse it + if (schema.type === 'field_group' && typeof value === 'string') { + try { + value = JSON.parse(value); + } catch (e) { + value = {}; + } + } + // For field_group, if value is an object and has keys, use it + // For other types, if value is not empty, use it + if ( + (schema.type === 'field_group' && + value && + typeof value === 'object' && + Object.keys(value).length > 0) || + (schema.type !== 'field_group' && value && value.length > 0) + ) { + // use value as is + } else { + value = + currentPostType.defaultSettings[schema.key] ?? + (schema.type === 'field_group' ? {} : ''); + } + } else { + value = + currentPostType.defaultSettings[schema.key] ?? + (schema.type === 'field_group' ? {} : ''); + } + + return ( + { + if (schema.type === 'field_group') { + setValues((prev) => ({ + ...prev, + [schema.key]: val, + })); + } else { + setValues((prev) => ({ ...prev, [schema.key]: val })); + } + }} + /> + ); + })} +
); }; diff --git a/assets/js/post-types/provider.js b/assets/js/post-types/provider.js new file mode 100644 index 00000000..c7cedb63 --- /dev/null +++ b/assets/js/post-types/provider.js @@ -0,0 +1,5 @@ +import { createContext, useContext } from '@wordpress/element'; + +export const PostTypeContext = createContext(); + +export const usePostTypeSettings = () => useContext(PostTypeContext); diff --git a/assets/js/post-types/style.css b/assets/js/post-types/style.css index 0438d4ef..3aedd8fa 100644 --- a/assets/js/post-types/style.css +++ b/assets/js/post-types/style.css @@ -9,8 +9,15 @@ } /* stylelint-disable-next-line selector-id-pattern */ -#custom_fields_app .ep-header-menu { - margin-left: 0; +#custom_fields_app { + + & #message { + display: none; + } + + & .ep-header-menu { + margin-left: 0; + } } #poststuff .inside { diff --git a/includes/classes/PostType.php b/includes/classes/PostType.php index 39e4b37b..f3b57624 100644 --- a/includes/classes/PostType.php +++ b/includes/classes/PostType.php @@ -118,6 +118,10 @@ public function get_json() { 'settingsSchema' => $this->get_settings_schema(), ]; + if ( property_exists( $this, 'default_settings' ) && ! empty( $this->default_settings ) ) { + $feature_desc['defaultSettings'] = $this->default_settings; + } + return $feature_desc; } diff --git a/includes/classes/PostType/Bot.php b/includes/classes/PostType/Bot.php index cb06979b..c6d28b9d 100644 --- a/includes/classes/PostType/Bot.php +++ b/includes/classes/PostType/Bot.php @@ -30,9 +30,27 @@ public function __construct() { $this->classic_editor_only = true; + $this->overridable_features = [ 'vector_embeddings', 'semantic_search', 'ai_search_summary' ]; + parent::__construct(); } + /** + * Default settings + * + * @var array $default_settings. + */ + public $default_settings = [ + 'user_prompt' => "You are an assistent in a website and you need to reply to a user search. If you do not know the answer, reply saying you could not find any results. Your answer should come formatted in HTML, but not as a full HTML page, just wrap everything in a div with the 'epio-response' class. Also, do not wrap it with ```html``` tags. + +The following JSON object contains the URL and the page content. You should use it as context: + +{posts}", + 'ai_feature_config' => [ + 'highlight_excerpt' => '1', + ], + ]; + /** * Setup search functionality. * @@ -53,24 +71,22 @@ public function set_i18n_strings(): void { } /** - * Set the `settings_schema` attribute + * Pulls each overridable feature's setting schema * * @since 5.3.0 */ - protected function set_settings_schema() { + public function set_overridable_features(): void { $store = Features::factory(); $features = $store->registered_features; - - $selectable_features = [ 'search', 'instant-results', 'autosuggest', 'did-you-mean', 'facets' ]; - - $options = [ + $options = [ [ 'label' => __( 'None', 'elasticpress' ), 'value' => '', ], ]; - foreach ( $selectable_features as $slug ) { + $feature_settings_all = []; + foreach ( $this->overridable_features as $slug ) { $feature = $features[ $slug ]; $feature_settings = $feature->get_settings_schema(); @@ -78,35 +94,79 @@ protected function set_settings_schema() { 'label' => $feature->title, 'value' => $feature->slug, ]; - } - $feature_settings_all = []; - foreach ( $selectable_features as $slug ) { $feature = $features[ $slug ]; $feature_settings = array_slice( $feature->get_settings_schema(), 1 ); - foreach ( $feature_settings as &$setting ) { - $setting['requires_fields'] = [ + $group = [ + 'type' => 'field_group', + 'key' => $feature->slug . '_config', + 'label' => $feature->title, + 'fields' => $feature_settings, + 'requires_fields' => [ 'conditions' => [ 'feature_selection' => $slug, ], - ]; - } - unset( $setting ); + ], + ]; + + $feature_settings_all[] = $group; - $feature_settings_all = array_merge( $feature_settings_all, $feature_settings ); } + $this->settings_schema[] = [ + 'help' => __( 'Select an AI-enabled ElasticPress Feature to override its configuration.', 'elasticpress' ), + 'key' => 'feature_selection', + 'label' => __( 'Feature Selection', 'elasticpress' ), + 'options' => $options, + 'type' => 'select', + ]; + $this->settings_schema = array_merge( $this->settings_schema, $feature_settings_all ); + } + + /** + * Set the `settings_schema` attribute + * + * @since 5.3.0 + */ + protected function set_settings_schema() { $this->settings_schema = [ [ - 'help' => __( 'Select an AI-enabled ElasticPress Feature to override its configuration.', 'elasticpress' ), - 'key' => 'feature_selection', - 'label' => __( 'Feature Selection', 'elasticpress' ), - 'options' => $options, - 'type' => 'select', + 'key' => 'user_prompt', + 'label' => __( 'AI User Prompt', 'elasticpress-labs' ), + 'help' => __( 'The {posts} string will be replaced.', 'elasticpress-labs' ), + 'type' => 'textarea', + 'default' => $this->default_settings['user_prompt'], + ], + [ + 'key' => 'system_prompt', + 'label' => __( 'AI System Prompt', 'elasticpress-labs' ), + 'help' => __( 'The {posts} string will be replaced.', 'elasticpress-labs' ), + 'type' => 'textarea', + 'default' => $this->default_settings['user_prompt'], + ], + [ + 'type' => 'field_group', + 'key' => 'ai_feature_config', + 'label' => __( 'AI Feature Configuration', 'elasticpress' ), + 'fields' => [ + [ + 'default' => '0', + 'help' => __( 'Enable to wrap search terms in HTML tags in results for custom styling. The wrapping HTML tag comes with the ep-highlight class for easy styling.' ), + 'key' => 'highlight_enabled', + 'label' => __( 'Highlight search terms', 'elasticpress' ), + 'type' => 'checkbox', + ], + [ + 'default' => '0', + 'help' => __( 'By default, WordPress strips HTML from content excerpts. Enable when using the_excerpt() to display search results.', 'elasticpress' ), + 'key' => 'highlight_excerpt', + 'label' => __( 'Highlight search terms in excerpts', 'elasticpress' ), + 'type' => 'checkbox', + ], + ], ], ]; - - $this->settings_schema = array_merge( $this->settings_schema, $feature_settings_all ); + $this->set_overridable_features(); } } diff --git a/includes/classes/PostTypes.php b/includes/classes/PostTypes.php index 536c81e5..308586f9 100644 --- a/includes/classes/PostTypes.php +++ b/includes/classes/PostTypes.php @@ -75,12 +75,20 @@ public function admin_enqueue_scripts() { $post_types = array_map( fn( $post_type ) => $post_type->get_json(), $post_types ); $post_types = array_values( $post_types ); - $meta_fields = array_map( - function ( $value ) { - return is_array( $value ) && count( $value ) === 1 ? $value[0] : $value; - }, - get_post_meta( get_the_ID() ) - ); + // Build meta_fields using get_post_meta( get_the_ID(), $key, true ) for each key in the settings schema + $meta_fields = []; + $current_post_type = get_post_type(); + if ( isset( $this->registered_post_types[ $current_post_type ] ) ) { + $settings_schema = $this->registered_post_types[ $current_post_type ]->get_settings_schema(); + foreach ( $settings_schema as $settings ) { + $key = $settings['key']; + $value = get_post_meta( get_the_ID(), $key, true ); + if ( is_string( $value ) && is_serialized( $value ) ) { + $value = maybe_unserialize( $value ); + } + $meta_fields[ $key ] = $value; + } + } $data = [ 'activePostType' => get_post_type(), @@ -147,16 +155,28 @@ public function setup_post_types() { public function register_meta_fields() { foreach ( $this->registered_post_types as $slug => $post_type ) { foreach ( $post_type->get_settings_schema() as $settings ) { - register_post_meta( - $slug, - $settings['key'], - [ - 'show_in_rest' => true, - 'type' => 'string', - 'single' => true, - 'auth_callback' => '__return_true', - ] - ); + if ( 'field_group' === $settings['type'] ) { + register_post_meta( + $slug, + $settings['key'], + [ + 'show_in_rest' => true, + 'type' => 'array', + 'auth_callback' => '__return_true', + ] + ); + } else { + register_post_meta( + $slug, + $settings['key'], + [ + 'show_in_rest' => true, + 'type' => 'string', + 'single' => true, + 'auth_callback' => '__return_true', + ] + ); + } } } } @@ -200,8 +220,17 @@ public function save_meta_fields( $post_id ) { $settings_schema = $this->registered_post_types[ $post_type ]->get_settings_schema(); foreach ( $settings_schema as $settings ) { $key = $settings['key']; - if ( isset( $_POST[ $key ] ) ) { - update_post_meta( $post_id, $key, sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) ); + if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below + if ( 'field_group' === $settings['type'] ) { + $decoded = json_decode( wp_unslash( $_POST[ $key ] ), true ); // phpcs:ignore + if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) { + update_post_meta( $post_id, $key, $decoded ); + } else { + update_post_meta( $post_id, $key, [] ); + } + } else { + update_post_meta( $post_id, $key, sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) ); + } } } } From 0a6c42baad371d1f8d1f3ecd4943d7564e1d5deb Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 25 Jun 2025 17:21:17 -0500 Subject: [PATCH 6/8] Default logic update --- assets/js/post-types/index.js | 88 +++++++------------------------ includes/classes/PostType.php | 1 - includes/classes/PostType/Bot.php | 32 ++--------- 3 files changed, 24 insertions(+), 97 deletions(-) diff --git a/assets/js/post-types/index.js b/assets/js/post-types/index.js index 4257257f..6b057304 100644 --- a/assets/js/post-types/index.js +++ b/assets/js/post-types/index.js @@ -50,43 +50,9 @@ const App = () => { if (settings.type === 'field_group') { input.value = JSON.stringify(values[settings.key] || {}); } else { - input.value = values[settings.key] || ''; - } - - if ( - input.value.length === 0 && - currentPostType.defaultSettings[settings.key]?.length > 0 - ) { - input.value = currentPostType.defaultSettings[settings.key]; - } - - const defaults = currentPostType?.defaultSettings?.[settings.key]; - - if ( - input.value === '{}' && - defaults && - typeof defaults === 'object' && - Object.keys(defaults).length - ) { - input.value = JSON.stringify(defaults); - } - - if ( - (input.value === '{}' || - input.value === 'undefined' || - input.value.length === 0) && - settings?.type === 'field_group' && - Array.isArray(settings?.fields) && - settings.fields.some((f) => 'default' in f) - ) { - const defaultMeta = settings.fields.reduce((acc, field) => { - if ('default' in field && 'key' in field) { - acc[field.key] = field.default; - } - return acc; - }, {}); - - input.value = JSON.stringify(defaultMeta); + const defaultVal = settings.default ?? ''; + input.value = + values[settings.key].length > 0 ? values[settings.key] : defaultVal; } form.appendChild(input); @@ -145,43 +111,27 @@ const App = () => {
{currentPostType.settingsSchema.map((schema) => { - /** - * Skip rendering if the control should not be rendered based on requires_fields. - */ + // Handle conditional rendering if (!shouldRenderControl(schema.requires_fields)) { return null; } - let value; - if (typeof values[schema.key] !== 'undefined') { - value = values[schema.key]; - // For field_group, if value is a string, parse it - if (schema.type === 'field_group' && typeof value === 'string') { - try { - value = JSON.parse(value); - } catch (e) { - value = {}; + // Handle default values + let value = values[schema.key]; + if ( + value.length === 0 && + schema.type === 'field_group' && + schema.fields.some((f) => 'default' in f) + ) { + const defaultValues = schema.fields.reduce((acc, field) => { + if ('default' in field && 'key' in field) { + acc[field.key] = field.default; } - } - // For field_group, if value is an object and has keys, use it - // For other types, if value is not empty, use it - if ( - (schema.type === 'field_group' && - value && - typeof value === 'object' && - Object.keys(value).length > 0) || - (schema.type !== 'field_group' && value && value.length > 0) - ) { - // use value as is - } else { - value = - currentPostType.defaultSettings[schema.key] ?? - (schema.type === 'field_group' ? {} : ''); - } - } else { - value = - currentPostType.defaultSettings[schema.key] ?? - (schema.type === 'field_group' ? {} : ''); + return acc; + }, {}); + value = JSON.stringify(defaultValues); + } else if (value.length === 0 && schema.default?.length > 0) { + value = schema.default; } return ( diff --git a/includes/classes/PostType.php b/includes/classes/PostType.php index f3b57624..668421b2 100644 --- a/includes/classes/PostType.php +++ b/includes/classes/PostType.php @@ -132,7 +132,6 @@ public function get_json() { * @return array */ public function get_settings_schema() { - // Settings were not set yet. if ( [] === $this->settings_schema ) { $this->set_settings_schema(); } diff --git a/includes/classes/PostType/Bot.php b/includes/classes/PostType/Bot.php index c6d28b9d..ade476b6 100644 --- a/includes/classes/PostType/Bot.php +++ b/includes/classes/PostType/Bot.php @@ -41,7 +41,7 @@ public function __construct() { * @var array $default_settings. */ public $default_settings = [ - 'user_prompt' => "You are an assistent in a website and you need to reply to a user search. If you do not know the answer, reply saying you could not find any results. Your answer should come formatted in HTML, but not as a full HTML page, just wrap everything in a div with the 'epio-response' class. Also, do not wrap it with ```html``` tags. + 'user_prompt' => "You are an assistent in a website and you need to reply to a user search. If you do not know the answer, reply saying you could not find any results. Your answer should come formatted in HTML, but not as a full HTML page, just wrap everything in a div with the 'epio-response' class. Also, do not wrap it with ```html``` tags. The following JSON object contains the URL and the page content. You should use it as context: @@ -99,10 +99,10 @@ public function set_overridable_features(): void { $feature_settings = array_slice( $feature->get_settings_schema(), 1 ); $group = [ - 'type' => 'field_group', - 'key' => $feature->slug . '_config', - 'label' => $feature->title, - 'fields' => $feature_settings, + 'type' => 'field_group', + 'key' => $feature->slug . '_config', + 'label' => $feature->title, + 'fields' => $feature_settings, 'requires_fields' => [ 'conditions' => [ 'feature_selection' => $slug, @@ -143,28 +143,6 @@ protected function set_settings_schema() { 'label' => __( 'AI System Prompt', 'elasticpress-labs' ), 'help' => __( 'The {posts} string will be replaced.', 'elasticpress-labs' ), 'type' => 'textarea', - 'default' => $this->default_settings['user_prompt'], - ], - [ - 'type' => 'field_group', - 'key' => 'ai_feature_config', - 'label' => __( 'AI Feature Configuration', 'elasticpress' ), - 'fields' => [ - [ - 'default' => '0', - 'help' => __( 'Enable to wrap search terms in HTML tags in results for custom styling. The wrapping HTML tag comes with the ep-highlight class for easy styling.' ), - 'key' => 'highlight_enabled', - 'label' => __( 'Highlight search terms', 'elasticpress' ), - 'type' => 'checkbox', - ], - [ - 'default' => '0', - 'help' => __( 'By default, WordPress strips HTML from content excerpts. Enable when using the_excerpt() to display search results.', 'elasticpress' ), - 'key' => 'highlight_excerpt', - 'label' => __( 'Highlight search terms in excerpts', 'elasticpress' ), - 'type' => 'checkbox', - ], - ], ], ]; $this->set_overridable_features(); From f0ab17b73779db14a8d0bcbe92e185cc14a06bcf Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 25 Jun 2025 18:50:33 -0500 Subject: [PATCH 7/8] Scope post type to feature being enabled --- includes/classes/Feature/AIBot.php | 84 ++++++++++++++++++++++++++++++ includes/functions/core.php | 14 +++-- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 includes/classes/Feature/AIBot.php diff --git a/includes/classes/Feature/AIBot.php b/includes/classes/Feature/AIBot.php new file mode 100644 index 00000000..d5723c6c --- /dev/null +++ b/includes/classes/Feature/AIBot.php @@ -0,0 +1,84 @@ +slug = 'ai_bot'; + + parent::__construct(); + } + + /** + * Sets i18n strings. + * + * @return void + * @since 2.4.0 + */ + public function set_i18n_strings(): void { + $this->title = esc_html__( 'AI Bots', 'elasticpress-labs' ); + } + + /** + * Setup your feature functionality. + * Use this method to hook your feature functionality to ElasticPress or WordPress. + */ + public function setup() {} + + /** + * Generate the instructions text + * + * @since 2.2.0 + */ + public function get_instructions() { + ob_start(); + ?> +

+
    +
  1. +
  2. +
  3. +
  4. +
+ settings_schema = [ + [ + 'key' => 'epio', + 'label' => $this->get_instructions(), + 'type' => 'markup', + ], + ]; + } +} diff --git a/includes/functions/core.php b/includes/functions/core.php index d42b5d1e..1f14681d 100644 --- a/includes/functions/core.php +++ b/includes/functions/core.php @@ -251,6 +251,9 @@ function maybe_load_features() { $ai_search_summary = new \ElasticPressLabs\Feature\AISearchSummary(); \ElasticPress\Features::factory()->register_feature( $ai_search_summary ); + + $ai_bot = new \ElasticPressLabs\Feature\AIBot(); + \ElasticPress\Features::factory()->register_feature( $ai_bot ); } /** @@ -328,10 +331,15 @@ function ( $plugin_info ) { * @return void */ function setup_post_types() { + $settings = \ElasticPress\Features::factory()->get_feature_settings(); + /** * Handle Bots Post Type */ - \ElasticPressLabs\PostTypes::factory()->register_post_type( - new \ElasticPressLabs\PostType\Bot() - ); + $ai_bot = $settings['ai_bot']; + if ( $ai_bot['active'] ) { + \ElasticPressLabs\PostTypes::factory()->register_post_type( + new \ElasticPressLabs\PostType\Bot() + ); + } } From d05cec708bfc20963f4db278920b58801a752e2f Mon Sep 17 00:00:00 2001 From: Zachary Rener Date: Wed, 25 Jun 2025 18:52:16 -0500 Subject: [PATCH 8/8] Minor change --- includes/classes/Feature/AIBot.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/classes/Feature/AIBot.php b/includes/classes/Feature/AIBot.php index d5723c6c..a3a3e103 100644 --- a/includes/classes/Feature/AIBot.php +++ b/includes/classes/Feature/AIBot.php @@ -1,6 +1,6 @@