diff --git a/assets/js/post-types/components/control.js b/assets/js/post-types/components/control.js new file mode 100644 index 00000000..925c774c --- /dev/null +++ b/assets/js/post-types/components/control.js @@ -0,0 +1,256 @@ +/** + * WordPress dependencies. + */ +import { + CheckboxControl, + FormTokenField, + RadioControl, + SelectControl, + 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. + * + * @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); + }; + + const { values } = usePostTypeSettings(); + + 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 ( + + ); + } + 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 ( + + ); + } + } + })()} +
+ ); +}; + +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..6b057304 --- /dev/null +++ b/assets/js/post-types/index.js @@ -0,0 +1,163 @@ +/** + * WordPress dependencies. + */ +import { createRoot, WPElement, useState, useEffect, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies. + */ +import { activePostType, postTypes, metaFields } from './config'; +import Control from './components/control'; +import { PostTypeContext } from './provider'; + +/** + * 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); + + const contextValue = useMemo(() => ({ values, setValues }), [values]); + + 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; + + if (settings.type === 'field_group') { + input.value = JSON.stringify(values[settings.key] || {}); + } else { + const defaultVal = settings.default ?? ''; + input.value = + values[settings.key].length > 0 ? values[settings.key] : defaultVal; + } + + form.appendChild(input); + }); + }; + form.addEventListener('submit', submit); + return () => form.removeEventListener('submit', submit); + }, [currentPostType.settingsSchema, values, currentPostType.defaultSettings]); + + /** + * 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) => { + // Handle conditional rendering + if (!shouldRenderControl(schema.requires_fields)) { + return null; + } + + // 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; + } + return acc; + }, {}); + value = JSON.stringify(defaultValues); + } else if (value.length === 0 && schema.default?.length > 0) { + value = schema.default; + } + + return ( + { + if (schema.type === 'field_group') { + setValues((prev) => ({ + ...prev, + [schema.key]: val, + })); + } else { + 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/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 new file mode 100644 index 00000000..3aedd8fa --- /dev/null +++ b/assets/js/post-types/style.css @@ -0,0 +1,30 @@ +#ep-post-types-dashboard { + + & > div { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + } +} + +/* stylelint-disable-next-line selector-id-pattern */ +#custom_fields_app { + + & #message { + display: none; + } + + & .ep-header-menu { + margin-left: 0; + } +} + +#poststuff .inside { + margin: 0; + padding: 0; +} + +.postbox-header { + display: none; +} diff --git a/includes/classes/Feature/AIBot.php b/includes/classes/Feature/AIBot.php new file mode 100644 index 00000000..a3a3e103 --- /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/classes/PostType.php b/includes/classes/PostType.php new file mode 100644 index 00000000..668421b2 --- /dev/null +++ b/includes/classes/PostType.php @@ -0,0 +1,159 @@ + $this->slug, + 'settingsSchema' => $this->get_settings_schema(), + ]; + + if ( property_exists( $this, 'default_settings' ) && ! empty( $this->default_settings ) ) { + $feature_desc['defaultSettings'] = $this->default_settings; + } + + return $feature_desc; + } + + /** + * Return the feature settings schema + * + * @since 5.3.0 + * @return array + */ + public function get_settings_schema() { + 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 new file mode 100644 index 00000000..ade476b6 --- /dev/null +++ b/includes/classes/PostType/Bot.php @@ -0,0 +1,150 @@ +slug = 'ai-bot'; + $this->icon = 'dashicons-nametag'; + $this->order = 20; + + $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. + * + * @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' ); + } + + /** + * Pulls each overridable feature's setting schema + * + * @since 5.3.0 + */ + public function set_overridable_features(): void { + $store = Features::factory(); + $features = $store->registered_features; + $options = [ + [ + 'label' => __( 'None', 'elasticpress' ), + 'value' => '', + ], + ]; + + $feature_settings_all = []; + foreach ( $this->overridable_features as $slug ) { + $feature = $features[ $slug ]; + $feature_settings = $feature->get_settings_schema(); + + $options[] = [ + 'label' => $feature->title, + 'value' => $feature->slug, + ]; + + $feature = $features[ $slug ]; + $feature_settings = array_slice( $feature->get_settings_schema(), 1 ); + + $group = [ + 'type' => 'field_group', + 'key' => $feature->slug . '_config', + 'label' => $feature->title, + 'fields' => $feature_settings, + 'requires_fields' => [ + 'conditions' => [ + 'feature_selection' => $slug, + ], + ], + ]; + + $feature_settings_all[] = $group; + + } + $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 = [ + [ + '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', + ], + ]; + $this->set_overridable_features(); + } +} diff --git a/includes/classes/PostTypes.php b/includes/classes/PostTypes.php new file mode 100644 index 00000000..308586f9 --- /dev/null +++ b/includes/classes/PostTypes.php @@ -0,0 +1,286 @@ +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 ); + + // 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(), + 'postTypes' => $post_types, + 'metaFields' => $meta_fields, + 'nonce' => wp_create_nonce( 'ep_post_type_save' ), + ]; + + wp_localize_script( 'ep_post_types_script', 'epPostTypes', $data ); + } + + /** + * Set up all active features + * + * @since 5.3.0 + */ + public function setup_post_types() { + /** + * Fires before post types are setup + * + * @hook ep_setup_post_types + * @since 5.3.0 + */ + do_action( 'ep_setup_post_types' ); + + foreach ( $this->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' ], + 'menu_position' => $post_type->order ?? '', + 'menu_icon' => $post_type->icon ?? '', + ] + ); + } + } + + /** + * 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 ) { + 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', + ] + ); + } + } + } + } + + /** + * 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 ] ) ) { // 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 ] ) ) ); + } + } + } + } + } + + /** + * 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; + } + + /** + * 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 + * + * @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..1f14681d 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(); } @@ -249,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 ); } /** @@ -317,3 +322,24 @@ function ( $plugin_info ) { } ); } + + +/** + * Setup the post types + * + * @since 2.1.1 + * @return void + */ +function setup_post_types() { + $settings = \ElasticPress\Features::factory()->get_feature_settings(); + + /** + * Handle Bots Post Type + */ + $ai_bot = $settings['ai_bot']; + if ( $ai_bot['active'] ) { + \ElasticPressLabs\PostTypes::factory()->register_post_type( + new \ElasticPressLabs\PostType\Bot() + ); + } +} 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