From da04ed08a9165d334de67565f4a3544ab7887d77 Mon Sep 17 00:00:00 2001 From: Hein van Vlastuin <94352322+hein-obox@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:25:39 +0200 Subject: [PATCH 01/38] Fix: Console error inside Elementor Connect screen [ED-23041] (#34833) --- .../js/utils/modules/event-dispatcher.js | 25 ++++++------------- .../events-manager/assets/js/module.js | 23 ++++++++++++++--- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/app/modules/onboarding/assets/js/utils/modules/event-dispatcher.js b/app/modules/onboarding/assets/js/utils/modules/event-dispatcher.js index 2ea1b432f3bf..0815a5421cb7 100644 --- a/app/modules/onboarding/assets/js/utils/modules/event-dispatcher.js +++ b/app/modules/onboarding/assets/js/utils/modules/event-dispatcher.js @@ -1,4 +1,3 @@ -import mixpanel from 'mixpanel-browser'; export const ONBOARDING_EVENTS_MAP = { UPGRADE_NOW_S3: 'core_onboarding_s3_upgrade_now', @@ -46,31 +45,23 @@ export function isEventsManagerAvailable() { 'function' === typeof elementorCommon.eventsManager.dispatchEvent; } -function isMixpanelInitialized() { - if ( 'undefined' === typeof mixpanel || ! mixpanel ) { - return false; - } - - try { - const distinctId = mixpanel.get_distinct_id(); - return distinctId !== undefined && distinctId !== null; - } catch ( error ) { - return false; - } -} - export function initializeAndEnableTracking() { if ( ! isEventsManagerAvailable() ) { return; } - if ( ! isMixpanelInitialized() ) { - elementorCommon.eventsManager.initializeMixpanel(); + if ( elementorCommon.eventsManager.trackingEnabled ) { + return; } - if ( ! elementorCommon.eventsManager.trackingEnabled ) { + if ( elementorCommon.eventsManager.isMixpanelReady() ) { elementorCommon.eventsManager.enableTracking(); + return; } + + elementorCommon.eventsManager.initializeMixpanel( + () => elementorCommon.eventsManager.enableTracking(), + ); } export function dispatch( eventName, payload = {} ) { diff --git a/core/common/modules/events-manager/assets/js/module.js b/core/common/modules/events-manager/assets/js/module.js index bdf198db187f..dfea7acd00ed 100644 --- a/core/common/modules/events-manager/assets/js/module.js +++ b/core/common/modules/events-manager/assets/js/module.js @@ -12,11 +12,10 @@ export default class extends elementorModules.Module { return; } - this.initializeMixpanel(); - this.enableTracking(); + this.initializeMixpanel( () => this.enableTracking() ); } - initializeMixpanel() { + initializeMixpanel( onLoaded ) { mixpanel.init( elementorCommon.config.editor_events?.token, { @@ -26,11 +25,16 @@ export default class extends elementorModules.Module { api_hosts: { flags: 'https://api-eu.mixpanel.com', }, + loaded: onLoaded, }, ); } enableTracking() { + if ( ! this.isMixpanelReady() ) { + return; + } + const userId = elementorCommon.config.library_connect?.user_id; if ( userId ) { @@ -132,6 +136,19 @@ export default class extends elementorModules.Module { mixpanel.track( '$experiment_started', { 'Experiment name': experimentName, 'Variant name': experimentVariant } ); } + isMixpanelReady() { + if ( 'undefined' === typeof mixpanel || ! mixpanel ) { + return false; + } + + try { + const distinctId = mixpanel.get_distinct_id(); + return distinctId !== undefined && distinctId !== null; + } catch ( error ) { + return false; + } + } + canSendEvents() { return !! elementorCommon?.config?.editor_events?.can_send_events; } From 3605908ce045f5b5ad43e65e231825d9d19de4b1 Mon Sep 17 00:00:00 2001 From: Mati Horowitz <21468434+matipojo@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:06:55 +0200 Subject: [PATCH 02/38] feat: add Markdown rendering infrastructure for AI crawlers Co-authored-by: Cursor --- core/modules-manager.php | 1 + includes/base/element-base.php | 20 ++ includes/base/widget-base.php | 8 + includes/utils.php | 13 + modules/markdown-render/html-to-markdown.php | 216 +++++++++++++++ modules/markdown-render/markdown-renderer.php | 98 +++++++ modules/markdown-render/module.php | 194 +++++++++++++ .../use-document-view-as-markdown-props.ts | 29 ++ .../src/extensions/documents-save/index.ts | 7 + .../src/sync/__tests__/sync-store.test.ts | 7 +- .../editor-documents/src/sync/sync-store.ts | 5 +- .../core/editor-documents/src/sync/utils.ts | 5 + .../core/editor-documents/src/types.ts | 2 + .../test-utils/create-mock-document-data.ts | 1 + .../tests/test-utils/create-mock-document.ts | 1 + .../markdown-render/test-html-to-markdown.php | 255 ++++++++++++++++++ .../modules/markdown-render/test-module.php | 47 ++++ 17 files changed, 906 insertions(+), 3 deletions(-) create mode 100644 modules/markdown-render/html-to-markdown.php create mode 100644 modules/markdown-render/markdown-renderer.php create mode 100644 modules/markdown-render/module.php create mode 100644 packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts create mode 100644 tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php create mode 100644 tests/phpunit/elementor/modules/markdown-render/test-module.php diff --git a/core/modules-manager.php b/core/modules-manager.php index b652add497db..9b448aed9826 100644 --- a/core/modules-manager.php +++ b/core/modules-manager.php @@ -137,6 +137,7 @@ public function get_modules_names() { 'interactions', 'feedback', 'editor-one', + 'markdown-render', ]; } diff --git a/includes/base/element-base.php b/includes/base/element-base.php index 47a3f1d1bf6f..c31ad725eede 100755 --- a/includes/base/element-base.php +++ b/includes/base/element-base.php @@ -327,6 +327,26 @@ public function get_children() { return $this->children; } + public function render_markdown(): string { + $children = $this->get_children(); + + if ( empty( $children ) ) { + return ''; + } + + $parts = []; + + foreach ( $children as $child ) { + $md = $child->render_markdown(); + + if ( ! empty( trim( $md ) ) ) { + $parts[] = $md; + } + } + + return implode( "\n\n", $parts ); + } + /** * Get default arguments. * diff --git a/includes/base/widget-base.php b/includes/base/widget-base.php index 3ce78dd6baa1..b0e584f8145c 100644 --- a/includes/base/widget-base.php +++ b/includes/base/widget-base.php @@ -697,6 +697,14 @@ public function render_plain_content() { $this->render_content(); } + public function render_markdown(): string { + ob_start(); + $this->render_content(); + $html = ob_get_clean(); + + return wp_strip_all_tags( $html ); + } + /** * Before widget rendering. * diff --git a/includes/utils.php b/includes/utils.php index bfc13de83b7f..c120749c4cfd 100644 --- a/includes/utils.php +++ b/includes/utils.php @@ -973,4 +973,17 @@ public static function decode_string( string $encoded_string, ?string $fallback public static function encode_string( string $decoded_string ): string { return base64_encode( $decoded_string ); } + + public static function html_to_plain_text( string $html ): string { + if ( empty( $html ) ) { + return ''; + } + + $text = preg_replace( '##i', ' ', $html ); + $text = preg_replace( '#]*>#i', ' ', $text ); + $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ); + $text = str_replace( "\xE2\x80\x8B", '', $text ); + + return trim( preg_replace( '/\s+/', ' ', $text ) ); + } } diff --git a/modules/markdown-render/html-to-markdown.php b/modules/markdown-render/html-to-markdown.php new file mode 100644 index 000000000000..d9d60a15489c --- /dev/null +++ b/modules/markdown-render/html-to-markdown.php @@ -0,0 +1,216 @@ +(.*?)#is', '', $html ); + $html = preg_replace( '#(.*?)#is', '', $html ); + $html = str_replace( "\xE2\x80\x8B", '', $html ); + + return $html; + } + + private static function convert_headings( string $html ): string { + for ( $i = 6; $i >= 1; $i-- ) { + $prefix = str_repeat( '#', $i ); + $html = preg_replace_callback( + "#]*>(.*?)#is", + function ( $matches ) use ( $prefix ) { + return "\n\n{$prefix} " . \Elementor\Utils::html_to_plain_text( $matches[1] ) . "\n\n"; + }, + $html + ); + } + + return $html; + } + + private static function convert_bold( string $html ): string { + $html = preg_replace( '#<(?:strong|b)[^>]*>(.*?)#is', '**$1**', $html ); + + return $html; + } + + private static function convert_italic( string $html ): string { + $html = preg_replace( '#<(?:em|i)[^>]*>(.*?)#is', '*$1*', $html ); + + return $html; + } + + private static function convert_links( string $html ): string { + $html = preg_replace_callback( + '#]+href=["\']([^"\']*)["\'][^>]*>(.*?)#is', + function ( $matches ) { + $url = $matches[1]; + $text = $matches[2]; + + if ( empty( $url ) || '#' === $url ) { + return $text; + } + + return '[' . $text . '](' . $url . ')'; + }, + $html + ); + + return $html; + } + + private static function convert_images( string $html ): string { + $html = preg_replace_callback( + '#]+>#is', + function ( $matches ) { + $tag = $matches[0]; + $src = ''; + $alt = ''; + + if ( preg_match( '/src=["\']([^"\']*)["\']/', $tag, $src_match ) ) { + $src = $src_match[1]; + } + + if ( preg_match( '/alt=["\']([^"\']*)["\']/', $tag, $alt_match ) ) { + $alt = $alt_match[1]; + } + + if ( empty( $src ) ) { + return ''; + } + + return '![' . $alt . '](' . $src . ')'; + }, + $html + ); + + return $html; + } + + private static function convert_lists( string $html ): string { + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $items = []; + $counter = 1; + + preg_match_all( '#]*>(.*?)#is', $matches[1], $li_matches ); + + foreach ( $li_matches[1] as $li_content ) { + $items[] = $counter . '. ' . trim( wp_strip_all_tags( $li_content ) ); + $counter++; + } + + return "\n\n" . implode( "\n", $items ) . "\n\n"; + }, + $html + ); + + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $items = []; + + preg_match_all( '#]*>(.*?)#is', $matches[1], $li_matches ); + + foreach ( $li_matches[1] as $li_content ) { + $items[] = '- ' . trim( wp_strip_all_tags( $li_content ) ); + } + + return "\n\n" . implode( "\n", $items ) . "\n\n"; + }, + $html + ); + + return $html; + } + + private static function convert_blockquotes( string $html ): string { + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $content = trim( wp_strip_all_tags( $matches[1] ) ); + $lines = explode( "\n", $content ); + $quoted = array_map( function ( $line ) { + return '> ' . $line; + }, $lines ); + + return "\n\n" . implode( "\n", $quoted ) . "\n\n"; + }, + $html + ); + + return $html; + } + + private static function convert_code( string $html ): string { + $html = preg_replace( '#]*>(.*?)#is', '`$1`', $html ); + + return $html; + } + + private static function convert_pre( string $html ): string { + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $content = html_entity_decode( wp_strip_all_tags( $matches[1] ), ENT_QUOTES, 'UTF-8' ); + + return "\n\n```\n" . $content . "\n```\n\n"; + }, + $html + ); + + return $html; + } + + private static function convert_line_breaks( string $html ): string { + $html = preg_replace( '##i', "\n", $html ); + + return $html; + } + + private static function convert_paragraphs( string $html ): string { + $html = preg_replace( '#]*>#i', "\n\n", $html ); + $html = preg_replace( '#

#i', "\n\n", $html ); + + return $html; + } + + private static function convert_horizontal_rules( string $html ): string { + $html = preg_replace( '##i', "\n\n---\n\n", $html ); + + return $html; + } +} diff --git a/modules/markdown-render/markdown-renderer.php b/modules/markdown-render/markdown-renderer.php new file mode 100644 index 000000000000..c05461ada4cd --- /dev/null +++ b/modules/markdown-render/markdown-renderer.php @@ -0,0 +1,98 @@ +build_frontmatter( $document ); + $data = $document->get_elements_data(); + + if ( empty( $data ) ) { + return $frontmatter; + } + + $sections = []; + + foreach ( $data as $element_data ) { + $md = $this->render_element( $element_data ); + + if ( ! empty( trim( $md ) ) ) { + $sections[] = $md; + } + } + + $body = implode( "\n\n---\n\n", $sections ); + + $output = $frontmatter . "\n\n" . $body; + + return apply_filters( 'elementor/markdown/document_output', $output, $document ); + } + + private function build_frontmatter( Document $document ): string { + $post_id = $document->get_main_id(); + + $lines = [ '---' ]; + $lines[] = 'title: "' . $this->escape_yaml_string( get_the_title( $post_id ) ) . '"'; + + $description = $this->get_meta_description( $post_id ); + + if ( $description ) { + $lines[] = 'description: "' . $this->escape_yaml_string( $description ) . '"'; + } + + $thumbnail = get_the_post_thumbnail_url( $post_id, 'full' ); + + if ( $thumbnail ) { + $lines[] = 'featured_image: "' . esc_url( $thumbnail ) . '"'; + } + + $lines[] = 'url: "' . get_permalink( $post_id ) . '"'; + $lines[] = 'date_modified: "' . get_the_modified_date( 'c', $post_id ) . '"'; + $lines[] = '---'; + + return implode( "\n", $lines ); + } + + private function get_meta_description( int $post_id ): string { + $description = get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ); + + if ( ! empty( $description ) ) { + return $description; + } + + $description = get_post_meta( $post_id, '_aioseo_description', true ); + + if ( ! empty( $description ) ) { + return $description; + } + + $excerpt = get_the_excerpt( $post_id ); + + return $excerpt ?: ''; + } + + private function render_element( array $element_data ): string { + $element = Plugin::$instance->elements_manager->create_element_instance( $element_data ); + + if ( ! $element ) { + return ''; + } + + $markdown = $element->render_markdown(); + + return apply_filters( 'elementor/markdown/element_output', $markdown, $element, $element_data ); + } + + private function escape_yaml_string( string $value ): string { + $value = str_replace( "\xE2\x80\x8B", '', $value ); + + return str_replace( [ '"', "\n", "\r" ], [ '\\"', ' ', '' ], $value ); + } +} diff --git a/modules/markdown-render/module.php b/modules/markdown-render/module.php new file mode 100644 index 000000000000..e68bab8ca626 --- /dev/null +++ b/modules/markdown-render/module.php @@ -0,0 +1,194 @@ + self::EXPERIMENT_NAME, + 'title' => esc_html__( 'Markdown Rendering', 'elementor' ), + 'description' => esc_html__( 'Serve page content as Markdown when AI crawlers request it via Accept: text/markdown header.', 'elementor' ), + 'default' => Experiments_Manager::STATE_INACTIVE, + 'release_status' => Experiments_Manager::RELEASE_STATUS_ALPHA, + ]; + } + + public function __construct() { + parent::__construct(); + + add_action( 'template_redirect', [ $this, 'maybe_serve_markdown' ], 1 ); + + add_action( 'elementor/core/files/clear_cache', [ $this, 'clear_all_markdown_cache' ] ); + add_action( 'save_post', [ $this, 'clear_post_markdown_cache' ] ); + add_action( 'activated_plugin', [ $this, 'clear_all_markdown_cache' ] ); + add_action( 'deactivated_plugin', [ $this, 'clear_all_markdown_cache' ] ); + add_action( 'switch_theme', [ $this, 'clear_all_markdown_cache' ] ); + + if ( is_admin() ) { + add_action( + 'elementor/admin/after_create_settings/' . Settings::PAGE_ID, + [ $this, 'register_admin_fields' ], + 100 + ); + } + } + + public function maybe_serve_markdown() { + if ( ! $this->is_markdown_request() ) { + return; + } + + if ( ! is_singular() ) { + return; + } + + $post_id = get_the_ID(); + $post = get_post( $post_id ); + + if ( ! $post ) { + return; + } + + $is_preview = $this->is_valid_preview_request( $post_id ); + + if ( ! $is_preview && 'publish' !== $post->post_status ) { + return; + } + + if ( post_password_required( $post ) ) { + return; + } + + $document = $is_preview + ? Plugin::$instance->documents->get_doc_for_frontend( $post_id ) + : Plugin::$instance->documents->get( $post_id ); + + if ( ! $document || ! $document->is_built_with_elementor() ) { + return; + } + + if ( $is_preview ) { + $markdown = ( new Markdown_Renderer() )->render( $document ); + } else { + $markdown = $this->get_cached_markdown( $post_id ); + + if ( false === $markdown ) { + $markdown = ( new Markdown_Renderer() )->render( $document ); + $this->set_cached_markdown( $post_id, $markdown ); + } + } + + nocache_headers(); + status_header( 200 ); + header( 'Content-Type: text/markdown; charset=utf-8' ); + header( 'X-Content-Type-Options: nosniff' ); + echo $markdown; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } + + private function is_valid_preview_request( int $post_id ): bool { + if ( ! is_preview() ) { + return false; + } + + $preview_id = (int) ( $_GET['preview_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $preview_nonce = sanitize_text_field( wp_unslash( $_GET['preview_nonce'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( ! $preview_id || ! wp_verify_nonce( $preview_nonce, 'post_preview_' . $preview_id ) ) { + return false; + } + + return current_user_can( 'edit_post', $post_id ); + } + + private function is_markdown_request(): bool { + if ( isset( $_GET['format'] ) && 'markdown' === $_GET['format'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return true; + } + + $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) : ''; + + return false !== strpos( $accept, 'text/markdown' ); + } + + private function get_cached_markdown( int $post_id ) { + $cache = get_post_meta( $post_id, self::CACHE_META_KEY, true ); + + if ( empty( $cache ) || ! is_array( $cache ) ) { + return false; + } + + if ( empty( $cache['timeout'] ) || time() > $cache['timeout'] ) { + return false; + } + + return $cache['content'] ?? false; + } + + private function set_cached_markdown( int $post_id, string $markdown ): void { + $ttl_hours = (int) get_option( 'elementor_markdown_cache_ttl', 24 ); + + if ( $ttl_hours <= 0 ) { + return; + } + + $cache = [ + 'timeout' => time() + ( $ttl_hours * HOUR_IN_SECONDS ), + 'content' => $markdown, + ]; + + update_post_meta( $post_id, self::CACHE_META_KEY, $cache ); + } + + public function clear_post_markdown_cache( int $post_id ): void { + delete_post_meta( $post_id, self::CACHE_META_KEY ); + } + + public function clear_all_markdown_cache(): void { + global $wpdb; + $wpdb->delete( $wpdb->postmeta, [ 'meta_key' => self::CACHE_META_KEY ] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + } + + public function register_admin_fields( Settings $settings ) { + $settings->add_field( + Settings::TAB_PERFORMANCE, + Settings::TAB_PERFORMANCE, + 'markdown_cache_ttl', + [ + 'label' => esc_html__( 'Markdown Cache', 'elementor' ), + 'field_args' => [ + 'class' => 'elementor-markdown-cache-ttl', + 'type' => 'select', + 'std' => '24', + 'options' => [ + '0' => esc_html__( 'Disable', 'elementor' ), + '1' => esc_html__( '1 Hour', 'elementor' ), + '6' => esc_html__( '6 Hours', 'elementor' ), + '12' => esc_html__( '12 Hours', 'elementor' ), + '24' => esc_html__( '1 Day', 'elementor' ), + '72' => esc_html__( '3 Days', 'elementor' ), + '168' => esc_html__( '1 Week', 'elementor' ), + '720' => esc_html__( '1 Month', 'elementor' ), + ], + 'desc' => esc_html__( 'Specify the duration for which Markdown output is cached. This cache is served to AI crawlers requesting text/markdown content.', 'elementor' ), + ], + ] + ); + } +} diff --git a/packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts b/packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts new file mode 100644 index 000000000000..363eb7601b34 --- /dev/null +++ b/packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts @@ -0,0 +1,29 @@ +import { __useActiveDocument as useActiveDocument } from '@elementor/editor-documents'; +import { __privateRunCommand as runCommand } from '@elementor/editor-v1-adapters'; +import { EyeIcon } from '@elementor/icons'; +import { __ } from '@wordpress/i18n'; + +export default function useDocumentViewAsMarkdownProps() { + const document = useActiveDocument(); + + return { + icon: EyeIcon, + title: __( 'View as Markdown', 'elementor' ), + onClick: async () => { + const baseUrl = document?.links?.wpPreview || document?.links?.permalink; + + if ( ! baseUrl ) { + return; + } + + if ( document?.isDirty ) { + await runCommand( 'document/save/auto', { force: true } ); + } + + const separator = baseUrl.includes( '?' ) ? '&' : '?'; + const url = baseUrl + separator + 'format=markdown'; + + window.open( url, '_blank' ); + }, + }; +} diff --git a/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts b/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts index 71fad433c796..4403c6f49094 100644 --- a/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts +++ b/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts @@ -3,6 +3,7 @@ import PrimaryAction from './components/primary-action'; import useDocumentCopyAndShareProps from './hooks/use-document-copy-and-share-props'; import useDocumentSaveDraftProps from './hooks/use-document-save-draft-props'; import useDocumentSaveTemplateProps from './hooks/use-document-save-template-props'; +import useDocumentViewAsMarkdownProps from './hooks/use-document-view-as-markdown-props'; import useDocumentViewPageProps from './hooks/use-document-view-page-props'; import { documentOptionsMenu } from './locations'; @@ -37,4 +38,10 @@ export function init() { priority: 50, useProps: useDocumentViewPageProps, } ); + + documentOptionsMenu.registerAction( { + id: 'document-view-as-markdown', + priority: 60, + useProps: useDocumentViewAsMarkdownProps, + } ); } diff --git a/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts b/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts index 7d76824c8b86..c9d61114c775 100644 --- a/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts +++ b/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts @@ -5,7 +5,7 @@ import { slice } from '../../store'; import { selectActiveDocument } from '../../store/selectors'; import { type Document, type ExitTo, type ExtendedWindow, type V1Document, type V1DocumentsManager } from '../../types'; import { syncStore } from '../index'; -import { getV1DocumentPermalink, getV1DocumentsExitTo } from '../utils'; + import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentWpPreview } from '../utils'; import { makeDocumentsManager } from './test-utils'; type WindowWithOptionalElementor = Omit< ExtendedWindow, 'elementor' > & { @@ -56,6 +56,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit: 'https://localhost/wp-admin/post.php?post=1&action=edit', permalink: 'https://localhost/?p=1', + wpPreview: 'https://localhost/?p=1&preview_id=1&preview_nonce=mock_nonce&preview=true', }, isDirty: false, isSaving: false, @@ -82,6 +83,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit: 'https://localhost/wp-admin/post.php?post=2&action=edit', permalink: 'https://localhost/?p=2', + wpPreview: 'https://localhost/?p=2&preview_id=2&preview_nonce=mock_nonce&preview=true', }, isDirty: false, isSaving: false, @@ -130,6 +132,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit: 'https://localhost/wp-admin/post.php?post=2&action=edit', permalink: 'https://localhost/?p=2', + wpPreview: 'https://localhost/?p=2&preview_id=2&preview_nonce=mock_nonce&preview=true', }, status: { value: 'publish', @@ -439,6 +442,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { const currentDocument = selectActiveDocument( store.getState() ); const platformEdit = getV1DocumentsExitTo( mockDocument ); const permalink = getV1DocumentPermalink( mockDocument ); + const wpPreview = getV1DocumentWpPreview( mockDocument ); expect( currentDocument ).toEqual< Document >( { id: 1, @@ -450,6 +454,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit, permalink, + wpPreview, }, status: { value: 'publish', diff --git a/packages/packages/core/editor-documents/src/sync/sync-store.ts b/packages/packages/core/editor-documents/src/sync/sync-store.ts index 7ef05b0ab372..5b285653a367 100644 --- a/packages/packages/core/editor-documents/src/sync/sync-store.ts +++ b/packages/packages/core/editor-documents/src/sync/sync-store.ts @@ -12,7 +12,7 @@ import { debounce } from '@elementor/utils'; import { slice } from '../store'; import { selectActiveDocument } from '../store/selectors'; import { type Document } from '../types'; -import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentsManager, normalizeV1Document } from './utils'; +import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentsManager, getV1DocumentWpPreview, normalizeV1Document } from './utils'; export function syncStore() { syncInitialization(); @@ -127,8 +127,9 @@ function syncOnExitToChange() { const currentDocument = getV1DocumentsManager().getCurrent(); const newExitTo = getV1DocumentsExitTo( currentDocument ); const permalink = getV1DocumentPermalink( currentDocument ); + const wpPreview = getV1DocumentWpPreview( currentDocument ); - __dispatch( updateActiveDocument( { links: { platformEdit: newExitTo, permalink } } ) ); + __dispatch( updateActiveDocument( { links: { platformEdit: newExitTo, permalink, wpPreview } } ) ); }, 400 ); listenTo( commandEndEvent( 'document/elements/settings' ), updateExitTo ); diff --git a/packages/packages/core/editor-documents/src/sync/utils.ts b/packages/packages/core/editor-documents/src/sync/utils.ts index a97d0d3444ca..8c1d96daf877 100644 --- a/packages/packages/core/editor-documents/src/sync/utils.ts +++ b/packages/packages/core/editor-documents/src/sync/utils.ts @@ -40,6 +40,10 @@ export function getV1DocumentPermalink( documentData: V1Document ) { return documentData.config.urls.permalink ?? ''; } +export function getV1DocumentWpPreview( documentData: V1Document ) { + return documentData.config.urls.wp_preview ?? ''; +} + export function normalizeV1Document( documentData: V1Document ): Document { // Draft or autosave. const isUnpublishedRevision = documentData.config.revisions.current_id !== documentData.id; @@ -58,6 +62,7 @@ export function normalizeV1Document( documentData: V1Document ): Document { }, links: { permalink: getV1DocumentPermalink( documentData ), + wpPreview: getV1DocumentWpPreview( documentData ), platformEdit: exitToUrl, }, isDirty: documentData.editor.isChanged || isUnpublishedRevision, diff --git a/packages/packages/core/editor-documents/src/types.ts b/packages/packages/core/editor-documents/src/types.ts index 6b8b65a1e7bb..b5ea5c55c2d2 100644 --- a/packages/packages/core/editor-documents/src/types.ts +++ b/packages/packages/core/editor-documents/src/types.ts @@ -16,6 +16,7 @@ export type Document = { links: { platformEdit: string; permalink: string; + wpPreview: string; }; isDirty: boolean; isSaving: boolean; @@ -72,6 +73,7 @@ export type V1Document = { urls: { exit_to_dashboard: string; permalink: string; + wp_preview: string; main_dashboard: string; all_post_type: string; }; diff --git a/packages/tests/test-utils/create-mock-document-data.ts b/packages/tests/test-utils/create-mock-document-data.ts index 3d64904ef67c..94dab50fffab 100644 --- a/packages/tests/test-utils/create-mock-document-data.ts +++ b/packages/tests/test-utils/create-mock-document-data.ts @@ -39,6 +39,7 @@ export function createMockDocumentData( { main_dashboard: `https://localhost/wp-admin/`, all_post_type: `https://localhost/wp-admin/post.php`, permalink: `https://localhost/?p=${ id }`, + wp_preview: `https://localhost/?p=${ id }&preview_id=${ id }&preview_nonce=mock_nonce&preview=true`, }, elements }, diff --git a/packages/tests/test-utils/create-mock-document.ts b/packages/tests/test-utils/create-mock-document.ts index 4a44683c6b2c..1eb3aee4f0cd 100644 --- a/packages/tests/test-utils/create-mock-document.ts +++ b/packages/tests/test-utils/create-mock-document.ts @@ -27,6 +27,7 @@ export default function createMockDocument( { links: links ?? { platformEdit: `https://localhost/wp-admin/post.php?post=${ id }&action=edit`, permalink: `https://localhost/?p=${ id }`, + wpPreview: `https://localhost/?p=${ id }&preview_id=${ id }&preview_nonce=mock_nonce&preview=true`, }, isDirty: isDirty ?? false, isSaving: isSaving ?? false, diff --git a/tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php b/tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php new file mode 100644 index 000000000000..ef51dfb9e779 --- /dev/null +++ b/tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php @@ -0,0 +1,255 @@ +assertEmpty( $result ); + } + + public function test_plain_text_passes_through() { + // Arrange + $html = 'Hello World'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertEquals( 'Hello World', $result ); + } + + public function test_paragraph_tags_produce_newlines() { + // Arrange + $html = '

First paragraph

Second paragraph

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( 'First paragraph', $result ); + $this->assertStringContainsString( 'Second paragraph', $result ); + } + + public function test_heading_tags_produce_markdown_headings() { + // Arrange + $html = '

Title

Subtitle

Section

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '# Title', $result ); + $this->assertStringContainsString( '## Subtitle', $result ); + $this->assertStringContainsString( '### Section', $result ); + } + + public function test_heading_with_line_breaks_produces_spaces() { + // Arrange + $html = '

The Future of
Autonomous Robotics
Starts Here

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '# The Future of Autonomous Robotics Starts Here', $result ); + } + + public function test_heading_with_inline_tags_produces_spaces() { + // Arrange + $html = '

The Future of
Autonomous Robotics
Starts Here

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '# The Future of Autonomous Robotics Starts Here', $result ); + } + + public function test_bold_tags_produce_double_asterisks() { + // Arrange + $html = 'bold text'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '**bold text**', $result ); + } + + public function test_italic_tags_produce_single_asterisks() { + // Arrange + $html = 'italic text'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '*italic text*', $result ); + } + + public function test_links_produce_markdown_links() { + // Arrange + $html = 'Click here'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '[Click here](https://example.com)', $result ); + } + + public function test_images_produce_markdown_images() { + // Arrange + $html = 'My image'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '![My image](https://example.com/image.jpg)', $result ); + } + + public function test_unordered_list_produces_dashes() { + // Arrange + $html = '
  • Item 1
  • Item 2
  • Item 3
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '- Item 1', $result ); + $this->assertStringContainsString( '- Item 2', $result ); + $this->assertStringContainsString( '- Item 3', $result ); + } + + public function test_ordered_list_produces_numbers() { + // Arrange + $html = '
  1. First
  2. Second
  3. Third
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '1. First', $result ); + $this->assertStringContainsString( '2. Second', $result ); + $this->assertStringContainsString( '3. Third', $result ); + } + + public function test_blockquote_produces_greater_than() { + // Arrange + $html = '
Quoted text
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '> Quoted text', $result ); + } + + public function test_code_produces_backticks() { + // Arrange + $html = 'inline code'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '`inline code`', $result ); + } + + public function test_pre_produces_code_block() { + // Arrange + $html = '
code block content
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '```', $result ); + $this->assertStringContainsString( 'code block content', $result ); + } + + public function test_br_produces_newline() { + // Arrange + $html = 'Line 1
Line 2'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( "Line 1\nLine 2", $result ); + } + + public function test_hr_produces_horizontal_rule() { + // Arrange + $html = '

Before


After

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '---', $result ); + } + + public function test_script_tags_are_stripped() { + // Arrange + $html = '

Visible

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( 'Visible', $result ); + $this->assertStringNotContainsString( 'alert', $result ); + $this->assertStringNotContainsString( 'script', $result ); + } + + public function test_style_tags_are_stripped() { + // Arrange + $html = '

Visible

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( 'Visible', $result ); + $this->assertStringNotContainsString( 'display', $result ); + } + + public function test_zero_width_spaces_are_stripped() { + // Arrange + $html = "

Navigation Accuracy\xE2\x80\x8B

"; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertEquals( 'Navigation Accuracy', $result ); + } + + public function test_complex_html_converts_correctly() { + // Arrange + $html = '

Welcome

This is a bold and italic paragraph with a link.

  • Item A
  • Item B
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '## Welcome', $result ); + $this->assertStringContainsString( '**bold**', $result ); + $this->assertStringContainsString( '*italic*', $result ); + $this->assertStringContainsString( '[link](https://example.com)', $result ); + $this->assertStringContainsString( '- Item A', $result ); + $this->assertStringContainsString( '- Item B', $result ); + } +} diff --git a/tests/phpunit/elementor/modules/markdown-render/test-module.php b/tests/phpunit/elementor/modules/markdown-render/test-module.php new file mode 100644 index 000000000000..e920a068dc36 --- /dev/null +++ b/tests/phpunit/elementor/modules/markdown-render/test-module.php @@ -0,0 +1,47 @@ +assertEquals( 'markdown_rendering', $data['name'] ); + $this->assertEquals( 'inactive', $data['default'] ); + $this->assertEquals( 'alpha', $data['release_status'] ); + } + + public function test_cache_meta_key_is_defined() { + // Assert + $this->assertEquals( '_elementor_markdown_cache', Module::CACHE_META_KEY ); + } + + public function test_non_markdown_request_does_not_intercept() { + // Arrange + unset( $_GET['format'] ); + unset( $_SERVER['HTTP_ACCEPT'] ); + + // Act + $module = new Module(); + + // Assert + $this->assertTrue( has_action( 'template_redirect', [ $module, 'maybe_serve_markdown' ] ) !== false ); + } + + public function test_cache_invalidation_hooks_are_registered() { + // Arrange + $module = new Module(); + + // Assert + $this->assertNotFalse( has_action( 'save_post', [ $module, 'clear_post_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'elementor/core/files/clear_cache', [ $module, 'clear_all_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'activated_plugin', [ $module, 'clear_all_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'deactivated_plugin', [ $module, 'clear_all_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'switch_theme', [ $module, 'clear_all_markdown_cache' ] ) ); + } +} From 849903da3b8eb18ad66514e37173cf528082df7f Mon Sep 17 00:00:00 2001 From: Mati Horowitz <21468434+matipojo@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:15:49 +0200 Subject: [PATCH 03/38] feat: add Markdown rendering infrastructure for AI crawlers Co-authored-by: Cursor --- core/modules-manager.php | 1 + includes/base/element-base.php | 20 ++ includes/base/widget-base.php | 8 + includes/utils.php | 13 + modules/markdown-render/html-to-markdown.php | 216 +++++++++++++++ modules/markdown-render/markdown-renderer.php | 98 +++++++ modules/markdown-render/module.php | 194 +++++++++++++ .../use-document-view-as-markdown-props.ts | 29 ++ .../src/extensions/documents-save/index.ts | 7 + .../src/sync/__tests__/sync-store.test.ts | 7 +- .../editor-documents/src/sync/sync-store.ts | 5 +- .../core/editor-documents/src/sync/utils.ts | 5 + .../core/editor-documents/src/types.ts | 2 + .../test-utils/create-mock-document-data.ts | 1 + .../tests/test-utils/create-mock-document.ts | 1 + .../markdown-render/test-html-to-markdown.php | 255 ++++++++++++++++++ .../modules/markdown-render/test-module.php | 47 ++++ 17 files changed, 906 insertions(+), 3 deletions(-) create mode 100644 modules/markdown-render/html-to-markdown.php create mode 100644 modules/markdown-render/markdown-renderer.php create mode 100644 modules/markdown-render/module.php create mode 100644 packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts create mode 100644 tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php create mode 100644 tests/phpunit/elementor/modules/markdown-render/test-module.php diff --git a/core/modules-manager.php b/core/modules-manager.php index b652add497db..9b448aed9826 100644 --- a/core/modules-manager.php +++ b/core/modules-manager.php @@ -137,6 +137,7 @@ public function get_modules_names() { 'interactions', 'feedback', 'editor-one', + 'markdown-render', ]; } diff --git a/includes/base/element-base.php b/includes/base/element-base.php index 47a3f1d1bf6f..c31ad725eede 100755 --- a/includes/base/element-base.php +++ b/includes/base/element-base.php @@ -327,6 +327,26 @@ public function get_children() { return $this->children; } + public function render_markdown(): string { + $children = $this->get_children(); + + if ( empty( $children ) ) { + return ''; + } + + $parts = []; + + foreach ( $children as $child ) { + $md = $child->render_markdown(); + + if ( ! empty( trim( $md ) ) ) { + $parts[] = $md; + } + } + + return implode( "\n\n", $parts ); + } + /** * Get default arguments. * diff --git a/includes/base/widget-base.php b/includes/base/widget-base.php index 3ce78dd6baa1..b0e584f8145c 100644 --- a/includes/base/widget-base.php +++ b/includes/base/widget-base.php @@ -697,6 +697,14 @@ public function render_plain_content() { $this->render_content(); } + public function render_markdown(): string { + ob_start(); + $this->render_content(); + $html = ob_get_clean(); + + return wp_strip_all_tags( $html ); + } + /** * Before widget rendering. * diff --git a/includes/utils.php b/includes/utils.php index bfc13de83b7f..c120749c4cfd 100644 --- a/includes/utils.php +++ b/includes/utils.php @@ -973,4 +973,17 @@ public static function decode_string( string $encoded_string, ?string $fallback public static function encode_string( string $decoded_string ): string { return base64_encode( $decoded_string ); } + + public static function html_to_plain_text( string $html ): string { + if ( empty( $html ) ) { + return ''; + } + + $text = preg_replace( '##i', ' ', $html ); + $text = preg_replace( '#]*>#i', ' ', $text ); + $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ); + $text = str_replace( "\xE2\x80\x8B", '', $text ); + + return trim( preg_replace( '/\s+/', ' ', $text ) ); + } } diff --git a/modules/markdown-render/html-to-markdown.php b/modules/markdown-render/html-to-markdown.php new file mode 100644 index 000000000000..d9d60a15489c --- /dev/null +++ b/modules/markdown-render/html-to-markdown.php @@ -0,0 +1,216 @@ +(.*?)#is', '', $html ); + $html = preg_replace( '#(.*?)#is', '', $html ); + $html = str_replace( "\xE2\x80\x8B", '', $html ); + + return $html; + } + + private static function convert_headings( string $html ): string { + for ( $i = 6; $i >= 1; $i-- ) { + $prefix = str_repeat( '#', $i ); + $html = preg_replace_callback( + "#]*>(.*?)#is", + function ( $matches ) use ( $prefix ) { + return "\n\n{$prefix} " . \Elementor\Utils::html_to_plain_text( $matches[1] ) . "\n\n"; + }, + $html + ); + } + + return $html; + } + + private static function convert_bold( string $html ): string { + $html = preg_replace( '#<(?:strong|b)[^>]*>(.*?)#is', '**$1**', $html ); + + return $html; + } + + private static function convert_italic( string $html ): string { + $html = preg_replace( '#<(?:em|i)[^>]*>(.*?)#is', '*$1*', $html ); + + return $html; + } + + private static function convert_links( string $html ): string { + $html = preg_replace_callback( + '#]+href=["\']([^"\']*)["\'][^>]*>(.*?)#is', + function ( $matches ) { + $url = $matches[1]; + $text = $matches[2]; + + if ( empty( $url ) || '#' === $url ) { + return $text; + } + + return '[' . $text . '](' . $url . ')'; + }, + $html + ); + + return $html; + } + + private static function convert_images( string $html ): string { + $html = preg_replace_callback( + '#]+>#is', + function ( $matches ) { + $tag = $matches[0]; + $src = ''; + $alt = ''; + + if ( preg_match( '/src=["\']([^"\']*)["\']/', $tag, $src_match ) ) { + $src = $src_match[1]; + } + + if ( preg_match( '/alt=["\']([^"\']*)["\']/', $tag, $alt_match ) ) { + $alt = $alt_match[1]; + } + + if ( empty( $src ) ) { + return ''; + } + + return '![' . $alt . '](' . $src . ')'; + }, + $html + ); + + return $html; + } + + private static function convert_lists( string $html ): string { + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $items = []; + $counter = 1; + + preg_match_all( '#]*>(.*?)#is', $matches[1], $li_matches ); + + foreach ( $li_matches[1] as $li_content ) { + $items[] = $counter . '. ' . trim( wp_strip_all_tags( $li_content ) ); + $counter++; + } + + return "\n\n" . implode( "\n", $items ) . "\n\n"; + }, + $html + ); + + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $items = []; + + preg_match_all( '#]*>(.*?)#is', $matches[1], $li_matches ); + + foreach ( $li_matches[1] as $li_content ) { + $items[] = '- ' . trim( wp_strip_all_tags( $li_content ) ); + } + + return "\n\n" . implode( "\n", $items ) . "\n\n"; + }, + $html + ); + + return $html; + } + + private static function convert_blockquotes( string $html ): string { + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $content = trim( wp_strip_all_tags( $matches[1] ) ); + $lines = explode( "\n", $content ); + $quoted = array_map( function ( $line ) { + return '> ' . $line; + }, $lines ); + + return "\n\n" . implode( "\n", $quoted ) . "\n\n"; + }, + $html + ); + + return $html; + } + + private static function convert_code( string $html ): string { + $html = preg_replace( '#]*>(.*?)#is', '`$1`', $html ); + + return $html; + } + + private static function convert_pre( string $html ): string { + $html = preg_replace_callback( + '#]*>(.*?)#is', + function ( $matches ) { + $content = html_entity_decode( wp_strip_all_tags( $matches[1] ), ENT_QUOTES, 'UTF-8' ); + + return "\n\n```\n" . $content . "\n```\n\n"; + }, + $html + ); + + return $html; + } + + private static function convert_line_breaks( string $html ): string { + $html = preg_replace( '##i', "\n", $html ); + + return $html; + } + + private static function convert_paragraphs( string $html ): string { + $html = preg_replace( '#]*>#i', "\n\n", $html ); + $html = preg_replace( '#

#i', "\n\n", $html ); + + return $html; + } + + private static function convert_horizontal_rules( string $html ): string { + $html = preg_replace( '##i', "\n\n---\n\n", $html ); + + return $html; + } +} diff --git a/modules/markdown-render/markdown-renderer.php b/modules/markdown-render/markdown-renderer.php new file mode 100644 index 000000000000..c05461ada4cd --- /dev/null +++ b/modules/markdown-render/markdown-renderer.php @@ -0,0 +1,98 @@ +build_frontmatter( $document ); + $data = $document->get_elements_data(); + + if ( empty( $data ) ) { + return $frontmatter; + } + + $sections = []; + + foreach ( $data as $element_data ) { + $md = $this->render_element( $element_data ); + + if ( ! empty( trim( $md ) ) ) { + $sections[] = $md; + } + } + + $body = implode( "\n\n---\n\n", $sections ); + + $output = $frontmatter . "\n\n" . $body; + + return apply_filters( 'elementor/markdown/document_output', $output, $document ); + } + + private function build_frontmatter( Document $document ): string { + $post_id = $document->get_main_id(); + + $lines = [ '---' ]; + $lines[] = 'title: "' . $this->escape_yaml_string( get_the_title( $post_id ) ) . '"'; + + $description = $this->get_meta_description( $post_id ); + + if ( $description ) { + $lines[] = 'description: "' . $this->escape_yaml_string( $description ) . '"'; + } + + $thumbnail = get_the_post_thumbnail_url( $post_id, 'full' ); + + if ( $thumbnail ) { + $lines[] = 'featured_image: "' . esc_url( $thumbnail ) . '"'; + } + + $lines[] = 'url: "' . get_permalink( $post_id ) . '"'; + $lines[] = 'date_modified: "' . get_the_modified_date( 'c', $post_id ) . '"'; + $lines[] = '---'; + + return implode( "\n", $lines ); + } + + private function get_meta_description( int $post_id ): string { + $description = get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ); + + if ( ! empty( $description ) ) { + return $description; + } + + $description = get_post_meta( $post_id, '_aioseo_description', true ); + + if ( ! empty( $description ) ) { + return $description; + } + + $excerpt = get_the_excerpt( $post_id ); + + return $excerpt ?: ''; + } + + private function render_element( array $element_data ): string { + $element = Plugin::$instance->elements_manager->create_element_instance( $element_data ); + + if ( ! $element ) { + return ''; + } + + $markdown = $element->render_markdown(); + + return apply_filters( 'elementor/markdown/element_output', $markdown, $element, $element_data ); + } + + private function escape_yaml_string( string $value ): string { + $value = str_replace( "\xE2\x80\x8B", '', $value ); + + return str_replace( [ '"', "\n", "\r" ], [ '\\"', ' ', '' ], $value ); + } +} diff --git a/modules/markdown-render/module.php b/modules/markdown-render/module.php new file mode 100644 index 000000000000..e68bab8ca626 --- /dev/null +++ b/modules/markdown-render/module.php @@ -0,0 +1,194 @@ + self::EXPERIMENT_NAME, + 'title' => esc_html__( 'Markdown Rendering', 'elementor' ), + 'description' => esc_html__( 'Serve page content as Markdown when AI crawlers request it via Accept: text/markdown header.', 'elementor' ), + 'default' => Experiments_Manager::STATE_INACTIVE, + 'release_status' => Experiments_Manager::RELEASE_STATUS_ALPHA, + ]; + } + + public function __construct() { + parent::__construct(); + + add_action( 'template_redirect', [ $this, 'maybe_serve_markdown' ], 1 ); + + add_action( 'elementor/core/files/clear_cache', [ $this, 'clear_all_markdown_cache' ] ); + add_action( 'save_post', [ $this, 'clear_post_markdown_cache' ] ); + add_action( 'activated_plugin', [ $this, 'clear_all_markdown_cache' ] ); + add_action( 'deactivated_plugin', [ $this, 'clear_all_markdown_cache' ] ); + add_action( 'switch_theme', [ $this, 'clear_all_markdown_cache' ] ); + + if ( is_admin() ) { + add_action( + 'elementor/admin/after_create_settings/' . Settings::PAGE_ID, + [ $this, 'register_admin_fields' ], + 100 + ); + } + } + + public function maybe_serve_markdown() { + if ( ! $this->is_markdown_request() ) { + return; + } + + if ( ! is_singular() ) { + return; + } + + $post_id = get_the_ID(); + $post = get_post( $post_id ); + + if ( ! $post ) { + return; + } + + $is_preview = $this->is_valid_preview_request( $post_id ); + + if ( ! $is_preview && 'publish' !== $post->post_status ) { + return; + } + + if ( post_password_required( $post ) ) { + return; + } + + $document = $is_preview + ? Plugin::$instance->documents->get_doc_for_frontend( $post_id ) + : Plugin::$instance->documents->get( $post_id ); + + if ( ! $document || ! $document->is_built_with_elementor() ) { + return; + } + + if ( $is_preview ) { + $markdown = ( new Markdown_Renderer() )->render( $document ); + } else { + $markdown = $this->get_cached_markdown( $post_id ); + + if ( false === $markdown ) { + $markdown = ( new Markdown_Renderer() )->render( $document ); + $this->set_cached_markdown( $post_id, $markdown ); + } + } + + nocache_headers(); + status_header( 200 ); + header( 'Content-Type: text/markdown; charset=utf-8' ); + header( 'X-Content-Type-Options: nosniff' ); + echo $markdown; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } + + private function is_valid_preview_request( int $post_id ): bool { + if ( ! is_preview() ) { + return false; + } + + $preview_id = (int) ( $_GET['preview_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $preview_nonce = sanitize_text_field( wp_unslash( $_GET['preview_nonce'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( ! $preview_id || ! wp_verify_nonce( $preview_nonce, 'post_preview_' . $preview_id ) ) { + return false; + } + + return current_user_can( 'edit_post', $post_id ); + } + + private function is_markdown_request(): bool { + if ( isset( $_GET['format'] ) && 'markdown' === $_GET['format'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return true; + } + + $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) : ''; + + return false !== strpos( $accept, 'text/markdown' ); + } + + private function get_cached_markdown( int $post_id ) { + $cache = get_post_meta( $post_id, self::CACHE_META_KEY, true ); + + if ( empty( $cache ) || ! is_array( $cache ) ) { + return false; + } + + if ( empty( $cache['timeout'] ) || time() > $cache['timeout'] ) { + return false; + } + + return $cache['content'] ?? false; + } + + private function set_cached_markdown( int $post_id, string $markdown ): void { + $ttl_hours = (int) get_option( 'elementor_markdown_cache_ttl', 24 ); + + if ( $ttl_hours <= 0 ) { + return; + } + + $cache = [ + 'timeout' => time() + ( $ttl_hours * HOUR_IN_SECONDS ), + 'content' => $markdown, + ]; + + update_post_meta( $post_id, self::CACHE_META_KEY, $cache ); + } + + public function clear_post_markdown_cache( int $post_id ): void { + delete_post_meta( $post_id, self::CACHE_META_KEY ); + } + + public function clear_all_markdown_cache(): void { + global $wpdb; + $wpdb->delete( $wpdb->postmeta, [ 'meta_key' => self::CACHE_META_KEY ] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + } + + public function register_admin_fields( Settings $settings ) { + $settings->add_field( + Settings::TAB_PERFORMANCE, + Settings::TAB_PERFORMANCE, + 'markdown_cache_ttl', + [ + 'label' => esc_html__( 'Markdown Cache', 'elementor' ), + 'field_args' => [ + 'class' => 'elementor-markdown-cache-ttl', + 'type' => 'select', + 'std' => '24', + 'options' => [ + '0' => esc_html__( 'Disable', 'elementor' ), + '1' => esc_html__( '1 Hour', 'elementor' ), + '6' => esc_html__( '6 Hours', 'elementor' ), + '12' => esc_html__( '12 Hours', 'elementor' ), + '24' => esc_html__( '1 Day', 'elementor' ), + '72' => esc_html__( '3 Days', 'elementor' ), + '168' => esc_html__( '1 Week', 'elementor' ), + '720' => esc_html__( '1 Month', 'elementor' ), + ], + 'desc' => esc_html__( 'Specify the duration for which Markdown output is cached. This cache is served to AI crawlers requesting text/markdown content.', 'elementor' ), + ], + ] + ); + } +} diff --git a/packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts b/packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts new file mode 100644 index 000000000000..363eb7601b34 --- /dev/null +++ b/packages/packages/core/editor-app-bar/src/extensions/documents-save/hooks/use-document-view-as-markdown-props.ts @@ -0,0 +1,29 @@ +import { __useActiveDocument as useActiveDocument } from '@elementor/editor-documents'; +import { __privateRunCommand as runCommand } from '@elementor/editor-v1-adapters'; +import { EyeIcon } from '@elementor/icons'; +import { __ } from '@wordpress/i18n'; + +export default function useDocumentViewAsMarkdownProps() { + const document = useActiveDocument(); + + return { + icon: EyeIcon, + title: __( 'View as Markdown', 'elementor' ), + onClick: async () => { + const baseUrl = document?.links?.wpPreview || document?.links?.permalink; + + if ( ! baseUrl ) { + return; + } + + if ( document?.isDirty ) { + await runCommand( 'document/save/auto', { force: true } ); + } + + const separator = baseUrl.includes( '?' ) ? '&' : '?'; + const url = baseUrl + separator + 'format=markdown'; + + window.open( url, '_blank' ); + }, + }; +} diff --git a/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts b/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts index 71fad433c796..4403c6f49094 100644 --- a/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts +++ b/packages/packages/core/editor-app-bar/src/extensions/documents-save/index.ts @@ -3,6 +3,7 @@ import PrimaryAction from './components/primary-action'; import useDocumentCopyAndShareProps from './hooks/use-document-copy-and-share-props'; import useDocumentSaveDraftProps from './hooks/use-document-save-draft-props'; import useDocumentSaveTemplateProps from './hooks/use-document-save-template-props'; +import useDocumentViewAsMarkdownProps from './hooks/use-document-view-as-markdown-props'; import useDocumentViewPageProps from './hooks/use-document-view-page-props'; import { documentOptionsMenu } from './locations'; @@ -37,4 +38,10 @@ export function init() { priority: 50, useProps: useDocumentViewPageProps, } ); + + documentOptionsMenu.registerAction( { + id: 'document-view-as-markdown', + priority: 60, + useProps: useDocumentViewAsMarkdownProps, + } ); } diff --git a/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts b/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts index 7d76824c8b86..c9d61114c775 100644 --- a/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts +++ b/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts @@ -5,7 +5,7 @@ import { slice } from '../../store'; import { selectActiveDocument } from '../../store/selectors'; import { type Document, type ExitTo, type ExtendedWindow, type V1Document, type V1DocumentsManager } from '../../types'; import { syncStore } from '../index'; -import { getV1DocumentPermalink, getV1DocumentsExitTo } from '../utils'; + import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentWpPreview } from '../utils'; import { makeDocumentsManager } from './test-utils'; type WindowWithOptionalElementor = Omit< ExtendedWindow, 'elementor' > & { @@ -56,6 +56,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit: 'https://localhost/wp-admin/post.php?post=1&action=edit', permalink: 'https://localhost/?p=1', + wpPreview: 'https://localhost/?p=1&preview_id=1&preview_nonce=mock_nonce&preview=true', }, isDirty: false, isSaving: false, @@ -82,6 +83,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit: 'https://localhost/wp-admin/post.php?post=2&action=edit', permalink: 'https://localhost/?p=2', + wpPreview: 'https://localhost/?p=2&preview_id=2&preview_nonce=mock_nonce&preview=true', }, isDirty: false, isSaving: false, @@ -130,6 +132,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit: 'https://localhost/wp-admin/post.php?post=2&action=edit', permalink: 'https://localhost/?p=2', + wpPreview: 'https://localhost/?p=2&preview_id=2&preview_nonce=mock_nonce&preview=true', }, status: { value: 'publish', @@ -439,6 +442,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { const currentDocument = selectActiveDocument( store.getState() ); const platformEdit = getV1DocumentsExitTo( mockDocument ); const permalink = getV1DocumentPermalink( mockDocument ); + const wpPreview = getV1DocumentWpPreview( mockDocument ); expect( currentDocument ).toEqual< Document >( { id: 1, @@ -450,6 +454,7 @@ describe( '@elementor/editor-documents - Sync Store', () => { links: { platformEdit, permalink, + wpPreview, }, status: { value: 'publish', diff --git a/packages/packages/core/editor-documents/src/sync/sync-store.ts b/packages/packages/core/editor-documents/src/sync/sync-store.ts index 7ef05b0ab372..5b285653a367 100644 --- a/packages/packages/core/editor-documents/src/sync/sync-store.ts +++ b/packages/packages/core/editor-documents/src/sync/sync-store.ts @@ -12,7 +12,7 @@ import { debounce } from '@elementor/utils'; import { slice } from '../store'; import { selectActiveDocument } from '../store/selectors'; import { type Document } from '../types'; -import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentsManager, normalizeV1Document } from './utils'; +import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentsManager, getV1DocumentWpPreview, normalizeV1Document } from './utils'; export function syncStore() { syncInitialization(); @@ -127,8 +127,9 @@ function syncOnExitToChange() { const currentDocument = getV1DocumentsManager().getCurrent(); const newExitTo = getV1DocumentsExitTo( currentDocument ); const permalink = getV1DocumentPermalink( currentDocument ); + const wpPreview = getV1DocumentWpPreview( currentDocument ); - __dispatch( updateActiveDocument( { links: { platformEdit: newExitTo, permalink } } ) ); + __dispatch( updateActiveDocument( { links: { platformEdit: newExitTo, permalink, wpPreview } } ) ); }, 400 ); listenTo( commandEndEvent( 'document/elements/settings' ), updateExitTo ); diff --git a/packages/packages/core/editor-documents/src/sync/utils.ts b/packages/packages/core/editor-documents/src/sync/utils.ts index a97d0d3444ca..8c1d96daf877 100644 --- a/packages/packages/core/editor-documents/src/sync/utils.ts +++ b/packages/packages/core/editor-documents/src/sync/utils.ts @@ -40,6 +40,10 @@ export function getV1DocumentPermalink( documentData: V1Document ) { return documentData.config.urls.permalink ?? ''; } +export function getV1DocumentWpPreview( documentData: V1Document ) { + return documentData.config.urls.wp_preview ?? ''; +} + export function normalizeV1Document( documentData: V1Document ): Document { // Draft or autosave. const isUnpublishedRevision = documentData.config.revisions.current_id !== documentData.id; @@ -58,6 +62,7 @@ export function normalizeV1Document( documentData: V1Document ): Document { }, links: { permalink: getV1DocumentPermalink( documentData ), + wpPreview: getV1DocumentWpPreview( documentData ), platformEdit: exitToUrl, }, isDirty: documentData.editor.isChanged || isUnpublishedRevision, diff --git a/packages/packages/core/editor-documents/src/types.ts b/packages/packages/core/editor-documents/src/types.ts index 6b8b65a1e7bb..b5ea5c55c2d2 100644 --- a/packages/packages/core/editor-documents/src/types.ts +++ b/packages/packages/core/editor-documents/src/types.ts @@ -16,6 +16,7 @@ export type Document = { links: { platformEdit: string; permalink: string; + wpPreview: string; }; isDirty: boolean; isSaving: boolean; @@ -72,6 +73,7 @@ export type V1Document = { urls: { exit_to_dashboard: string; permalink: string; + wp_preview: string; main_dashboard: string; all_post_type: string; }; diff --git a/packages/tests/test-utils/create-mock-document-data.ts b/packages/tests/test-utils/create-mock-document-data.ts index 3d64904ef67c..94dab50fffab 100644 --- a/packages/tests/test-utils/create-mock-document-data.ts +++ b/packages/tests/test-utils/create-mock-document-data.ts @@ -39,6 +39,7 @@ export function createMockDocumentData( { main_dashboard: `https://localhost/wp-admin/`, all_post_type: `https://localhost/wp-admin/post.php`, permalink: `https://localhost/?p=${ id }`, + wp_preview: `https://localhost/?p=${ id }&preview_id=${ id }&preview_nonce=mock_nonce&preview=true`, }, elements }, diff --git a/packages/tests/test-utils/create-mock-document.ts b/packages/tests/test-utils/create-mock-document.ts index 4a44683c6b2c..1eb3aee4f0cd 100644 --- a/packages/tests/test-utils/create-mock-document.ts +++ b/packages/tests/test-utils/create-mock-document.ts @@ -27,6 +27,7 @@ export default function createMockDocument( { links: links ?? { platformEdit: `https://localhost/wp-admin/post.php?post=${ id }&action=edit`, permalink: `https://localhost/?p=${ id }`, + wpPreview: `https://localhost/?p=${ id }&preview_id=${ id }&preview_nonce=mock_nonce&preview=true`, }, isDirty: isDirty ?? false, isSaving: isSaving ?? false, diff --git a/tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php b/tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php new file mode 100644 index 000000000000..ef51dfb9e779 --- /dev/null +++ b/tests/phpunit/elementor/modules/markdown-render/test-html-to-markdown.php @@ -0,0 +1,255 @@ +assertEmpty( $result ); + } + + public function test_plain_text_passes_through() { + // Arrange + $html = 'Hello World'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertEquals( 'Hello World', $result ); + } + + public function test_paragraph_tags_produce_newlines() { + // Arrange + $html = '

First paragraph

Second paragraph

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( 'First paragraph', $result ); + $this->assertStringContainsString( 'Second paragraph', $result ); + } + + public function test_heading_tags_produce_markdown_headings() { + // Arrange + $html = '

Title

Subtitle

Section

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '# Title', $result ); + $this->assertStringContainsString( '## Subtitle', $result ); + $this->assertStringContainsString( '### Section', $result ); + } + + public function test_heading_with_line_breaks_produces_spaces() { + // Arrange + $html = '

The Future of
Autonomous Robotics
Starts Here

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '# The Future of Autonomous Robotics Starts Here', $result ); + } + + public function test_heading_with_inline_tags_produces_spaces() { + // Arrange + $html = '

The Future of
Autonomous Robotics
Starts Here

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '# The Future of Autonomous Robotics Starts Here', $result ); + } + + public function test_bold_tags_produce_double_asterisks() { + // Arrange + $html = 'bold text'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '**bold text**', $result ); + } + + public function test_italic_tags_produce_single_asterisks() { + // Arrange + $html = 'italic text'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '*italic text*', $result ); + } + + public function test_links_produce_markdown_links() { + // Arrange + $html = 'Click here'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '[Click here](https://example.com)', $result ); + } + + public function test_images_produce_markdown_images() { + // Arrange + $html = 'My image'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '![My image](https://example.com/image.jpg)', $result ); + } + + public function test_unordered_list_produces_dashes() { + // Arrange + $html = '
  • Item 1
  • Item 2
  • Item 3
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '- Item 1', $result ); + $this->assertStringContainsString( '- Item 2', $result ); + $this->assertStringContainsString( '- Item 3', $result ); + } + + public function test_ordered_list_produces_numbers() { + // Arrange + $html = '
  1. First
  2. Second
  3. Third
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '1. First', $result ); + $this->assertStringContainsString( '2. Second', $result ); + $this->assertStringContainsString( '3. Third', $result ); + } + + public function test_blockquote_produces_greater_than() { + // Arrange + $html = '
Quoted text
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '> Quoted text', $result ); + } + + public function test_code_produces_backticks() { + // Arrange + $html = 'inline code'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '`inline code`', $result ); + } + + public function test_pre_produces_code_block() { + // Arrange + $html = '
code block content
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '```', $result ); + $this->assertStringContainsString( 'code block content', $result ); + } + + public function test_br_produces_newline() { + // Arrange + $html = 'Line 1
Line 2'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( "Line 1\nLine 2", $result ); + } + + public function test_hr_produces_horizontal_rule() { + // Arrange + $html = '

Before


After

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '---', $result ); + } + + public function test_script_tags_are_stripped() { + // Arrange + $html = '

Visible

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( 'Visible', $result ); + $this->assertStringNotContainsString( 'alert', $result ); + $this->assertStringNotContainsString( 'script', $result ); + } + + public function test_style_tags_are_stripped() { + // Arrange + $html = '

Visible

'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( 'Visible', $result ); + $this->assertStringNotContainsString( 'display', $result ); + } + + public function test_zero_width_spaces_are_stripped() { + // Arrange + $html = "

Navigation Accuracy\xE2\x80\x8B

"; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertEquals( 'Navigation Accuracy', $result ); + } + + public function test_complex_html_converts_correctly() { + // Arrange + $html = '

Welcome

This is a bold and italic paragraph with a link.

  • Item A
  • Item B
'; + + // Act + $result = Html_To_Markdown::convert( $html ); + + // Assert + $this->assertStringContainsString( '## Welcome', $result ); + $this->assertStringContainsString( '**bold**', $result ); + $this->assertStringContainsString( '*italic*', $result ); + $this->assertStringContainsString( '[link](https://example.com)', $result ); + $this->assertStringContainsString( '- Item A', $result ); + $this->assertStringContainsString( '- Item B', $result ); + } +} diff --git a/tests/phpunit/elementor/modules/markdown-render/test-module.php b/tests/phpunit/elementor/modules/markdown-render/test-module.php new file mode 100644 index 000000000000..e920a068dc36 --- /dev/null +++ b/tests/phpunit/elementor/modules/markdown-render/test-module.php @@ -0,0 +1,47 @@ +assertEquals( 'markdown_rendering', $data['name'] ); + $this->assertEquals( 'inactive', $data['default'] ); + $this->assertEquals( 'alpha', $data['release_status'] ); + } + + public function test_cache_meta_key_is_defined() { + // Assert + $this->assertEquals( '_elementor_markdown_cache', Module::CACHE_META_KEY ); + } + + public function test_non_markdown_request_does_not_intercept() { + // Arrange + unset( $_GET['format'] ); + unset( $_SERVER['HTTP_ACCEPT'] ); + + // Act + $module = new Module(); + + // Assert + $this->assertTrue( has_action( 'template_redirect', [ $module, 'maybe_serve_markdown' ] ) !== false ); + } + + public function test_cache_invalidation_hooks_are_registered() { + // Arrange + $module = new Module(); + + // Assert + $this->assertNotFalse( has_action( 'save_post', [ $module, 'clear_post_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'elementor/core/files/clear_cache', [ $module, 'clear_all_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'activated_plugin', [ $module, 'clear_all_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'deactivated_plugin', [ $module, 'clear_all_markdown_cache' ] ) ); + $this->assertNotFalse( has_action( 'switch_theme', [ $module, 'clear_all_markdown_cache' ] ) ); + } +} From eda99918523a7146ed4da62b852745cda6b2f59d Mon Sep 17 00:00:00 2001 From: Mati Horowitz <21468434+matipojo@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:15:55 +0200 Subject: [PATCH 04/38] feat: add semantic Markdown overrides for 37 widgets Co-authored-by: Cursor --- includes/widgets/accordion.php | 30 ++ includes/widgets/alert.php | 22 + includes/widgets/audio.php | 7 + includes/widgets/button.php | 16 + includes/widgets/counter.php | 12 + includes/widgets/divider.php | 10 + includes/widgets/google-maps.php | 7 + includes/widgets/heading.php | 28 ++ includes/widgets/html.php | 7 + includes/widgets/icon-box.php | 17 + includes/widgets/icon-list.php | 27 ++ includes/widgets/icon.php | 4 + includes/widgets/image-box.php | 19 + includes/widgets/image-carousel.php | 16 + includes/widgets/image-gallery.php | 16 + includes/widgets/image.php | 56 +++ includes/widgets/menu-anchor.php | 4 + includes/widgets/progress.php | 29 ++ includes/widgets/rating.php | 8 + includes/widgets/read-more.php | 10 + includes/widgets/shortcode.php | 8 + includes/widgets/social-icons.php | 43 ++ includes/widgets/spacer.php | 4 + includes/widgets/star-rating.php | 23 + includes/widgets/tabs.php | 30 ++ includes/widgets/testimonial.php | 27 ++ includes/widgets/text-editor.php | 12 + includes/widgets/toggle.php | 15 + includes/widgets/video.php | 22 + .../elements/atomic-button/atomic-button.php | 15 + .../atomic-divider/atomic-divider.php | 4 + .../elements/atomic-form/atomic-form.php | 4 + .../atomic-heading/atomic-heading.php | 21 + .../elements/atomic-image/atomic-image.php | 13 + .../atomic-paragraph/atomic-paragraph.php | 11 + .../elements/atomic-svg/atomic-svg.php | 4 + .../atomic-youtube/atomic-youtube.php | 11 + .../test-widget-render-markdown.php | 432 ++++++++++++++++++ 38 files changed, 1044 insertions(+) create mode 100644 tests/phpunit/elementor/modules/markdown-render/test-widget-render-markdown.php diff --git a/includes/widgets/accordion.php b/includes/widgets/accordion.php index 0cd2ad28c833..6c4b0bd0974b 100644 --- a/includes/widgets/accordion.php +++ b/includes/widgets/accordion.php @@ -658,6 +658,36 @@ protected function render() { * @since 2.9.0 * @access protected */ + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + + if ( empty( $settings['tabs'] ) ) { + return ''; + } + + $sections = []; + + foreach ( $settings['tabs'] as $item ) { + $title = Utils::html_to_plain_text( $item['tab_title'] ?? '' ); + $content = Utils::html_to_plain_text( $item['tab_content'] ?? '' ); + + if ( empty( $title ) && empty( $content ) ) { + continue; + } + + $section = '### ' . $title; + + if ( ! empty( $content ) ) { + $section .= "\n\n" . $content; + } + + $sections[] = $section; + } + + return implode( "\n\n", $sections ); + } + protected function content_template() { ?>
diff --git a/includes/widgets/alert.php b/includes/widgets/alert.php index 27cf5300d968..1f48695a3951 100644 --- a/includes/widgets/alert.php +++ b/includes/widgets/alert.php @@ -525,6 +525,28 @@ protected function render() { * @since 2.9.0 * @access protected */ + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + + $title = Utils::html_to_plain_text( $settings['alert_title'] ?? '' ); + $description = Utils::html_to_plain_text( $settings['alert_description'] ?? '' ); + + if ( empty( $title ) && empty( $description ) ) { + return ''; + } + + if ( ! empty( $title ) && ! empty( $description ) ) { + return '> **' . $title . ':** ' . $description; + } + + if ( ! empty( $title ) ) { + return '> **' . $title . '**'; + } + + return '> ' . $description; + } + protected function content_template() { ?> <# diff --git a/includes/widgets/audio.php b/includes/widgets/audio.php index 3869137844f7..1c76997cbe39 100644 --- a/includes/widgets/audio.php +++ b/includes/widgets/audio.php @@ -342,4 +342,11 @@ public function filter_oembed_result( $html ) { * @access protected */ protected function content_template() {} + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + $url = $settings['link']['url'] ?? ''; + if ( empty( $url ) ) { return ''; } + return '[Audio](' . esc_url( $url ) . ')'; + } } diff --git a/includes/widgets/button.php b/includes/widgets/button.php index 10b71560b0bc..0c4037aab3a9 100644 --- a/includes/widgets/button.php +++ b/includes/widgets/button.php @@ -144,4 +144,20 @@ protected function register_controls() { protected function render() { $this->render_button(); } + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + + $text = Utils::html_to_plain_text( $settings['text'] ?? '' ); + + if ( empty( $text ) ) { + return ''; + } + + if ( ! empty( $settings['link']['url'] ) ) { + return '[' . $text . '](' . esc_url( $settings['link']['url'] ) . ')'; + } + + return '**' . $text . '**'; + } } diff --git a/includes/widgets/counter.php b/includes/widgets/counter.php index 29346ee31206..2d0c9d6190e1 100644 --- a/includes/widgets/counter.php +++ b/includes/widgets/counter.php @@ -710,4 +710,16 @@ protected function render() {
get_settings_for_display(); + $number = $settings['ending_number'] ?? ''; + $prefix = $settings['prefix'] ?? ''; + $suffix = $settings['suffix'] ?? ''; + $title = Utils::html_to_plain_text( $settings['title'] ?? '' ); + $value = $prefix . $number . $suffix; + if ( empty( $value ) && empty( $title ) ) { return ''; } + $parts = array_filter( [ $value, $title ] ); + return implode( ' - ', $parts ); + } } diff --git a/includes/widgets/divider.php b/includes/widgets/divider.php index 344985f578c6..7dfbea27f5ed 100644 --- a/includes/widgets/divider.php +++ b/includes/widgets/divider.php @@ -1138,4 +1138,14 @@ protected function render() { get_settings_for_display(); + + if ( 'line_text' === ( $settings['look'] ?? '' ) && ! empty( $settings['text'] ) ) { + return '--- ' . Utils::html_to_plain_text( $settings['text'] ) . ' ---'; + } + + return '---'; + } } diff --git a/includes/widgets/google-maps.php b/includes/widgets/google-maps.php index f8e19dfd8db8..a5bc3e1e579c 100644 --- a/includes/widgets/google-maps.php +++ b/includes/widgets/google-maps.php @@ -325,4 +325,11 @@ protected function render() { * @access protected */ protected function content_template() {} + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + $address = Utils::html_to_plain_text( $settings['address'] ?? '' ); + if ( empty( $address ) ) { return ''; } + return '[Map: ' . $address . '](https://maps.google.com/maps?q=' . rawurlencode( $address ) . ')'; + } } diff --git a/includes/widgets/heading.php b/includes/widgets/heading.php index 18b1d2cae129..5b193f958ba3 100644 --- a/includes/widgets/heading.php +++ b/includes/widgets/heading.php @@ -460,6 +460,34 @@ protected function render() { echo $title_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + + if ( '' === $settings['title'] ) { + return ''; + } + + $tag = $settings['header_size'] ?? 'h2'; + $level_map = [ + 'h1' => 1, + 'h2' => 2, + 'h3' => 3, + 'h4' => 4, + 'h5' => 5, + 'h6' => 6, + ]; + $level = $level_map[ $tag ] ?? 2; + $title = Utils::html_to_plain_text( $settings['title'] ); + + $md = str_repeat( '#', $level ) . ' ' . $title; + + if ( ! empty( $settings['link']['url'] ) ) { + $md = str_repeat( '#', $level ) . ' [' . $title . '](' . esc_url( $settings['link']['url'] ) . ')'; + } + + return $md; + } + public function maybe_add_ally_heading_hint() { $notice_id = 'ally_heading_notice'; $plugin_slug = 'pojo-accessibility'; diff --git a/includes/widgets/html.php b/includes/widgets/html.php index 4ac786098701..fbdb093be11b 100644 --- a/includes/widgets/html.php +++ b/includes/widgets/html.php @@ -140,4 +140,11 @@ protected function content_template() { {{{ settings.html }}} get_settings_for_display(); + $html = $settings['html'] ?? ''; + if ( empty( $html ) ) { return ''; } + return \Elementor\Modules\MarkdownRender\Html_To_Markdown::convert( $html ); + } } diff --git a/includes/widgets/icon-box.php b/includes/widgets/icon-box.php index 29f64c817f41..1306f40a66ac 100644 --- a/includes/widgets/icon-box.php +++ b/includes/widgets/icon-box.php @@ -967,4 +967,21 @@ protected function content_template() { public function on_import( $element ) { return Icons_Manager::on_import_migration( $element, 'icon', 'selected_icon', true ); } + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + $title = Utils::html_to_plain_text( $settings['title_text'] ?? '' ); + $description = Utils::html_to_plain_text( $settings['description_text'] ?? '' ); + if ( empty( $title ) && empty( $description ) ) { return ''; } + $parts = []; + if ( ! empty( $title ) ) { + if ( ! empty( $settings['link']['url'] ) ) { + $parts[] = '### [' . $title . '](' . esc_url( $settings['link']['url'] ) . ')'; + } else { + $parts[] = '### ' . $title; + } + } + if ( ! empty( $description ) ) { $parts[] = $description; } + return implode( "\n\n", $parts ); + } } diff --git a/includes/widgets/icon-list.php b/includes/widgets/icon-list.php index 3074d49cbb0d..661d2db56329 100644 --- a/includes/widgets/icon-list.php +++ b/includes/widgets/icon-list.php @@ -803,6 +803,33 @@ protected function render() { * @since 2.9.0 * @access protected */ + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + + if ( empty( $settings['icon_list'] ) ) { + return ''; + } + + $lines = []; + + foreach ( $settings['icon_list'] as $item ) { + $text = Utils::html_to_plain_text( $item['text'] ?? '' ); + + if ( empty( $text ) ) { + continue; + } + + if ( ! empty( $item['link']['url'] ) ) { + $text = '[' . $text . '](' . esc_url( $item['link']['url'] ) . ')'; + } + + $lines[] = '- ' . $text; + } + + return implode( "\n", $lines ); + } + protected function content_template() { ?> <# diff --git a/includes/widgets/icon.php b/includes/widgets/icon.php index 75edca2a2804..5c1bd37f01af 100644 --- a/includes/widgets/icon.php +++ b/includes/widgets/icon.php @@ -523,4 +523,8 @@ protected function content_template() { get_settings_for_display(); + $title = Utils::html_to_plain_text( $settings['title_text'] ?? '' ); + $description = Utils::html_to_plain_text( $settings['description_text'] ?? '' ); + $image_url = $settings['image']['url'] ?? ''; + if ( empty( $title ) && empty( $description ) && empty( $image_url ) ) { return ''; } + $parts = []; + if ( ! empty( $image_url ) ) { $parts[] = '![' . $title . '](' . esc_url( $image_url ) . ')'; } + if ( ! empty( $title ) ) { + if ( ! empty( $settings['link']['url'] ) ) { + $parts[] = '### [' . $title . '](' . esc_url( $settings['link']['url'] ) . ')'; + } else { + $parts[] = '### ' . $title; + } + } + if ( ! empty( $description ) ) { $parts[] = $description; } + return implode( "\n\n", $parts ); + } } diff --git a/includes/widgets/image-carousel.php b/includes/widgets/image-carousel.php index ccd3ce4caab6..4c940cac3839 100644 --- a/includes/widgets/image-carousel.php +++ b/includes/widgets/image-carousel.php @@ -1143,4 +1143,20 @@ private function render_swiper_button( $type ) { Icons_Manager::render_icon( $icon_settings, [ 'aria-hidden' => 'true' ] ); } + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + if ( empty( $settings['carousel'] ) ) { return ''; } + $images = []; + foreach ( $settings['carousel'] as $item ) { + $url = $item['url'] ?? ''; + if ( empty( $url ) ) { continue; } + $alt = ''; + if ( ! empty( $item['id'] ) ) { + $alt = get_post_meta( $item['id'], '_wp_attachment_image_alt', true ); + } + $images[] = '![' . $alt . '](' . esc_url( $url ) . ')'; + } + return implode( "\n\n", $images ); + } } diff --git a/includes/widgets/image-gallery.php b/includes/widgets/image-gallery.php index 8fd801230e24..e0911b14a6b3 100644 --- a/includes/widgets/image-gallery.php +++ b/includes/widgets/image-gallery.php @@ -500,4 +500,20 @@ protected function render() { get_settings_for_display(); + if ( empty( $settings['wp_gallery'] ) ) { return ''; } + $images = []; + foreach ( $settings['wp_gallery'] as $image ) { + $url = $image['url'] ?? ''; + if ( empty( $url ) ) { continue; } + $alt = ''; + if ( ! empty( $image['id'] ) ) { + $alt = get_post_meta( $image['id'], '_wp_attachment_image_alt', true ); + } + $images[] = '![' . $alt . '](' . esc_url( $url ) . ')'; + } + return implode( "\n\n", $images ); + } } diff --git a/includes/widgets/image.php b/includes/widgets/image.php index 792c73528611..2f7446c5ccc0 100644 --- a/includes/widgets/image.php +++ b/includes/widgets/image.php @@ -774,6 +774,62 @@ protected function render() { get_settings_for_display(); + + if ( empty( $settings['image']['url'] ) ) { + return ''; + } + + $url = esc_url( $settings['image']['url'] ); + $alt = $this->get_image_alt_text( $settings ); + $image_md = '![' . $alt . '](' . $url . ')'; + + $link = $this->get_link_url( $settings ); + + if ( ! empty( $link['url'] ) && $link['url'] !== $url ) { + $image_md = '[' . $image_md . '](' . esc_url( $link['url'] ) . ')'; + } + + $caption = $this->get_visible_caption( $settings ); + + if ( ! empty( $caption ) ) { + $image_md .= "\n*" . $caption . '*'; + } + + return $image_md; + } + + private function get_image_alt_text( array $settings ): string { + if ( empty( $settings['image']['id'] ) ) { + return ''; + } + + $attachment_id = $settings['image']['id']; + + $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); + + if ( ! empty( $alt ) ) { + return $alt; + } + + $attachment = get_post( $attachment_id ); + + if ( ! $attachment ) { + return ''; + } + + return $attachment->post_title ?? ''; + } + + private function get_visible_caption( array $settings ): string { + if ( ! $this->has_caption( $settings ) ) { + return ''; + } + + return Utils::html_to_plain_text( $this->get_caption( $settings ) ); + } + /** * Render image widget output in the editor. * diff --git a/includes/widgets/menu-anchor.php b/includes/widgets/menu-anchor.php index b58a6f264381..6ccadcceab53 100644 --- a/includes/widgets/menu-anchor.php +++ b/includes/widgets/menu-anchor.php @@ -196,6 +196,10 @@ protected function content_template() { get_settings_for_display(); + $title = Utils::html_to_plain_text( $settings['title'] ?? '' ); + $inner_text = Utils::html_to_plain_text( $settings['inner_text'] ?? '' ); + $percent = $settings['percent']['size'] ?? ( $settings['percent'] ?? '' ); + + $parts = []; + + if ( ! empty( $title ) ) { + $parts[] = $title; + } + + if ( ! empty( $inner_text ) ) { + $parts[] = $inner_text; + } + + if ( empty( $parts ) && empty( $percent ) ) { + return ''; + } + + $label = implode( ' - ', $parts ); + + if ( ! empty( $percent ) ) { + return ! empty( $label ) ? $label . ': ' . $percent . '%' : $percent . '%'; + } + + return $label; + } } diff --git a/includes/widgets/rating.php b/includes/widgets/rating.php index 2461cfe9deed..346d6d3c2806 100644 --- a/includes/widgets/rating.php +++ b/includes/widgets/rating.php @@ -322,4 +322,12 @@ protected function render() { get_settings_for_display(); + $value = $settings['rating_value'] ?? ''; + $scale = $settings['rating_scale']['size'] ?? ( $settings['rating_scale'] ?? '5' ); + if ( empty( $value ) ) { return ''; } + return $value . '/' . $scale; + } } diff --git a/includes/widgets/read-more.php b/includes/widgets/read-more.php index 613d83165f8e..eced5b861db5 100644 --- a/includes/widgets/read-more.php +++ b/includes/widgets/read-more.php @@ -157,4 +157,14 @@ protected function content_template() { get_settings_for_display(); + $text = Utils::html_to_plain_text( $settings['link_text'] ?? 'Read More' ); + $url = ( $settings['link'] ?? [] )['url'] ?? ''; + if ( ! empty( $url ) ) { + return '[' . $text . '](' . esc_url( $url ) . ')'; + } + return $text; + } } diff --git a/includes/widgets/shortcode.php b/includes/widgets/shortcode.php index 025b23c06937..abdb15beac10 100644 --- a/includes/widgets/shortcode.php +++ b/includes/widgets/shortcode.php @@ -166,4 +166,12 @@ public function render_plain_content() { * @access protected */ protected function content_template() {} + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + $shortcode = $settings['shortcode'] ?? ''; + if ( empty( $shortcode ) ) { return ''; } + $output = do_shortcode( $shortcode ); + return Utils::html_to_plain_text( $output ); + } } diff --git a/includes/widgets/social-icons.php b/includes/widgets/social-icons.php index a8385d4027aa..2423623b2c46 100644 --- a/includes/widgets/social-icons.php +++ b/includes/widgets/social-icons.php @@ -713,4 +713,47 @@ protected function content_template() { get_settings_for_display(); + if ( empty( $settings['social_icon_list'] ) ) { + return ''; + } + + $migration_allowed = Icons_Manager::is_migration_allowed(); + $links = []; + + foreach ( $settings['social_icon_list'] as $item ) { + $url = $item['link']['url'] ?? ''; + if ( empty( $url ) ) { + continue; + } + + $migrated = isset( $item['__fa4_migrated']['social_icon'] ); + $is_new = empty( $item['social'] ) && $migration_allowed; + $social = ''; + + if ( ! empty( $item['social'] ) ) { + $social = str_replace( 'fa fa-', '', $item['social'] ); + } + + if ( ( $is_new || $migrated ) && 'svg' !== ( $item['social_icon']['library'] ?? '' ) ) { + $parts = explode( ' ', $item['social_icon']['value'] ?? '', 2 ); + $social = ! empty( $parts[1] ) ? str_replace( 'fa-', '', $parts[1] ) : ''; + } + + if ( 'svg' === ( $item['social_icon']['library'] ?? '' ) ) { + $social = get_post_meta( $item['social_icon']['value']['id'] ?? 0, '_wp_attachment_image_alt', true ); + } + + $label = ucwords( str_replace( '-', ' ', $social ) ); + if ( empty( $label ) ) { + $label = 'Link'; + } + + $links[] = '- [' . $label . '](' . esc_url( $url ) . ')'; + } + + return implode( "\n", $links ); + } } diff --git a/includes/widgets/spacer.php b/includes/widgets/spacer.php index c3e715f4184b..b1c14d71c8f5 100644 --- a/includes/widgets/spacer.php +++ b/includes/widgets/spacer.php @@ -192,4 +192,8 @@ protected function content_template() { get_settings_for_display(); + $title = Utils::html_to_plain_text( $settings['title'] ?? '' ); + $rating = floatval( $settings['rating'] ?? 0 ); + $scale = intval( $settings['rating_scale'] ?? 5 ); + + if ( empty( $rating ) ) { + return ''; + } + + $full_stars = intval( floor( $rating ) ); + $empty_stars = $scale - $full_stars; + $stars = str_repeat( '★', $full_stars ) . str_repeat( '☆', max( 0, $empty_stars ) ); + + $md = $stars . ' ' . $rating . '/' . $scale; + + if ( ! empty( $title ) ) { + $md .= "\n\n" . $title; + } + + return $md; + } + /** * @since 2.9.0 * @access protected diff --git a/includes/widgets/tabs.php b/includes/widgets/tabs.php index 3b3b426c850d..fac0d43da1eb 100644 --- a/includes/widgets/tabs.php +++ b/includes/widgets/tabs.php @@ -586,6 +586,36 @@ protected function render() { * @since 2.9.0 * @access protected */ + + public function render_markdown(): string { + $tabs = $this->get_settings_for_display( 'tabs' ); + + if ( empty( $tabs ) ) { + return ''; + } + + $sections = []; + + foreach ( $tabs as $item ) { + $title = Utils::html_to_plain_text( $item['tab_title'] ?? '' ); + $content = Utils::html_to_plain_text( $item['tab_content'] ?? '' ); + + if ( empty( $title ) && empty( $content ) ) { + continue; + } + + $section = '### ' . $title; + + if ( ! empty( $content ) ) { + $section .= "\n\n" . $content; + } + + $sections[] = $section; + } + + return implode( "\n\n", $sections ); + } + protected function content_template() { ?>
diff --git a/includes/widgets/testimonial.php b/includes/widgets/testimonial.php index ccdb071860cd..99a712905673 100644 --- a/includes/widgets/testimonial.php +++ b/includes/widgets/testimonial.php @@ -568,6 +568,33 @@ protected function render() { * @since 2.9.0 * @access protected */ + + public function render_markdown(): string { + $settings = $this->get_settings_for_display(); + + $content = Utils::html_to_plain_text( $settings['testimonial_content'] ?? '' ); + $name = Utils::html_to_plain_text( $settings['testimonial_name'] ?? '' ); + $job = Utils::html_to_plain_text( $settings['testimonial_job'] ?? '' ); + + if ( empty( $content ) && empty( $name ) ) { + return ''; + } + + $md = ''; + + if ( ! empty( $content ) ) { + $md = '> "' . $content . '"'; + } + + $attribution = array_filter( [ $name, $job ] ); + + if ( ! empty( $attribution ) ) { + $md .= "\n>\n> — " . implode( ', ', $attribution ); + } + + return $md; + } + protected function content_template() { ?> <# diff --git a/includes/widgets/text-editor.php b/includes/widgets/text-editor.php index d15ae82398de..138ecc6d023c 100644 --- a/includes/widgets/text-editor.php +++ b/includes/widgets/text-editor.php @@ -636,6 +636,18 @@ public function render_plain_content() { * @since 2.9.0 * @access protected */ + + public function render_markdown(): string { + $editor_content = $this->get_settings_for_display( 'editor' ); + $editor_content = $this->parse_text_editor( $editor_content ); + + if ( empty( $editor_content ) ) { + return ''; + } + + return \Elementor\Modules\MarkdownRender\Html_To_Markdown::convert( $editor_content ); + } + protected function content_template() { ?> <# diff --git a/includes/widgets/toggle.php b/includes/widgets/toggle.php index 12b2540c4b4a..72bb4f2a9897 100644 --- a/includes/widgets/toggle.php +++ b/includes/widgets/toggle.php @@ -739,4 +739,19 @@ protected function content_template() {
get_settings_for_display(); + if ( empty( $settings['tabs'] ) ) { return ''; } + $sections = []; + foreach ( $settings['tabs'] as $item ) { + $title = Utils::html_to_plain_text( $item['tab_title'] ?? '' ); + $content = Utils::html_to_plain_text( $item['tab_content'] ?? '' ); + if ( empty( $title ) && empty( $content ) ) { continue; } + $section = '### ' . $title; + if ( ! empty( $content ) ) { $section .= "\n\n" . $content; } + $sections[] = $section; + } + return implode( "\n\n", $sections ); + } } diff --git a/includes/widgets/video.php b/includes/widgets/video.php index 416109500d19..996ab70f287a 100644 --- a/includes/widgets/video.php +++ b/includes/widgets/video.php @@ -1413,4 +1413,26 @@ private function render_hosted_video() { get_settings_for_display(); + $video_type = $settings['video_type'] ?? 'youtube'; + $url = ''; + + if ( 'hosted' === $video_type ) { + if ( ! empty( $settings['insert_url'] ) ) { + $url = $settings['external_url']['url'] ?? ''; + } else { + $url = $settings['hosted_url']['url'] ?? ''; + } + } else { + $url = $settings[ $video_type . '_url' ] ?? ''; + } + + if ( empty( $url ) ) { + return ''; + } + + return '[Video](' . esc_url( $url ) . ')'; + } } diff --git a/modules/atomic-widgets/elements/atomic-button/atomic-button.php b/modules/atomic-widgets/elements/atomic-button/atomic-button.php index f17cc068a3ae..431013792df6 100644 --- a/modules/atomic-widgets/elements/atomic-button/atomic-button.php +++ b/modules/atomic-widgets/elements/atomic-button/atomic-button.php @@ -147,4 +147,19 @@ protected function get_templates(): array { 'elementor/elements/atomic-button' => __DIR__ . '/atomic-button.html.twig', ]; } + + public function render_markdown(): string { + $settings = $this->get_atomic_settings(); + $text = wp_strip_all_tags( $settings['text'] ?? '' ); + + if ( empty( $text ) ) { + return ''; + } + + if ( ! empty( $settings['link']['href'] ) ) { + return '[' . $text . '](' . esc_url( $settings['link']['href'] ) . ')'; + } + + return '**' . $text . '**'; + } } diff --git a/modules/atomic-widgets/elements/atomic-divider/atomic-divider.php b/modules/atomic-widgets/elements/atomic-divider/atomic-divider.php index c60b94442bdd..7e3c401116a7 100644 --- a/modules/atomic-widgets/elements/atomic-divider/atomic-divider.php +++ b/modules/atomic-widgets/elements/atomic-divider/atomic-divider.php @@ -105,4 +105,8 @@ protected function get_templates(): array { 'elementor/elements/atomic-divider' => __DIR__ . '/atomic-divider.html.twig', ]; } + + public function render_markdown(): string { + return '---'; + } } diff --git a/modules/atomic-widgets/elements/atomic-form/atomic-form.php b/modules/atomic-widgets/elements/atomic-form/atomic-form.php index 15bfd8e737a9..108040eb0711 100644 --- a/modules/atomic-widgets/elements/atomic-form/atomic-form.php +++ b/modules/atomic-widgets/elements/atomic-form/atomic-form.php @@ -267,4 +267,8 @@ protected function add_render_attributes() { $this->add_render_attribute( '_wrapper', $attributes ); } + + public function render_markdown(): string { + return ''; + } } diff --git a/modules/atomic-widgets/elements/atomic-heading/atomic-heading.php b/modules/atomic-widgets/elements/atomic-heading/atomic-heading.php index 40aaf70edd35..5747ceab2ed5 100644 --- a/modules/atomic-widgets/elements/atomic-heading/atomic-heading.php +++ b/modules/atomic-widgets/elements/atomic-heading/atomic-heading.php @@ -154,4 +154,25 @@ protected function get_templates(): array { 'elementor/elements/atomic-heading' => __DIR__ . '/atomic-heading.html.twig', ]; } + + public function render_markdown(): string { + $settings = $this->get_atomic_settings(); + $title = wp_strip_all_tags( $settings['title'] ?? '' ); + + if ( empty( $title ) ) { + return ''; + } + + $tag = $settings['tag'] ?? 'h2'; + $level_map = [ 'h1' => 1, 'h2' => 2, 'h3' => 3, 'h4' => 4, 'h5' => 5, 'h6' => 6 ]; + $level = $level_map[ $tag ] ?? 2; + + $md = str_repeat( '#', $level ) . ' ' . $title; + + if ( ! empty( $settings['link']['href'] ) ) { + $md = str_repeat( '#', $level ) . ' [' . $title . '](' . esc_url( $settings['link']['href'] ) . ')'; + } + + return $md; + } } diff --git a/modules/atomic-widgets/elements/atomic-image/atomic-image.php b/modules/atomic-widgets/elements/atomic-image/atomic-image.php index e1561b036cc9..9c1708a4dc65 100644 --- a/modules/atomic-widgets/elements/atomic-image/atomic-image.php +++ b/modules/atomic-widgets/elements/atomic-image/atomic-image.php @@ -108,4 +108,17 @@ protected function get_templates(): array { 'elementor/elements/atomic-image' => __DIR__ . '/atomic-image.html.twig', ]; } + + public function render_markdown(): string { + $settings = $this->get_atomic_settings(); + $src = $settings['image']['src'] ?? ''; + + if ( empty( $src ) ) { + return ''; + } + + $alt = $settings['image']['alt'] ?? ''; + + return '![' . $alt . '](' . esc_url( $src ) . ')'; + } } diff --git a/modules/atomic-widgets/elements/atomic-paragraph/atomic-paragraph.php b/modules/atomic-widgets/elements/atomic-paragraph/atomic-paragraph.php index ad31216a0e0d..e0b398be25df 100644 --- a/modules/atomic-widgets/elements/atomic-paragraph/atomic-paragraph.php +++ b/modules/atomic-widgets/elements/atomic-paragraph/atomic-paragraph.php @@ -136,4 +136,15 @@ protected function get_templates(): array { 'elementor/elements/atomic-paragraph' => __DIR__ . '/atomic-paragraph.html.twig', ]; } + + public function render_markdown(): string { + $settings = $this->get_atomic_settings(); + $content = $settings['paragraph'] ?? ''; + + if ( empty( $content ) ) { + return ''; + } + + return \Elementor\Modules\MarkdownRender\Html_To_Markdown::convert( $content ); + } } diff --git a/modules/atomic-widgets/elements/atomic-svg/atomic-svg.php b/modules/atomic-widgets/elements/atomic-svg/atomic-svg.php index 0e79f40d32b8..cdbe40f9f7d2 100644 --- a/modules/atomic-widgets/elements/atomic-svg/atomic-svg.php +++ b/modules/atomic-widgets/elements/atomic-svg/atomic-svg.php @@ -227,4 +227,8 @@ private function add_svg_style( &$svg, $new_style ) { $svg->set_attribute( 'style', $svg_style ); } + + public function render_markdown(): string { + return ''; + } } diff --git a/modules/atomic-widgets/elements/atomic-youtube/atomic-youtube.php b/modules/atomic-widgets/elements/atomic-youtube/atomic-youtube.php index 9f5e92dc8ea7..ddb248bdb379 100644 --- a/modules/atomic-widgets/elements/atomic-youtube/atomic-youtube.php +++ b/modules/atomic-widgets/elements/atomic-youtube/atomic-youtube.php @@ -141,4 +141,15 @@ protected function get_templates(): array { 'elementor/elements/atomic-youtube' => __DIR__ . '/atomic-youtube.html.twig', ]; } + + public function render_markdown(): string { + $settings = $this->get_atomic_settings(); + $url = $settings['source'] ?? ''; + + if ( empty( $url ) ) { + return ''; + } + + return '[Video](' . esc_url( $url ) . ')'; + } } diff --git a/tests/phpunit/elementor/modules/markdown-render/test-widget-render-markdown.php b/tests/phpunit/elementor/modules/markdown-render/test-widget-render-markdown.php new file mode 100644 index 000000000000..4a67a6d590fd --- /dev/null +++ b/tests/phpunit/elementor/modules/markdown-render/test-widget-render-markdown.php @@ -0,0 +1,432 @@ + 'test-' . $widget_type, + 'elType' => 'widget', + 'widgetType' => $widget_type, + 'settings' => $settings, + ]; + + $element = Plugin::$instance->elements_manager->create_element_instance( $data ); + + return $element instanceof \Elementor\Widget_Base ? $element : null; + } + + public function test_heading_renders_h1_markdown() { + // Arrange + $widget = $this->create_widget( 'heading', [ + 'title' => 'Hello World', + 'header_size' => 'h1', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Heading widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEquals( '# Hello World', $md ); + } + + public function test_heading_renders_h3_with_link() { + // Arrange + $widget = $this->create_widget( 'heading', [ + 'title' => 'Linked Title', + 'header_size' => 'h3', + 'link' => [ 'url' => 'https://example.com' ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Heading widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( '### [Linked Title](https://example.com)', $md ); + } + + public function test_heading_with_br_and_span_renders_clean_text() { + // Arrange + $widget = $this->create_widget( 'heading', [ + 'title' => 'The Future of
Autonomous Robotics
Starts Here', + 'header_size' => 'h1', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Heading widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEquals( '# The Future of Autonomous Robotics Starts Here', $md ); + } + + public function test_heading_empty_title_returns_empty() { + // Arrange + $widget = $this->create_widget( 'heading', [ + 'title' => '', + 'header_size' => 'h2', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Heading widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEmpty( $md ); + } + + public function test_button_renders_link() { + // Arrange + $widget = $this->create_widget( 'button', [ + 'text' => 'Click Me', + 'link' => [ 'url' => 'https://example.com/action' ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Button widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( '[Click Me](https://example.com/action)', $md ); + } + + public function test_button_without_link_renders_bold() { + // Arrange + $widget = $this->create_widget( 'button', [ + 'text' => 'Submit', + 'link' => [ 'url' => '' ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Button widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEquals( '**Submit**', $md ); + } + + public function test_divider_renders_horizontal_rule() { + // Arrange + $widget = $this->create_widget( 'divider', [] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Divider widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEquals( '---', $md ); + } + + public function test_alert_renders_blockquote() { + // Arrange + $widget = $this->create_widget( 'alert', [ + 'alert_title' => 'Warning', + 'alert_description' => 'Something happened', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Alert widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEquals( '> **Warning:** Something happened', $md ); + } + + public function test_testimonial_renders_quote() { + // Arrange + $widget = $this->create_widget( 'testimonial', [ + 'testimonial_content' => 'Great product!', + 'testimonial_name' => 'John Doe', + 'testimonial_job' => 'CEO', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Testimonial widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( '> "Great product!"', $md ); + $this->assertStringContainsString( '-- John Doe, CEO', $md ); + } + + public function test_spacer_returns_empty() { + // Arrange + $widget = $this->create_widget( 'spacer', [] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Spacer widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEmpty( $md ); + } + + public function test_icon_returns_empty() { + // Arrange + $widget = $this->create_widget( 'icon', [] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Icon widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEmpty( $md ); + } + + public function test_icon_list_renders_list() { + // Arrange + $widget = $this->create_widget( 'icon-list', [ + 'icon_list' => [ + [ 'text' => 'Item One', 'link' => [ 'url' => '' ] ], + [ 'text' => 'Item Two', 'link' => [ 'url' => 'https://example.com' ] ], + ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Icon List widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( '- Item One', $md ); + $this->assertStringContainsString( '- [Item Two](https://example.com)', $md ); + } + + public function test_accordion_renders_sections() { + // Arrange + $widget = $this->create_widget( 'accordion', [ + 'tabs' => [ + [ 'tab_title' => 'Section 1', 'tab_content' => 'Content 1' ], + [ 'tab_title' => 'Section 2', 'tab_content' => 'Content 2' ], + ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Accordion widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( '### Section 1', $md ); + $this->assertStringContainsString( 'Content 1', $md ); + $this->assertStringContainsString( '### Section 2', $md ); + $this->assertStringContainsString( 'Content 2', $md ); + } + + public function test_progress_renders_title_inner_text_and_percent() { + // Arrange + $widget = $this->create_widget( 'progress', [ + 'title' => 'Loading Speed', + 'inner_text' => 'Autonomous Navigation Accuracy', + 'percent' => [ 'unit' => '%', 'size' => 85 ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Progress widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( 'Loading Speed', $md ); + $this->assertStringContainsString( 'Autonomous Navigation Accuracy', $md ); + $this->assertStringContainsString( '85%', $md ); + } + + public function test_progress_renders_inner_text_with_percent() { + // Arrange + $widget = $this->create_widget( 'progress', [ + 'title' => '', + 'inner_text' => 'Accuracy', + 'percent' => [ 'unit' => '%', 'size' => 92 ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Progress widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEquals( 'Accuracy: 92%', $md ); + } + + public function test_progress_renders_title_and_percent_without_inner_text() { + // Arrange + $widget = $this->create_widget( 'progress', [ + 'title' => 'Progress', + 'inner_text' => '', + 'percent' => [ 'unit' => '%', 'size' => 50 ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Progress widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEquals( 'Progress: 50%', $md ); + } + + public function test_progress_empty_returns_empty() { + // Arrange + $widget = $this->create_widget( 'progress', [ + 'title' => '', + 'inner_text' => '', + 'percent' => [ 'unit' => '%', 'size' => '' ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Progress widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEmpty( $md ); + } + + public function test_image_renders_markdown_with_url() { + // Arrange + $widget = $this->create_widget( 'image', [ + 'image' => [ 'url' => 'https://example.com/photo.jpg', 'id' => '' ], + 'link_to' => 'none', + 'caption_source' => 'none', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Image widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( '![', $md ); + $this->assertStringContainsString( 'https://example.com/photo.jpg', $md ); + } + + public function test_image_empty_url_returns_empty() { + // Arrange + $widget = $this->create_widget( 'image', [ + 'image' => [ 'url' => '', 'id' => '' ], + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Image widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertEmpty( $md ); + } + + public function test_image_with_custom_link_wraps_in_link() { + // Arrange + $widget = $this->create_widget( 'image', [ + 'image' => [ 'url' => 'https://example.com/photo.jpg', 'id' => '' ], + 'link_to' => 'custom', + 'link' => [ 'url' => 'https://example.com/page' ], + 'caption_source' => 'none', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Image widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( '[![', $md ); + $this->assertStringContainsString( 'https://example.com/page', $md ); + } + + public function test_image_with_custom_caption_renders_below_image() { + // Arrange + $widget = $this->create_widget( 'image', [ + 'image' => [ 'url' => 'https://example.com/photo.jpg', 'id' => '' ], + 'link_to' => 'none', + 'caption_source' => 'custom', + 'caption' => 'A beautiful sunset', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'Image widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertStringContainsString( "![](https://example.com/photo.jpg)\nA beautiful sunset", $md ); + } + + public function test_fallback_strips_tags() { + // Arrange + $widget = $this->create_widget( 'wordpress', [ + 'widget' => 'WP_Widget_Text', + ] ); + + if ( ! $widget ) { + $this->markTestSkipped( 'WordPress widget not available' ); + } + + // Act + $md = $widget->render_markdown(); + + // Assert + $this->assertIsString( $md ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( ' Date: Sat, 21 Feb 2026 21:27:08 +0200 Subject: [PATCH 05/38] Lint --- modules/markdown-render/markdown-renderer.php | 2 +- modules/markdown-render/module.php | 2 +- .../core/editor-documents/src/sync/__tests__/sync-store.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/markdown-render/markdown-renderer.php b/modules/markdown-render/markdown-renderer.php index c05461ada4cd..2e539ffa3b8e 100644 --- a/modules/markdown-render/markdown-renderer.php +++ b/modules/markdown-render/markdown-renderer.php @@ -75,7 +75,7 @@ private function get_meta_description( int $post_id ): string { $excerpt = get_the_excerpt( $post_id ); - return $excerpt ?: ''; + return ! empty( $excerpt ) ? $excerpt : ''; } private function render_element( array $element_data ): string { diff --git a/modules/markdown-render/module.php b/modules/markdown-render/module.php index e68bab8ca626..5db6c1b3031a 100644 --- a/modules/markdown-render/module.php +++ b/modules/markdown-render/module.php @@ -107,7 +107,7 @@ private function is_valid_preview_request( int $post_id ): bool { return false; } - $preview_id = (int) ( $_GET['preview_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $preview_id = isset( $_GET['preview_id'] ) ? absint( wp_unslash( $_GET['preview_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $preview_nonce = sanitize_text_field( wp_unslash( $_GET['preview_nonce'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $preview_id || ! wp_verify_nonce( $preview_nonce, 'post_preview_' . $preview_id ) ) { diff --git a/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts b/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts index c9d61114c775..ad4cf2595d07 100644 --- a/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts +++ b/packages/packages/core/editor-documents/src/sync/__tests__/sync-store.test.ts @@ -5,7 +5,7 @@ import { slice } from '../../store'; import { selectActiveDocument } from '../../store/selectors'; import { type Document, type ExitTo, type ExtendedWindow, type V1Document, type V1DocumentsManager } from '../../types'; import { syncStore } from '../index'; - import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentWpPreview } from '../utils'; +import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentWpPreview } from '../utils'; import { makeDocumentsManager } from './test-utils'; type WindowWithOptionalElementor = Omit< ExtendedWindow, 'elementor' > & { From 7df78270cb2de2d3e1cb225e65c8efddf13dc5ef Mon Sep 17 00:00:00 2001 From: Mati Horowitz <21468434+matipojo@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:42:10 +0200 Subject: [PATCH 06/38] Lint --- .../packages/core/editor-documents/src/sync/sync-store.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/packages/core/editor-documents/src/sync/sync-store.ts b/packages/packages/core/editor-documents/src/sync/sync-store.ts index 5b285653a367..16de781e247a 100644 --- a/packages/packages/core/editor-documents/src/sync/sync-store.ts +++ b/packages/packages/core/editor-documents/src/sync/sync-store.ts @@ -12,7 +12,13 @@ import { debounce } from '@elementor/utils'; import { slice } from '../store'; import { selectActiveDocument } from '../store/selectors'; import { type Document } from '../types'; -import { getV1DocumentPermalink, getV1DocumentsExitTo, getV1DocumentsManager, getV1DocumentWpPreview, normalizeV1Document } from './utils'; +import { + getV1DocumentPermalink, + getV1DocumentsExitTo, + getV1DocumentsManager, + getV1DocumentWpPreview, + normalizeV1Document, +} from './utils'; export function syncStore() { syncInitialization(); From ad60ef44a001d9c7caf4013d8831b5612d5f9feb Mon Sep 17 00:00:00 2001 From: Miryam Oren <77922014+MiryamOren@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:45:25 +0200 Subject: [PATCH 07/38] Fix: Prevent overridable props deletion on element move + fix overrides array structure [ED-22999] (#34826) --- .../prop-types/overrides-prop-type.php | 5 +- .../store/actions/delete-overridable-prop.ts | 48 ++++--- .../src/store/utils/groups-transformers.ts | 6 +- ...leanup-overridable-props-on-delete.test.ts | 135 ++++++++---------- .../cleanup-overridable-props-on-delete.ts | 48 +++---- .../__tests__/sync-with-document-save.test.ts | 22 ++- .../__tests__/ensure-current-user.test.tsx | 2 +- .../src/data-hooks/__tests__/index.test.ts | 6 +- .../src/data-hooks/register-data-hook.ts | 37 ++++- .../libs/editor-v1-adapters/src/index.ts | 2 +- .../tests/test-utils/create-hooks-registry.ts | 5 +- .../test-component-instance-prop-type.php | 16 ++- .../prop-types/test-overrides-prop-type.php | 70 +++++++++ 13 files changed, 269 insertions(+), 133 deletions(-) create mode 100644 tests/phpunit/elementor/modules/components/prop-types/test-overrides-prop-type.php diff --git a/modules/components/prop-types/overrides-prop-type.php b/modules/components/prop-types/overrides-prop-type.php index b8675b67abea..89970a78d931 100644 --- a/modules/components/prop-types/overrides-prop-type.php +++ b/modules/components/prop-types/overrides-prop-type.php @@ -23,7 +23,8 @@ protected function define_item_type(): Prop_Type { public function sanitize_value( $value ): array { $sanitized = parent::sanitize_value( $value ); - return array_filter( $sanitized, function( $item ) { + // array_values is used to format filtered overrides to indexed array + return array_values( array_filter( $sanitized, function( $item ) { switch ( $item['$$type'] ) { case 'override': return null !== $item['value']; @@ -31,6 +32,6 @@ public function sanitize_value( $value ): array { $override = $item['value']['origin_value']; return null !== $override['value']; } - } ); + } ) ); } } diff --git a/packages/packages/core/editor-components/src/store/actions/delete-overridable-prop.ts b/packages/packages/core/editor-components/src/store/actions/delete-overridable-prop.ts index 58908c13b02f..5b026b1f93fe 100644 --- a/packages/packages/core/editor-components/src/store/actions/delete-overridable-prop.ts +++ b/packages/packages/core/editor-components/src/store/actions/delete-overridable-prop.ts @@ -1,6 +1,6 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId } from '../../types'; +import { type ComponentId, type OverridableProp } from '../../types'; import { revertElementOverridableSetting } from '../../utils/revert-overridable-settings'; import { type Source, trackComponentEvent } from '../../utils/tracking'; import { selectCurrentComponent, selectOverridableProps, slice } from '../store'; @@ -8,26 +8,38 @@ import { removePropFromAllGroups } from '../utils/groups-transformers'; type DeletePropParams = { componentId: ComponentId; - propKey: string; + propKey: string | string[]; source: Source; }; export function deleteOverridableProp( { componentId, propKey, source }: DeletePropParams ): void { const overridableProps = selectOverridableProps( getState(), componentId ); - if ( ! overridableProps ) { + if ( ! overridableProps || Object.keys( overridableProps.props ).length === 0 ) { return; } - const prop = overridableProps.props[ propKey ]; + const propKeysToDelete = Array.isArray( propKey ) ? propKey : [ propKey ]; + const deletedProps: OverridableProp[] = []; - if ( ! prop ) { - return; + for ( const key of propKeysToDelete ) { + const prop = overridableProps.props[ key ]; + + if ( ! prop ) { + continue; + } + + deletedProps.push( prop ); + revertElementOverridableSetting( prop.elementId, prop.propKey, prop.originValue, key ); } - revertElementOverridableSetting( prop.elementId, prop.propKey, prop.originValue, propKey ); + if ( deletedProps.length === 0 ) { + return; + } - const { [ propKey ]: removedProp, ...remainingProps } = overridableProps.props; + const remainingProps = Object.fromEntries( + Object.entries( overridableProps.props ).filter( ( [ key ] ) => ! propKeysToDelete.includes( key ) ) + ); const updatedGroups = removePropFromAllGroups( overridableProps.groups, propKey ); @@ -44,13 +56,15 @@ export function deleteOverridableProp( { componentId, propKey, source }: DeleteP const currentComponent = selectCurrentComponent( getState() ); - trackComponentEvent( { - action: 'propertyRemoved', - source, - component_uid: currentComponent?.uid, - property_id: removedProp.overrideKey, - property_path: removedProp.propKey, - property_name: removedProp.label, - element_type: removedProp.widgetType ?? removedProp.elType, - } ); + for ( const prop of deletedProps ) { + trackComponentEvent( { + action: 'propertyRemoved', + source, + component_uid: currentComponent?.uid, + property_id: prop.overrideKey, + property_path: prop.propKey, + property_name: prop.label, + element_type: prop.widgetType ?? prop.elType, + } ); + } } diff --git a/packages/packages/core/editor-components/src/store/utils/groups-transformers.ts b/packages/packages/core/editor-components/src/store/utils/groups-transformers.ts index 62e4818e2f92..fefac49059e0 100644 --- a/packages/packages/core/editor-components/src/store/utils/groups-transformers.ts +++ b/packages/packages/core/editor-components/src/store/utils/groups-transformers.ts @@ -5,7 +5,9 @@ import { type OverridableProp, type OverridableProps, type OverridablePropsGroup type Groups = OverridableProps[ 'groups' ]; -export function removePropFromAllGroups( groups: Groups, propKey: string ): Groups { +export function removePropFromAllGroups( groups: Groups, propKey: string | string[] ): Groups { + const propKeys = Array.isArray( propKey ) ? propKey : [ propKey ]; + return { ...groups, items: Object.fromEntries( @@ -13,7 +15,7 @@ export function removePropFromAllGroups( groups: Groups, propKey: string ): Grou groupId, { ...group, - props: group.props.filter( ( p ) => p !== propKey ), + props: group.props.filter( ( p ) => ! propKeys.includes( p ) ), }, ] ) ), diff --git a/packages/packages/core/editor-components/src/sync/__tests__/cleanup-overridable-props-on-delete.test.ts b/packages/packages/core/editor-components/src/sync/__tests__/cleanup-overridable-props-on-delete.test.ts index 0063a5e5be9b..a8e86ecd1cbd 100644 --- a/packages/packages/core/editor-components/src/sync/__tests__/cleanup-overridable-props-on-delete.test.ts +++ b/packages/packages/core/editor-components/src/sync/__tests__/cleanup-overridable-props-on-delete.test.ts @@ -1,7 +1,8 @@ -import { createHooksRegistry, createMockElement, setupHooksRegistry } from 'test-utils'; +import { createHooksRegistry, createMockElement, setupHooksRegistry, type WindowWithHooks } from 'test-utils'; import { getAllDescendants, type V1Element } from '@elementor/editor-elements'; -import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; +import { __getState as getState } from '@elementor/store'; +import { deleteOverridableProp } from '../../store/actions/delete-overridable-prop'; import { SLICE_NAME } from '../../store/store'; import type { OverridableProps, PublishedComponent } from '../../types'; import { initCleanupOverridablePropsOnDelete } from '../cleanup-overridable-props-on-delete'; @@ -18,6 +19,10 @@ jest.mock( '@elementor/editor-elements', () => ( { getAllDescendants: jest.fn(), } ) ); +jest.mock( '../../store/actions/delete-overridable-prop', () => ( { + deleteOverridableProp: jest.fn(), +} ) ); + describe( 'initCleanupOverridablePropsOnDelete', () => { const MOCK_COMPONENT_ID = 123; const ELEMENT_ID_1 = 'element-1'; @@ -127,9 +132,12 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { }, } ); + let originalWindow: WindowWithHooks; + beforeEach( () => { jest.clearAllMocks(); setupHooksRegistry( hooksRegistry ); + originalWindow = { ...( window as unknown as WindowWithHooks ) }; mockState = { data: [ @@ -148,6 +156,11 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { } ) ); jest.mocked( getAllDescendants ).mockReturnValue( [] ); + jest.mocked( deleteOverridableProp ).mockReturnValue( undefined ); + } ); + + afterEach( () => { + ( window as unknown as WindowWithHooks ) = originalWindow; } ); it( 'should register a hook for document/elements/delete command', () => { @@ -162,7 +175,7 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { expect( registeredHooks[ 0 ].getCommand() ).toBe( 'document/elements/delete' ); } ); - it( 'should dispatch setOverridableProps with prop removed when element is deleted', () => { + it( 'should call deleteOverridableProp with prop removed when element is deleted', () => { // Arrange initCleanupOverridablePropsOnDelete(); const registeredHooks = hooksRegistry.getAll(); @@ -176,24 +189,11 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { container: deletedElement }, deletedElement ); // Assert - expect( dispatch ).toHaveBeenCalledWith( - expect.objectContaining( { - type: `${ SLICE_NAME }/setOverridableProps`, - payload: expect.objectContaining( { - componentId: MOCK_COMPONENT_ID, - overridableProps: expect.objectContaining( { - props: {}, - groups: expect.objectContaining( { - items: expect.objectContaining( { - [ GROUP_ID ]: expect.objectContaining( { - props: [], - } ), - } ), - } ), - } ), - } ), - } ) - ); + expect( deleteOverridableProp ).toHaveBeenCalledWith( { + componentId: MOCK_COMPONENT_ID, + propKey: [ PROP_KEY_1 ], + source: 'system', + } ); } ); it( 'should remove multiple props when deleting multiple elements', () => { @@ -210,15 +210,11 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { containers: [ deletedElement1, deletedElement2 ] }, [ deletedElement1, deletedElement2 ] ); // Assert - expect( dispatch ).toHaveBeenCalledWith( - expect.objectContaining( { - payload: expect.objectContaining( { - overridableProps: expect.objectContaining( { - props: {}, - } ), - } ), - } ) - ); + expect( deleteOverridableProp ).toHaveBeenCalledWith( { + componentId: MOCK_COMPONENT_ID, + propKey: [ PROP_KEY_1, PROP_KEY_2 ], + source: 'system', + } ); } ); it( 'should remove props for parent and descendant elements', () => { @@ -240,22 +236,11 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { container: parentElement }, parentElement ); // Assert - expect( dispatch ).toHaveBeenCalledWith( - expect.objectContaining( { - payload: expect.objectContaining( { - overridableProps: expect.objectContaining( { - props: {}, - groups: expect.objectContaining( { - items: expect.objectContaining( { - [ GROUP_ID ]: expect.objectContaining( { - props: [], - } ), - } ), - } ), - } ), - } ), - } ) - ); + expect( deleteOverridableProp ).toHaveBeenCalledWith( { + componentId: MOCK_COMPONENT_ID, + propKey: [ PROP_KEY_1, PROP_KEY_CHILD ], + source: 'system', + } ); } ); it( 'should only remove props for matching elements', () => { @@ -271,27 +256,14 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { container: deletedElement1 }, deletedElement1 ); // Assert - expect( dispatch ).toHaveBeenCalledWith( - expect.objectContaining( { - payload: expect.objectContaining( { - overridableProps: expect.objectContaining( { - props: { - [ PROP_KEY_2 ]: expect.anything(), - }, - groups: expect.objectContaining( { - items: expect.objectContaining( { - [ GROUP_ID ]: expect.objectContaining( { - props: [ PROP_KEY_2 ], - } ), - } ), - } ), - } ), - } ), - } ) - ); + expect( deleteOverridableProp ).toHaveBeenCalledWith( { + componentId: MOCK_COMPONENT_ID, + propKey: [ PROP_KEY_1 ], + source: 'system', + } ); } ); - it( 'should not dispatch when no matching elements', () => { + it( 'should not call deleteOverridableProp when no matching elements', () => { // Arrange initCleanupOverridablePropsOnDelete(); const registeredHooks = hooksRegistry.getAll(); @@ -303,10 +275,10 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { container: deletedElement }, deletedElement ); // Assert - expect( dispatch ).not.toHaveBeenCalled(); + expect( deleteOverridableProp ).not.toHaveBeenCalled(); } ); - it( 'should not dispatch when component has no overridable props', () => { + it( 'should not call deleteOverridableProp when component has no overridable props', () => { // Arrange mockState.data[ 0 ].overridableProps = { props: {}, groups: { items: {}, order: [] } }; initCleanupOverridablePropsOnDelete(); @@ -319,10 +291,10 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { container: deletedElement }, deletedElement ); // Assert - expect( dispatch ).not.toHaveBeenCalled(); + expect( deleteOverridableProp ).not.toHaveBeenCalled(); } ); - it( 'should not dispatch when not editing a component', () => { + it( 'should not call deleteOverridableProp when not editing a component', () => { // Arrange mockState.currentComponentId = null; initCleanupOverridablePropsOnDelete(); @@ -335,10 +307,10 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { container: deletedElement }, deletedElement ); // Assert - expect( dispatch ).not.toHaveBeenCalled(); + expect( deleteOverridableProp ).not.toHaveBeenCalled(); } ); - it( 'should not dispatch when container is null', () => { + it( 'should not call deleteOverridableProp when container is null', () => { // Arrange initCleanupOverridablePropsOnDelete(); const registeredHooks = hooksRegistry.getAll(); @@ -348,6 +320,25 @@ describe( 'initCleanupOverridablePropsOnDelete', () => { hook.apply( { container: null }, null ); // Assert - expect( dispatch ).not.toHaveBeenCalled(); + expect( deleteOverridableProp ).not.toHaveBeenCalled(); + } ); + + it( 'should not call deleteOverridableProp when part of move command', () => { + // Arrange + initCleanupOverridablePropsOnDelete(); + const registeredHooks = hooksRegistry.getAll(); + const hook = registeredHooks[ 0 ]; + + ( window as unknown as WindowWithHooks ).$e.commands = { + currentTrace: [ 'document/elements/move' ], + }; + + const deletedElement = createMockElement( { model: { id: ELEMENT_ID_1 } } ); + + // Act + hook.apply( { container: deletedElement }, { commandsCurrentTrace: [ 'document/elements/move' ] } ); + + // Assert + expect( deleteOverridableProp ).not.toHaveBeenCalled(); } ); } ); diff --git a/packages/packages/core/editor-components/src/sync/cleanup-overridable-props-on-delete.ts b/packages/packages/core/editor-components/src/sync/cleanup-overridable-props-on-delete.ts index 1a728f35af0e..b554fa403d76 100644 --- a/packages/packages/core/editor-components/src/sync/cleanup-overridable-props-on-delete.ts +++ b/packages/packages/core/editor-components/src/sync/cleanup-overridable-props-on-delete.ts @@ -1,9 +1,9 @@ import { getAllDescendants, type V1Element } from '@elementor/editor-elements'; -import { registerDataHook } from '@elementor/editor-v1-adapters'; -import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; +import { type HookOptions, registerDataHook } from '@elementor/editor-v1-adapters'; +import { __getState as getState } from '@elementor/store'; -import { type ComponentsSlice, selectCurrentComponentId, selectOverridableProps, slice } from '../store/store'; -import { removePropFromAllGroups } from '../store/utils/groups-transformers'; +import { deleteOverridableProp } from '../store/actions/delete-overridable-prop'; +import { type ComponentsSlice, selectCurrentComponentId, selectOverridableProps } from '../store/store'; type DeleteCommandArgs = { container?: V1Element; @@ -11,7 +11,14 @@ type DeleteCommandArgs = { }; export function initCleanupOverridablePropsOnDelete() { - registerDataHook( 'dependency', 'document/elements/delete', ( args: DeleteCommandArgs ) => { + // This hook is not a real dependency - it doesn't block the execution of the command in any case, only perform side effect. + // We use `dependency` and not `after` hook because the `after` hook doesn't include the children of a deleted container + // in the callback parameters (as they already were deleted). + registerDataHook( 'dependency', 'document/elements/delete', ( args: DeleteCommandArgs, options?: HookOptions ) => { + if ( isPartOfMoveCommand( options ) ) { + return true; + } + const state = getState() as ComponentsSlice | undefined; if ( ! state ) { @@ -50,25 +57,7 @@ export function initCleanupOverridablePropsOnDelete() { return true; } - const remainingProps = Object.fromEntries( - Object.entries( overridableProps.props ).filter( ( [ propKey ] ) => ! propKeysToDelete.includes( propKey ) ) - ); - - let updatedGroups = overridableProps.groups; - for ( const propKey of propKeysToDelete ) { - updatedGroups = removePropFromAllGroups( updatedGroups, propKey ); - } - - dispatch( - slice.actions.setOverridableProps( { - componentId: currentComponentId, - overridableProps: { - ...overridableProps, - props: remainingProps, - groups: updatedGroups, - }, - } ) - ); + deleteOverridableProp( { componentId: currentComponentId, propKey: propKeysToDelete, source: 'system' } ); return true; } ); @@ -83,3 +72,14 @@ function collectDeletedElementIds( containers: V1Element[] ): string[] { return elementIds; } + +function isPartOfMoveCommand( options?: HookOptions ): boolean { + // Skip cleanup if this delete is part of a move command + // Move = delete + create, and we don't want to delete the overridable prop in this case. + // See assets/dev/js/editor/document/elements/commands/move.js + const isMoveCommandInTrace = + options?.commandsCurrentTrace?.includes( 'document/elements/move' ) || + options?.commandsCurrentTrace?.includes( 'document/repeater/move' ); + + return Boolean( isMoveCommandInTrace ); +} diff --git a/packages/packages/core/editor-global-classes/src/__tests__/sync-with-document-save.test.ts b/packages/packages/core/editor-global-classes/src/__tests__/sync-with-document-save.test.ts index 94b475994bbb..3dc1b63c059a 100644 --- a/packages/packages/core/editor-global-classes/src/__tests__/sync-with-document-save.test.ts +++ b/packages/packages/core/editor-global-classes/src/__tests__/sync-with-document-save.test.ts @@ -1,6 +1,10 @@ import { createMockStyleDefinition } from 'test-utils'; import { getCurrentUser } from '@elementor/editor-current-user'; -import { __privateRunCommandSync as runCommandSync, registerDataHook } from '@elementor/editor-v1-adapters'; +import { + __privateRunCommandSync as runCommandSync, + type HookOptions, + registerDataHook, +} from '@elementor/editor-v1-adapters'; import { __createStore as createStore, __dispatch as dispatch, @@ -135,8 +139,15 @@ describe( 'syncWithDocumentSave', () => { } ); } ); +type AfterHookCallback = ( + args: Record< string, unknown >, + result: unknown, + options: HookOptions +) => unknown | Promise< void >; +type DependencyHookCallback = ( args: Record< string, unknown >, options: HookOptions ) => boolean; + function mockRegisterDataHook() { - const callbacks = new Map< string, ( args: Record< string, unknown >, result?: unknown ) => unknown >(); + const callbacks = new Map< string, AfterHookCallback | DependencyHookCallback >(); jest.mocked( registerDataHook ).mockImplementation( ( type, command, callback ) => { const key = `${ command }-${ type }`; @@ -157,6 +168,11 @@ function mockRegisterDataHook() { callbacks.delete( key ); - return callback?.( args, result ); + switch ( type ) { + case 'after': + return ( callback as AfterHookCallback )?.( args, result, { commandsCurrentTrace: [] } ); + case 'dependency': + return ( callback as DependencyHookCallback )?.( args, { commandsCurrentTrace: [] } ); + } }; } diff --git a/packages/packages/core/editor/src/__tests__/ensure-current-user.test.tsx b/packages/packages/core/editor/src/__tests__/ensure-current-user.test.tsx index b1942cf8c62d..c26d982b6ba5 100644 --- a/packages/packages/core/editor/src/__tests__/ensure-current-user.test.tsx +++ b/packages/packages/core/editor/src/__tests__/ensure-current-user.test.tsx @@ -16,7 +16,7 @@ describe( 'ensureCurrentUser', () => { it( 'should not fail the attach preview command if user fetch failed', async () => { // Arrange. jest.mocked( registerDataHook ).mockImplementation( ( _hook, _command, callback ) => { - return callback( {}, undefined ) as never; + return callback( {}, undefined, { commandsCurrentTrace: [] } ) as never; } ); jest.mocked( ensureUser ).mockRejectedValue( new Error( 'User fetch failed' ) ); diff --git a/packages/packages/libs/editor-v1-adapters/src/data-hooks/__tests__/index.test.ts b/packages/packages/libs/editor-v1-adapters/src/data-hooks/__tests__/index.test.ts index 6c9123131cce..0d5f02bca2be 100644 --- a/packages/packages/libs/editor-v1-adapters/src/data-hooks/__tests__/index.test.ts +++ b/packages/packages/libs/editor-v1-adapters/src/data-hooks/__tests__/index.test.ts @@ -40,9 +40,9 @@ describe( 'Data Hooks', () => { hook3.apply( { id: 3 } ); expect( mockFn ).toHaveBeenCalledTimes( 3 ); - expect( mockFn ).toHaveBeenNthCalledWith( 1, { id: 1 } ); - expect( mockFn ).toHaveBeenNthCalledWith( 2, { id: 2 } ); - expect( mockFn ).toHaveBeenNthCalledWith( 3, { id: 3 } ); + expect( mockFn ).toHaveBeenNthCalledWith( 1, { id: 1 }, undefined, {} ); + expect( mockFn ).toHaveBeenNthCalledWith( 2, { id: 2 }, undefined, {} ); + expect( mockFn ).toHaveBeenNthCalledWith( 3, { id: 3 }, undefined, {} ); expect( hook1.getId?.() ).not.toEqual( hook2.getId?.() ); } ); diff --git a/packages/packages/libs/editor-v1-adapters/src/data-hooks/register-data-hook.ts b/packages/packages/libs/editor-v1-adapters/src/data-hooks/register-data-hook.ts index 274be007bd95..52557e883a1f 100644 --- a/packages/packages/libs/editor-v1-adapters/src/data-hooks/register-data-hook.ts +++ b/packages/packages/libs/editor-v1-adapters/src/data-hooks/register-data-hook.ts @@ -5,6 +5,9 @@ export type WindowWithDataHooks = Window & { [ K in keyof HooksMap as Capitalize< K > ]: HooksMap[ K ]; }; }; + commands?: { + currentTrace?: string[]; + }; }; }; @@ -14,6 +17,18 @@ type HookType = 'after' | 'dependency'; export type Args = Record< string, unknown >; +export type HookOptions = { + commandsCurrentTrace?: string[]; +}; + +export type AfterHookCallback< TArgs extends Args = Args, TResult = unknown > = ( + args: TArgs, + result: TResult, + options?: HookOptions +) => void | Promise< void >; + +export type DependencyHookCallback< TArgs extends Args = Args > = ( args: TArgs, options?: HookOptions ) => boolean; + export declare class DataHook< TArgs extends Args = Args, TResult = unknown > { getCommand(): string; getId(): string; @@ -26,19 +41,19 @@ let hookId = 0; export function registerDataHook< TArgs extends Args = Args >( type: 'dependency', command: string, - callback: ( args: TArgs ) => boolean + callback: DependencyHookCallback< TArgs > ): DataHook< TArgs >; export function registerDataHook< TArgs extends Args = Args, TResult = unknown >( type: 'after', command: string, - callback: ( args: TArgs, result: TResult ) => void | Promise< void > + callback: AfterHookCallback< TArgs, TResult > ): DataHook< TArgs, TResult >; export function registerDataHook< TArgs extends Args = Args, TResult = unknown >( type: HookType, command: string, - callback: ( args: TArgs, result?: TResult ) => unknown + callback: AfterHookCallback< TArgs, TResult > | DependencyHookCallback< TArgs > ): DataHook< TArgs, TResult > { const eWindow = window as unknown as WindowWithDataHooks; const hooksClasses = eWindow.$e?.modules?.hookData; @@ -65,8 +80,20 @@ export function registerDataHook< TArgs extends Args = Args, TResult = unknown > return `${ command }--data--${ currentHookId }`; } - apply( ...args: [ TArgs, TResult ] ) { - return callback( ...args ); + apply( args: TArgs, result?: TResult ) { + const hookOptions: HookOptions = {}; + + const currentWindow = window as unknown as WindowWithDataHooks; + const commandsCurrentTrace = currentWindow.$e?.commands?.currentTrace; + if ( commandsCurrentTrace ) { + hookOptions.commandsCurrentTrace = commandsCurrentTrace; + } + + if ( type === 'dependency' ) { + return ( callback as DependencyHookCallback< TArgs > )( args, hookOptions ); + } + + return ( callback as AfterHookCallback< TArgs, TResult > )( args, result as TResult, hookOptions ); } } )(); diff --git a/packages/packages/libs/editor-v1-adapters/src/index.ts b/packages/packages/libs/editor-v1-adapters/src/index.ts index fc65b32cccd5..dfba0562acee 100644 --- a/packages/packages/libs/editor-v1-adapters/src/index.ts +++ b/packages/packages/libs/editor-v1-adapters/src/index.ts @@ -37,7 +37,7 @@ export type { HistoryItem, WindowWithHistoryManager } from './undoable/get-histo export { useEditMode, changeEditMode, type EditMode, getCurrentEditMode } from './edit-mode'; -export { registerDataHook } from './data-hooks/register-data-hook'; +export { registerDataHook, type HookOptions } from './data-hooks/register-data-hook'; export { blockCommand } from './data-hooks/block-command'; export { getCanvasIframeDocument } from './canvas'; diff --git a/packages/tests/test-utils/create-hooks-registry.ts b/packages/tests/test-utils/create-hooks-registry.ts index 84e7d57db9bf..27e08b2fd5b9 100644 --- a/packages/tests/test-utils/create-hooks-registry.ts +++ b/packages/tests/test-utils/create-hooks-registry.ts @@ -1,7 +1,7 @@ type MockHook = { getCommand: () => string; getId?: () => string; - apply: ( args: unknown, result?: unknown ) => unknown; + apply: ( args: unknown, result?: unknown, options?: unknown ) => unknown; }; type HooksRegistry = ReturnType< typeof createHooksRegistry >; @@ -14,6 +14,9 @@ export type WindowWithHooks = Window & { Dependency: ReturnType< HooksRegistry[ 'createClass' ] >; }; }; + commands?: { + currentTrace?: string[]; + }; }; }; diff --git a/tests/phpunit/elementor/modules/components/prop-types/test-component-instance-prop-type.php b/tests/phpunit/elementor/modules/components/prop-types/test-component-instance-prop-type.php index 23252dbe5584..343db4e143d0 100644 --- a/tests/phpunit/elementor/modules/components/prop-types/test-component-instance-prop-type.php +++ b/tests/phpunit/elementor/modules/components/prop-types/test-component-instance-prop-type.php @@ -140,15 +140,25 @@ public function test_sanitize__filters_out_overrides_with_override_key_not_in_co 'overrides' => [ '$$type' => 'overrides', 'value' => [ + [ + '$$type' => 'override', + 'value' => [ + 'override_key' => 'non-existent-key-1', + 'override_value' => [ '$$type' => 'string', 'value' => 'Should be removed' ], + 'schema_source' => ['type' => 'component', 'id' => self::VALID_COMPONENT_ID ], + ], + ], $this->mocks->get_mock_valid_heading_title_component_override(), + $this->mocks->get_mock_valid_heading_tag_component_override(), [ '$$type' => 'override', 'value' => [ - 'override_key' => 'non-existent-key', + 'override_key' => 'non-existent-key-2', 'override_value' => [ '$$type' => 'string', 'value' => 'Should be removed' ], 'schema_source' => ['type' => 'component', 'id' => self::VALID_COMPONENT_ID ], ], ], + $this->mocks->get_mock_valid_image_image_component_override(), ], ], ], @@ -156,8 +166,10 @@ public function test_sanitize__filters_out_overrides_with_override_key_not_in_co // Assert $overrides_array = $result['value']['overrides']['value']; - $this->assertCount( 1, $overrides_array ); + $this->assertCount( 3, $overrides_array ); $this->assertEquals( 'prop-uuid-1', $overrides_array[0]['value']['override_key'] ); + $this->assertEquals( 'prop-uuid-2', $overrides_array[1]['value']['override_key'] ); + $this->assertEquals( 'prop-uuid-3', $overrides_array[2]['value']['override_key'] ); } public function test_sanitize__handles_component_not_found() { diff --git a/tests/phpunit/elementor/modules/components/prop-types/test-overrides-prop-type.php b/tests/phpunit/elementor/modules/components/prop-types/test-overrides-prop-type.php new file mode 100644 index 000000000000..b66b1dd16e9c --- /dev/null +++ b/tests/phpunit/elementor/modules/components/prop-types/test-overrides-prop-type.php @@ -0,0 +1,70 @@ +getMockBuilder( Override_Prop_Type::class ) + ->disableOriginalConstructor()->onlyMethods( [ 'sanitize' ] )->getMock(); + + $override_prop_type_mock->method( 'sanitize' )->willReturnCallback( function ( $item ) use ( $prop_keys_to_filter_out ) { + if ( in_array( $item['value']['override_key'], $prop_keys_to_filter_out ) ) { + return [ + '$$type' => 'override', + 'value' => null, + ]; + } + + return $item; + } ); + + $prop_type = Overrides_Prop_Type::make() + ->set_item_type( $override_prop_type_mock ); + + // Act + $result = $prop_type->sanitize( [ + '$$type' => 'overrides', + 'value' => [ + $this->create_mock_override( 'prop-uuid-1' ), + $this->create_mock_override( 'prop-uuid-2' ), + $this->create_mock_override( 'prop-uuid-3' ), + $this->create_mock_override( 'prop-uuid-4' ), + $this->create_mock_override( 'prop-uuid-5' ), + ], + ] ); + + // Assert + $this->assertEquals( [ + '$$type' => 'overrides', + 'value' => [ + $this->create_mock_override( 'prop-uuid-2' ), + $this->create_mock_override( 'prop-uuid-3' ), + $this->create_mock_override( 'prop-uuid-5' ), + ], + ], $result ); + } + + private function create_mock_override( string $override_key ): array { + return [ + '$$type' => 'override', + 'value' => [ + 'override_key' => $override_key, + 'override_value' => [ '$$type' => 'string', 'value' => 'Click me' ], + 'schema_source' => ['type' => 'component', 'id' => '123' ], + ], + ]; + } +} From 7938ad3baeb3f0da422f4fa14a5f1017899da210 Mon Sep 17 00:00:00 2001 From: Nami printz <144731882+nami-p@users.noreply.github.com> Date: Sun, 22 Feb 2026 12:13:10 +0200 Subject: [PATCH 08/38] Internal: Update interactions promotion logic [ED-22093] (#34827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR Checklist - [ ] The commit message follows our guidelines: https://github.com/elementor/elementor/blob/master/.github/CONTRIBUTING.md ## PR Type What kind of change does this PR introduce? - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, local variables) - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] CI related changes - [ ] Documentation content changes - [ ] Other... Please describe: ## Summary This PR can be summarized in the following changelog entry: * ## Description An explanation of what is done in this PR * ## Test instructions This PR can be tested by following these steps: * ## Quality assurance - [ ] I have tested this code to the best of my abilities - [ ] I have added unittests to verify the code works as intended - [ ] Docs have been added / updated (for bug fixes / features) Fixes # ### ✨ PR Description Purpose: Refactor interactions promotion logic by extracting reusable PromotionSelect component to standardize upgrade prompts across trigger and easing controls. Main changes: - Created PromotionSelect component consolidating promotion UI logic from Easing and Trigger controls with configurable options - Updated test selectors to use hidden:true flag for compatibility with disablePortal menu rendering changes - Exported DEFAULT_VALUES constant and added aria-label to Field component for improved test accessibility _Generated by LinearB AI and added by gitStream._ AI-generated content may contain inaccuracies. Please verify before using. 💡 **Tip:** You can customize your AI Description using **Guidelines** [Learn how](https://docs.gitstream.cm/automation-actions/#describe-changes) --------- Co-authored-by: ElementorBot <48412871+elementorbot@users.noreply.github.com> Co-authored-by: Netanel Baba <50736016+Ntnelbaba@users.noreply.github.com> --- .../__tests__/interaction-details.test.tsx | 23 +++--- .../src/components/controls/easing.tsx | 71 +++--------------- .../src/components/controls/effect.tsx | 1 + .../src/components/controls/trigger.tsx | 43 ++++------- .../src/components/field.tsx | 2 +- .../src/components/interaction-details.tsx | 2 +- .../src/ui/promotion-select.tsx | 75 +++++++++++++++++++ .../modules/v4-tests/interactions-tab.test.ts | 52 +++++++------ 8 files changed, 143 insertions(+), 126 deletions(-) create mode 100644 packages/packages/core/editor-interactions/src/ui/promotion-select.tsx diff --git a/packages/packages/core/editor-interactions/src/__tests__/interaction-details.test.tsx b/packages/packages/core/editor-interactions/src/__tests__/interaction-details.test.tsx index 3fa05ffb5d64..21edab56bb76 100644 --- a/packages/packages/core/editor-interactions/src/__tests__/interaction-details.test.tsx +++ b/packages/packages/core/editor-interactions/src/__tests__/interaction-details.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { renderWithTheme } from 'test-utils'; +import { fireEvent, screen, within } from '@testing-library/react'; import { Direction } from '../components/controls/direction'; import { Easing } from '../components/controls/easing'; @@ -52,7 +53,7 @@ describe( 'InteractionDetails', () => { const mockOnPlayInteraction = jest.fn(); const renderInteractionDetails = ( interaction: InteractionItemValue ) => { - return render( + return renderWithTheme( { fireEvent.mouseDown( triggerSelect ); // Sanity: core UI enables only these trigger options. - expect( screen.getByRole( 'option', { name: /page load/i } ) ).toBeInTheDocument(); - expect( screen.getByRole( 'option', { name: /scroll into view/i } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'option', { name: /page load/i, hidden: true } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'option', { name: /scroll into view/i, hidden: true } ) ).toBeInTheDocument(); // Guard: Pro-only trigger should be present but disabled in the core trigger control. - const scrollOnOption = screen.getByRole( 'option', { name: /while scrolling/i } ); + const scrollOnOption = screen.getByRole( 'option', { name: /while scrolling/i, hidden: true } ); expect( scrollOnOption ).toBeInTheDocument(); expect( scrollOnOption ).toHaveAttribute( 'aria-disabled', 'true' ); } ); @@ -245,7 +246,7 @@ describe( 'InteractionDetails', () => { const comboboxes = screen.getAllByRole( 'combobox' ); const triggerSelect = comboboxes[ 0 ]; fireEvent.mouseDown( triggerSelect ); - const scrollInOption = screen.getByRole( 'option', { name: /scroll into view/i } ); + const scrollInOption = screen.getByRole( 'option', { name: /scroll into view/i, hidden: true } ); fireEvent.click( scrollInOption ); expect( mockOnChange ).toHaveBeenCalledTimes( 1 ); @@ -268,7 +269,7 @@ describe( 'InteractionDetails', () => { const effectSelect = getEffectCombobox(); fireEvent.mouseDown( effectSelect ); - const slideOption = screen.getByRole( 'option', { name: /slide/i } ); + const slideOption = screen.getByRole( 'option', { name: /slide/i, hidden: true } ); fireEvent.click( slideOption ); expect( mockOnChange ).toHaveBeenCalledTimes( 1 ); @@ -426,7 +427,7 @@ describe( 'InteractionDetails', () => { const comboboxes = screen.getAllByRole( 'combobox' ); const triggerSelect = comboboxes[ 0 ]; fireEvent.mouseDown( triggerSelect ); - const scrollInOption = screen.getByRole( 'option', { name: /scroll into view/i } ); + const scrollInOption = screen.getByRole( 'option', { name: /scroll into view/i, hidden: true } ); fireEvent.click( scrollInOption ); const updatedInteraction = mockOnChange.mock.calls[ 0 ][ 0 ]; @@ -457,7 +458,7 @@ describe( 'InteractionDetails', () => { const effectSelect = getEffectCombobox(); fireEvent.mouseDown( effectSelect ); - const slideOption = screen.getByRole( 'option', { name: /slide/i } ); + const slideOption = screen.getByRole( 'option', { name: /slide/i, hidden: true } ); fireEvent.click( slideOption ); const updatedInteraction = mockOnChange.mock.calls[ 0 ][ 0 ]; @@ -481,7 +482,7 @@ describe( 'InteractionDetails', () => { const effectSelect = getEffectCombobox(); fireEvent.mouseDown( effectSelect ); - const scaleOption = screen.getByRole( 'option', { name: /scale/i } ); + const scaleOption = screen.getByRole( 'option', { name: /scale/i, hidden: true } ); fireEvent.click( scaleOption ); const updatedInteraction = mockOnChange.mock.calls[ 0 ][ 0 ]; @@ -512,7 +513,7 @@ describe( 'InteractionDetails', () => { const effectSelect = getEffectCombobox(); fireEvent.mouseDown( effectSelect ); - const slideOption = screen.getByRole( 'option', { name: /slide/i } ); + const slideOption = screen.getByRole( 'option', { name: /slide/i, hidden: true } ); fireEvent.click( slideOption ); const updatedInteraction = mockOnChange.mock.calls[ 0 ][ 0 ]; diff --git a/packages/packages/core/editor-interactions/src/components/controls/easing.tsx b/packages/packages/core/editor-interactions/src/components/controls/easing.tsx index cf2b9ae5ce65..03d9da9aab6a 100644 --- a/packages/packages/core/editor-interactions/src/components/controls/easing.tsx +++ b/packages/packages/core/editor-interactions/src/components/controls/easing.tsx @@ -1,17 +1,15 @@ import * as React from 'react'; -import { type MouseEvent, useRef } from 'react'; -import { MenuListItem } from '@elementor/editor-ui'; -import { MenuSubheader, Select } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; import { type FieldProps } from '../../types'; -import { InteractionsPromotionChip, type InteractionsPromotionChipRef } from '../../ui/interactions-promotion-chip'; +import { PromotionSelect } from '../../ui/promotion-select'; +import { DEFAULT_VALUES } from '../interaction-details'; -const BASE_EASING_OPTIONS = { +const BASE_OPTIONS = { easeIn: __( 'Ease In', 'elementor' ), }; -const EXTENDED_EASING_OPTIONS = { +const DISABLED_OPTIONS = { easeInOut: __( 'Ease In Out', 'elementor' ), easeOut: __( 'Ease Out', 'elementor' ), backIn: __( 'Back In', 'elementor' ), @@ -20,60 +18,15 @@ const EXTENDED_EASING_OPTIONS = { linear: __( 'Linear', 'elementor' ), }; -const DEFAULT_EASING = 'easeIn'; - export function Easing( {}: FieldProps ) { - const promotionRef = useRef< InteractionsPromotionChipRef >( null ); - const anchorRef = useRef< HTMLElement >( null ); - - const baseOptions = Object.entries( BASE_EASING_OPTIONS ).map( ( [ key, label ] ) => ( { key, label } ) ); - const extendedOptions = Object.entries( EXTENDED_EASING_OPTIONS ).map( ( [ key, label ] ) => ( { key, label } ) ); - return ( - + ); } diff --git a/packages/packages/core/editor-interactions/src/components/controls/effect.tsx b/packages/packages/core/editor-interactions/src/components/controls/effect.tsx index 862d7dfe83b7..047f7c1d23cd 100644 --- a/packages/packages/core/editor-interactions/src/components/controls/effect.tsx +++ b/packages/packages/core/editor-interactions/src/components/controls/effect.tsx @@ -20,6 +20,7 @@ export function Effect( { value, onChange }: FieldProps ) { size="tiny" value={ value } onChange={ ( event: SelectChangeEvent< string > ) => onChange( event.target.value ) } + MenuProps={ { disablePortal: true } } > { availableEffects.map( ( effect ) => { return ( diff --git a/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx b/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx index b484af751ec0..4ab319213d93 100644 --- a/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx +++ b/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx @@ -1,42 +1,31 @@ import * as React from 'react'; -import { MenuListItem } from '@elementor/editor-ui'; -import { Select, type SelectChangeEvent } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; import { type FieldProps } from '../../types'; +import { PromotionSelect } from '../../ui/promotion-select'; +import { DEFAULT_VALUES } from '../interaction-details'; -const TRIGGER_OPTIONS = { +const BASE_OPTIONS = { load: __( 'Page load', 'elementor' ), scrollIn: __( 'Scroll into view', 'elementor' ), - scrollOn: __( 'While scrolling', 'elementor' ), +}; + +const DISABLED_OPTIONS = { + scrollOn: __( 'While Scrolling', 'elementor' ), hover: __( 'On hover', 'elementor' ), click: __( 'On click', 'elementor' ), }; -const SUPPORTED_TRIGGERS = [ 'load', 'scrollIn' ]; - export function Trigger( { value, onChange }: FieldProps ) { - const availableTriggers = Object.entries( TRIGGER_OPTIONS ).map( ( [ key, label ] ) => ( { - key, - label, - disabled: ! SUPPORTED_TRIGGERS.includes( key ), - } ) ); - return ( - + ); } diff --git a/packages/packages/core/editor-interactions/src/components/field.tsx b/packages/packages/core/editor-interactions/src/components/field.tsx index b28ff3601de8..71148d90f653 100644 --- a/packages/packages/core/editor-interactions/src/components/field.tsx +++ b/packages/packages/core/editor-interactions/src/components/field.tsx @@ -5,7 +5,7 @@ import { Grid } from '@elementor/ui'; export const Field = ( { label, children }: { label: string } & PropsWithChildren ) => { return ( - + { label } diff --git a/packages/packages/core/editor-interactions/src/components/interaction-details.tsx b/packages/packages/core/editor-interactions/src/components/interaction-details.tsx index 07711b38f595..9e8c42ed74a3 100644 --- a/packages/packages/core/editor-interactions/src/components/interaction-details.tsx +++ b/packages/packages/core/editor-interactions/src/components/interaction-details.tsx @@ -25,7 +25,7 @@ type InteractionDetailsProps = { onPlayInteraction: ( interactionId: string ) => void; }; -const DEFAULT_VALUES = { +export const DEFAULT_VALUES = { trigger: 'load', effect: 'fade', type: 'in', diff --git a/packages/packages/core/editor-interactions/src/ui/promotion-select.tsx b/packages/packages/core/editor-interactions/src/ui/promotion-select.tsx new file mode 100644 index 000000000000..0eb63197144a --- /dev/null +++ b/packages/packages/core/editor-interactions/src/ui/promotion-select.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { type MouseEvent, useRef } from 'react'; +import { MenuListItem } from '@elementor/editor-ui'; +import { MenuSubheader, Select, type SelectChangeEvent } from '@elementor/ui'; + +import { InteractionsPromotionChip, type InteractionsPromotionChipRef } from './interactions-promotion-chip'; + +type PromotionSelectProps = { + value: string; + onChange?: ( value: string ) => void; + baseOptions: Record< string, string >; + disabledOptions: Record< string, string >; + promotionLabel: string; + promotionContent: string; + upgradeUrl: string; +}; + +export function PromotionSelect( { + value, + onChange, + baseOptions, + disabledOptions, + promotionLabel, + promotionContent, + upgradeUrl, +}: PromotionSelectProps ) { + const promotionRef = useRef< InteractionsPromotionChipRef >( null ); + const anchorRef = useRef< HTMLElement >( null ); + + return ( + + ); +} diff --git a/tests/playwright/sanity/modules/v4-tests/interactions-tab.test.ts b/tests/playwright/sanity/modules/v4-tests/interactions-tab.test.ts index 3a81112b7720..ce887cc7eced 100644 --- a/tests/playwright/sanity/modules/v4-tests/interactions-tab.test.ts +++ b/tests/playwright/sanity/modules/v4-tests/interactions-tab.test.ts @@ -103,19 +103,19 @@ test.describe( 'Interactions Tab @v4-tests', () => { await test.step( 'Assert trigger options and hidden scrollOn-only controls', async () => { const popover = page.locator( '.MuiPopover-root' ).first(); - // Open trigger menu (default is "Page load"). - const triggerSelect = popover.getByText( 'Page load', { exact: true } ); + // Open trigger menu via the combobox (avoids duplicate text matches from disablePortal). + const triggerSelect = popover.locator( '[role="combobox"]' ).first(); await expect( triggerSelect ).toBeVisible(); await triggerSelect.click(); // Core trigger control enables only these two options. - await expect( page.getByRole( 'option', { name: 'Page load' } ) ).toBeVisible(); - await expect( page.getByRole( 'option', { name: 'Scroll into view' } ) ).toBeVisible(); + await expect( page.locator( '[role="option"]' ).filter( { hasText: 'Page load' } ) ).toBeVisible(); + await expect( page.locator( '[role="option"]' ).filter( { hasText: 'Scroll into view' } ) ).toBeVisible(); // Pro-only option is present but disabled in core runs. - const scrollOnOption = page.getByRole( 'option', { name: 'While scrolling' } ); + const scrollOnOption = page.locator( '[role="option"]' ).filter( { hasText: 'While Scrolling' } ); await expect( scrollOnOption ).toBeVisible(); - await expect( scrollOnOption ).toBeDisabled(); + await expect( scrollOnOption ).toHaveAttribute( 'aria-disabled', 'true' ); // Close menu without changing selection. await page.keyboard.press( 'Escape' ); @@ -127,7 +127,7 @@ test.describe( 'Interactions Tab @v4-tests', () => { // Switch to "Scroll into view" and ensure scrollOn-only controls still do not render. await triggerSelect.click(); - await page.getByRole( 'option', { name: 'Scroll into view' } ).click(); + await page.locator( '[role="option"]' ).filter( { hasText: 'Scroll into view' } ).click(); await expect( popover.getByText( 'Relative To', { exact: true } ) ).toHaveCount( 0 ); await expect( popover.getByText( 'Offset Top', { exact: true } ) ).toHaveCount( 0 ); @@ -160,25 +160,26 @@ test.describe( 'Interactions Tab @v4-tests', () => { const interactionTag = page.locator( '.MuiTag-root' ).first(); await expect( interactionTag ).toBeVisible(); - const popover = page.locator( '.MuiPopover-root' ); + const popover = page.locator( '.MuiPopover-root' ).first(); await popover.waitFor( { state: 'visible' } ); - // Helper function to select from a dropdown - const selectOption = async ( currentText, optionName ) => { - const button = popover.getByText( currentText, { exact: true } ); - await expect( button ).toBeVisible(); - await button.click(); - - const option = page.getByRole( 'option', { name: optionName } ); - await expect( option ).toBeVisible(); - await option.click(); + const selectInPopover = async ( combobox, optionText ) => { + await combobox.click(); + await page.locator( '[role="option"]' ).filter( { hasText: optionText } ).click(); + await page.waitForFunction( + () => ! document.querySelector( '.MuiPopover-paper .MuiModal-root' ), + { timeout: 3000 }, + ); }; + const getFieldCombobox = ( label: string ) => + popover.locator( `[aria-label="${ label } control"] [role="combobox"]` ); + // Change trigger from "Page load" to "Scroll into view" - await selectOption( 'Page load', 'Scroll into view' ); + await selectInPopover( getFieldCombobox( 'Trigger' ), 'Scroll into view' ); // Change animation from "Fade" to "Slide" - await selectOption( 'Fade', 'Slide' ); + await selectInPopover( getFieldCombobox( 'Effect' ), 'Slide' ); // Set duration to 300ms const durationInput = popover.locator( 'input[type="number"]' ).first(); @@ -312,15 +313,12 @@ test.describe( 'Interactions Tab @v4-tests', () => { } ); await test.step( 'Verify Replay control is visible with Yes button disabled', async () => { - // Arrange - const selectOption = async ( openSelector, optionName ) => { - await openSelector.click(); - - const option = page.getByRole( 'option', { name: optionName } ); - await option.click(); - }; + // Arrange – use CSS selectors to bypass aria-hidden from disablePortal. + const popover = page.locator( '.MuiPopover-root' ).first(); + const triggerCombobox = popover.locator( '[role="combobox"]' ).first(); + await triggerCombobox.click(); - await selectOption( page.getByText( 'Page load', { exact: true } ), 'Scroll into view' ); + await page.locator( '[role="option"]' ).filter( { hasText: 'Scroll into view' } ).click(); const replayLabel = page.getByText( 'Replay', { exact: true } ); From 8c9b4824a655a4e4ff44dc0e20e0e0ebd396a1db Mon Sep 17 00:00:00 2001 From: Netanel Baba <50736016+Ntnelbaba@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:01:08 +0200 Subject: [PATCH 09/38] Internal: Change variables limit to 1000 [ED-20171] (#34835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR Checklist - [ ] The commit message follows our guidelines: https://github.com/elementor/elementor/blob/master/.github/CONTRIBUTING.md ## PR Type What kind of change does this PR introduce? - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, local variables) - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] CI related changes - [ ] Documentation content changes - [ ] Other... Please describe: ## Summary This PR can be summarized in the following changelog entry: * ## Description An explanation of what is done in this PR * ## Test instructions This PR can be tested by following these steps: * ## Quality assurance - [ ] I have tested this code to the best of my abilities - [ ] I have added unittests to verify the code works as intended - [ ] Docs have been added / updated (for bug fixes / features) Fixes # ### ✨ PR Description Purpose: Refactor variables storage system to increase global variables limit from 100 to 1000 and centralize configuration constants for better maintainability. Main changes: - Created Constants class centralizing FORMAT_VERSION_V1, FORMAT_VERSION_V2, TOTAL_VARIABLES_COUNT (1000), and VARIABLES_META_KEY definitions - Migrated constant references from Variables_Collection and Repository classes to use centralized Constants class - Updated limit validation logic across repository, collection, and import-export modules to enforce new 1000 variable threshold _Generated by LinearB AI and added by gitStream._ AI-generated content may contain inaccuracies. Please verify before using. 💡 **Tip:** You can customize your AI Description using **Guidelines** [Learn how](https://docs.gitstream.cm/automation-actions/#describe-changes) --------- Co-authored-by: ElementorBot <48412871+elementorbot@users.noreply.github.com> --- .../import-export-customization/module.php | 5 +- .../variables/adapters/prop-type-adapter.php | 5 +- modules/variables/storage/constants.php | 14 +++++ modules/variables/storage/repository.php | 12 ++-- .../storage/variables-collection.php | 9 +-- .../storage/variables-repository.php | 6 +- .../variables/classes/test-rest-api.php | 9 +-- .../classes/test-variable-repository.php | 59 ++++++++++--------- .../batch-operations/test-batch-processor.php | 3 +- .../services/test-variables-service.php | 3 +- .../storage/test-variables-collection.php | 9 +-- 11 files changed, 73 insertions(+), 61 deletions(-) create mode 100644 modules/variables/storage/constants.php diff --git a/app/modules/import-export-customization/module.php b/app/modules/import-export-customization/module.php index c257bd3d1dd8..635fd72bcf7e 100644 --- a/app/modules/import-export-customization/module.php +++ b/app/modules/import-export-customization/module.php @@ -9,6 +9,7 @@ use Elementor\Modules\CloudKitLibrary\Module as CloudKitLibrary; use Elementor\Modules\GlobalClasses\Global_Classes_REST_API; use Elementor\Modules\System_Info\Reporters\Server; +use Elementor\Modules\Variables\Storage\Constants; use Elementor\Modules\Variables\Storage\Variables_Collection; use Elementor\Plugin; use Elementor\Tools; @@ -659,8 +660,8 @@ private function get_limits() { ? Global_Classes_REST_API::MAX_ITEMS : 100; - $variables_limit = class_exists( Variables_Collection::class ) - ? Variables_Collection::TOTAL_VARIABLES_COUNT + $variables_limit = class_exists( Constants::class ) + ? Constants::TOTAL_VARIABLES_COUNT : 100; return [ diff --git a/modules/variables/adapters/prop-type-adapter.php b/modules/variables/adapters/prop-type-adapter.php index 39a1136d5af0..bacddefe740b 100644 --- a/modules/variables/adapters/prop-type-adapter.php +++ b/modules/variables/adapters/prop-type-adapter.php @@ -10,6 +10,7 @@ use Elementor\Modules\Variables\PropTypes\Font_Variable_Prop_Type; use Elementor\Modules\Variables\PropTypes\Size_Variable_Prop_Type; use Elementor\Modules\Variables\Storage\Entities\Variable; +use Elementor\Modules\Variables\Storage\Constants; use Elementor\Modules\Variables\Storage\Variables_Collection; class Prop_Type_Adapter { @@ -17,7 +18,7 @@ class Prop_Type_Adapter { public static function to_storage( Variables_Collection $collection ): array { $schema = self::get_schema(); - $collection->set_version( Variables_Collection::FORMAT_VERSION_V2 ); + $collection->set_version( Constants::FORMAT_VERSION_V2 ); $record = $collection->serialize(); @@ -85,7 +86,7 @@ public static function from_storage( Variables_Collection $collection ): Variabl $variable->set_value( $value ); } ); - $collection->set_version( Variables_Collection::FORMAT_VERSION_V1 ); + $collection->set_version( Constants::FORMAT_VERSION_V1 ); return $collection; } diff --git a/modules/variables/storage/constants.php b/modules/variables/storage/constants.php new file mode 100644 index 000000000000..5a7064e14a43 --- /dev/null +++ b/modules/variables/storage/constants.php @@ -0,0 +1,14 @@ +kit->get_json_meta( static::VARIABLES_META_KEY ); + $db_record = $this->kit->get_json_meta( Constants::VARIABLES_META_KEY ); if ( is_array( $db_record ) && ! empty( $db_record ) ) { return $db_record; @@ -459,7 +455,7 @@ private function save( array $db_record ) { ++$db_record['watermark']; - if ( $this->kit->update_json_meta( static::VARIABLES_META_KEY, $db_record ) ) { + if ( $this->kit->update_json_meta( Constants::VARIABLES_META_KEY, $db_record ) ) { return $db_record['watermark']; } @@ -482,7 +478,7 @@ private function get_default_meta(): array { return [ 'data' => [], 'watermark' => 0, - 'version' => self::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ]; } diff --git a/modules/variables/storage/variables-collection.php b/modules/variables/storage/variables-collection.php index 8527f983611f..dc24c2efc8ca 100644 --- a/modules/variables/storage/variables-collection.php +++ b/modules/variables/storage/variables-collection.php @@ -15,9 +15,6 @@ * we will see if we need to extend collection as time goes on */ class Variables_Collection extends Collection { - const FORMAT_VERSION_V1 = 1; - const FORMAT_VERSION_V2 = 2; - const TOTAL_VARIABLES_COUNT = 100; private int $watermark; @@ -28,7 +25,7 @@ private function __construct( array $items = [], ?int $watermark = 0, ?int $vers $this->items = $items; $this->watermark = $watermark; - $this->version = $version ?? self::FORMAT_VERSION_V1; + $this->version = $version ?? Constants::FORMAT_VERSION_V1; } public static function hydrate( array $record ): self { @@ -76,7 +73,7 @@ public static function default(): self { return new self( [], 0, - self::FORMAT_VERSION_V1 + Constants::FORMAT_VERSION_V1 ); } @@ -144,7 +141,7 @@ public function assert_limit_not_reached(): void { } } - if ( self::TOTAL_VARIABLES_COUNT <= $active_count ) { + if ( Constants::TOTAL_VARIABLES_COUNT <= $active_count ) { throw new VariablesLimitReached( 'Total variables count limit reached' ); } } diff --git a/modules/variables/storage/variables-repository.php b/modules/variables/storage/variables-repository.php index 2321ac11241a..84ca654eebf5 100644 --- a/modules/variables/storage/variables-repository.php +++ b/modules/variables/storage/variables-repository.php @@ -6,8 +6,6 @@ use Elementor\Modules\Variables\Adapters\Prop_Type_Adapter; class Variables_Repository { - private const VARIABLES_META_KEY = '_elementor_global_variables'; - private Kit $kit; public function __construct( Kit $kit ) { @@ -15,7 +13,7 @@ public function __construct( Kit $kit ) { } public function load(): Variables_Collection { - $db_record = $this->kit->get_json_meta( self::VARIABLES_META_KEY ); + $db_record = $this->kit->get_json_meta( Constants::VARIABLES_META_KEY ); if ( is_array( $db_record ) && ! empty( $db_record ) ) { $collection = Variables_Collection::hydrate( $db_record ); @@ -33,7 +31,7 @@ public function save( Variables_Collection $collection ) { $record = Prop_Type_Adapter::to_storage( $collection ); - if ( $this->kit->update_json_meta( static::VARIABLES_META_KEY, $record ) ) { + if ( $this->kit->update_json_meta( Constants::VARIABLES_META_KEY, $record ) ) { return $collection->watermark(); } diff --git a/tests/phpunit/elementor/modules/variables/classes/test-rest-api.php b/tests/phpunit/elementor/modules/variables/classes/test-rest-api.php index 69f474112a88..b72c2118532d 100644 --- a/tests/phpunit/elementor/modules/variables/classes/test-rest-api.php +++ b/tests/phpunit/elementor/modules/variables/classes/test-rest-api.php @@ -7,6 +7,7 @@ use Elementor\Modules\Variables\Classes\Rest_Api; use Elementor\Modules\Variables\Services\Batch_Operations\Batch_Processor; use Elementor\Modules\Variables\Services\Variables_Service; +use Elementor\Modules\Variables\Storage\Constants; use Elementor\Modules\Variables\Storage\Variables_Collection; use Elementor\Modules\Variables\Storage\Variables_Repository; use Elementor\Modules\Variables\PropTypes\Color_Variable_Prop_Type; @@ -691,7 +692,7 @@ public function test_variables_limit_reached() { expects( $this->once() )-> method( 'get_json_meta' )-> willReturn( [ - 'data' => array_fill( 0, Variables_Collection::TOTAL_VARIABLES_COUNT, [ + 'data' => array_fill( 0, Constants::TOTAL_VARIABLES_COUNT, [ 'type' => Color_Variable_Prop_Type::get_key(), 'label' => 'primary-color', 'value' => '#FF0000', @@ -772,7 +773,7 @@ public function test_process_batch__successful_operations() { ], ], 'watermark' => 10, - 'version' => Variables_Collection::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit @@ -838,7 +839,7 @@ public function test_process_batch__batch_operation_failed_error() { ], ], 'watermark' => 5, - 'version' => Variables_Collection::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Act @@ -995,7 +996,7 @@ public function test_process_batch__handles_mixed_success_and_failure_operations ], ], 'watermark' => 5, - 'version' => Variables_Collection::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Act diff --git a/tests/phpunit/elementor/modules/variables/classes/test-variable-repository.php b/tests/phpunit/elementor/modules/variables/classes/test-variable-repository.php index 38ac4db87c8a..fa1092616990 100644 --- a/tests/phpunit/elementor/modules/variables/classes/test-variable-repository.php +++ b/tests/phpunit/elementor/modules/variables/classes/test-variable-repository.php @@ -5,6 +5,7 @@ use Elementor\Core\Kits\Documents\Kit; use Elementor\Modules\Variables\PropTypes\Color_Variable_Prop_Type; use Elementor\Modules\Variables\PropTypes\Font_Variable_Prop_Type; +use Elementor\Modules\Variables\Storage\Constants; use Elementor\Modules\Variables\Storage\Repository as Variables_Repository; use Elementor\Modules\Variables\Storage\Exceptions\FatalError; use Elementor\Modules\Variables\Storage\Exceptions\RecordNotFound; @@ -68,7 +69,7 @@ public function test_list_of_variables__returns_existing_data() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Act. @@ -144,7 +145,7 @@ public function test_create_new_variable__add_color_variable_to_existing_list() ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $captured_data = []; @@ -152,7 +153,7 @@ public function test_create_new_variable__add_color_variable_to_existing_list() $this->kit->expects( $this->once() ) ->method( 'update_json_meta' ) ->with( - Variables_Repository::VARIABLES_META_KEY, + Constants::VARIABLES_META_KEY, $this->callback( function ( $meta ) use ( &$captured_data ) { $captured_data = $meta['data']; @@ -191,7 +192,7 @@ public function test_create_new_variable__font_variable() { ], ], 'watermark' => 10, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $captured_data = []; @@ -199,7 +200,7 @@ public function test_create_new_variable__font_variable() { $this->kit->expects( $this->once() ) ->method( 'update_json_meta' ) ->with( - Variables_Repository::VARIABLES_META_KEY, + Constants::VARIABLES_META_KEY, $this->callback( function ( $meta ) use ( &$captured_data ) { $captured_data = $meta['data']; @@ -241,7 +242,7 @@ public function test_create_variable__throws_exception_when_has_duplicated_label ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Assert. @@ -290,7 +291,7 @@ public function test_create_variable__allows_duplicated_label_used_by_deleted_re ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -322,7 +323,7 @@ public function test_update_variable__with_valid_data() { ], ], 'watermark' => 8, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -357,7 +358,7 @@ public function test_update_variable__updating_wont_change_its_original_type() { ], ], 'watermark' => 8, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -398,7 +399,7 @@ public function test_update_variable__throws_exception_when_has_duplicated_label ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Assert. @@ -428,7 +429,7 @@ public function test_update_variable__allows_duplicate_label_for_same_id() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -464,7 +465,7 @@ public function test_update_variable__throws_exception_when_save_fails() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->method( 'update_json_meta' )->willReturn( false ); @@ -491,7 +492,7 @@ public function test_update_variable__throws_exception_when_id_not_found() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Assert. @@ -523,7 +524,7 @@ public function test_update_variable__allows_duplicated_label_used_by_deleted_re ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -559,7 +560,7 @@ public function test_delete_variable__with_existing_variable() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -586,7 +587,7 @@ public function test_delete_variable__throws_exception_when_id_not_found() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Assert. @@ -610,7 +611,7 @@ public function test_restore_variable() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -643,7 +644,7 @@ public function test_restore_variable__throws_exception_when_has_duplicated_labe ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Assert. @@ -672,7 +673,7 @@ public function test_restore_variable__allows_restoring_deleted_variable_when_no ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -709,7 +710,7 @@ public function test_restore_variable__throws_exception_when_id_not_found() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); // Assert. @@ -739,7 +740,7 @@ public function test_restore_variable__allows_restoring_when_conflict_with_delet ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -774,7 +775,7 @@ public function test_watermark__resets_when_reaching_max() { ], ], 'watermark' => PHP_INT_MAX, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $captured_watermark = null; @@ -782,7 +783,7 @@ public function test_watermark__resets_when_reaching_max() { $this->kit->expects( $this->once() ) ->method( 'update_json_meta' ) ->with( - Variables_Repository::VARIABLES_META_KEY, + Constants::VARIABLES_META_KEY, $this->callback( function ( $meta ) use ( &$captured_watermark ) { $captured_watermark = $meta['watermark']; @@ -818,7 +819,7 @@ public function test_process_atomic_batch__successful_mixed_operations() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) @@ -885,7 +886,7 @@ public function test_process_atomic_batch__throws_batch_operation_failed_with_du ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $operations = [ @@ -921,7 +922,7 @@ public function test_process_atomic_batch__throws_batch_operation_failed_with_re $this->kit->method( 'get_json_meta' )->willReturn( [ 'data' => [], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $operations = [ @@ -955,7 +956,7 @@ public function test_process_atomic_batch__ensures_atomicity_on_failure() { $this->kit->method( 'get_json_meta' )->willReturn( [ 'data' => $original_data, 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $operations = [ @@ -992,7 +993,7 @@ public function test_process_atomic_batch__throws_fatal_error_when_save_fails() $this->kit->method( 'get_json_meta' )->willReturn( [ 'data' => [], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->method( 'update_json_meta' )->willReturn( false ); @@ -1027,7 +1028,7 @@ public function test_process_atomic_batch__handles_delete_operation() { ], ], 'watermark' => 5, - 'version' => Variables_Repository::FORMAT_VERSION_V1, + 'version' => Constants::FORMAT_VERSION_V1, ] ); $this->kit->expects( $this->once() ) diff --git a/tests/phpunit/elementor/modules/variables/services/batch-operations/test-batch-processor.php b/tests/phpunit/elementor/modules/variables/services/batch-operations/test-batch-processor.php index 19a5d93a9c24..5d4a5b3bb85d 100644 --- a/tests/phpunit/elementor/modules/variables/services/batch-operations/test-batch-processor.php +++ b/tests/phpunit/elementor/modules/variables/services/batch-operations/test-batch-processor.php @@ -2,6 +2,7 @@ namespace Elementor\Modules\Variables\Services\Batch_Operations; +use Elementor\Modules\Variables\Storage\Constants; use Elementor\Modules\Variables\Storage\Exceptions\BatchOperationFailed; use Elementor\Modules\Variables\Storage\Exceptions\DuplicatedLabel; use Elementor\Modules\Variables\Storage\Exceptions\RecordNotFound; @@ -196,7 +197,7 @@ public function test_apply_operation__create_throws_duplicated_label() { public function test_apply_operation__create_throws_variables_limit_reached() { // Arrange $variables = []; - for ( $i = 0; $i < 100; $i++ ) { + for ( $i = 0; $i < Constants::TOTAL_VARIABLES_COUNT; $i++ ) { $variables[ "id-{$i}" ] = [ 'type' => 'color', 'label' => "Label {$i}", diff --git a/tests/phpunit/elementor/modules/variables/services/test-variables-service.php b/tests/phpunit/elementor/modules/variables/services/test-variables-service.php index 8c37d155c29c..6f4b547d29e3 100644 --- a/tests/phpunit/elementor/modules/variables/services/test-variables-service.php +++ b/tests/phpunit/elementor/modules/variables/services/test-variables-service.php @@ -5,6 +5,7 @@ use Elementor\Modules\Variables\Adapters\Prop_Type_Adapter; use Elementor\Modules\Variables\PropTypes\Color_Variable_Prop_Type; use Elementor\Modules\Variables\Services\Batch_Operations\Batch_Processor; +use Elementor\Modules\Variables\Storage\Constants; use Elementor\Modules\Variables\Storage\Exceptions\DuplicatedLabel; use Elementor\Modules\Variables\Storage\Exceptions\FatalError; use Elementor\Modules\Variables\Storage\Exceptions\RecordNotFound; @@ -157,7 +158,7 @@ public function test_process_batch_operations__successful() { public function test_process_batch__throws_variables_limit_reached() { // Arrange $variables = []; - for ( $i = 0; $i < 100; $i++ ) { + for ( $i = 0; $i < Constants::TOTAL_VARIABLES_COUNT; $i++ ) { $variables[ "id-{$i}" ] = [ 'type' => 'color', 'label' => "Label-{$i}", diff --git a/tests/phpunit/elementor/modules/variables/storage/test-variables-collection.php b/tests/phpunit/elementor/modules/variables/storage/test-variables-collection.php index 3acaf59f4e11..6ed585aaa584 100644 --- a/tests/phpunit/elementor/modules/variables/storage/test-variables-collection.php +++ b/tests/phpunit/elementor/modules/variables/storage/test-variables-collection.php @@ -2,6 +2,7 @@ namespace Elementor\Modules\Variables\Storage; +use Elementor\Modules\Variables\Storage\Constants; use Elementor\Modules\Variables\Storage\Entities\Variable; use Elementor\Modules\Variables\Storage\Exceptions\DuplicatedLabel; use Elementor\Modules\Variables\Storage\Exceptions\VariablesLimitReached; @@ -24,7 +25,7 @@ public function test_empty_variables__creates_empty_collection() { // Assert $this->assertEmpty( $collection->all() ); $this->assertEquals( 0, $collection->watermark() ); - $this->assertEquals( Variables_Collection::FORMAT_VERSION_V1, $collection->serialize()['version'] ); + $this->assertEquals( Constants::FORMAT_VERSION_V1, $collection->serialize()['version'] ); } public function test_hydrate__creates_collection_from_array() { @@ -331,7 +332,7 @@ public function test_assert_label_is_unique_with__ignore_id_but_different_label( public function test_assert_limit_not_reached__throws_when_at_limit() { // Arrange $variables = []; - for ( $i = 0; $i < Variables_Collection::TOTAL_VARIABLES_COUNT; $i++ ) { + for ( $i = 0; $i < Constants::TOTAL_VARIABLES_COUNT; $i++ ) { $variables[ "id-{$i}" ] = [ 'type' => 'color', 'label' => "Label {$i}", @@ -358,7 +359,7 @@ public function test_assert_limit_not_reached__throws_error_when_over_limit() { // Arrange $variables = []; - for ( $i = 0; $i < Variables_Collection::TOTAL_VARIABLES_COUNT + 1; $i++ ) { + for ( $i = 0; $i < Constants::TOTAL_VARIABLES_COUNT + 1; $i++ ) { $variables[ "id-{$i}" ] = [ 'type' => 'color', 'label' => "Label {$i}", @@ -386,7 +387,7 @@ public function test_assert_limit_not_reached__ignores_deleted_variables() { $variables = []; $variables = []; - for ( $i = 0; $i < Variables_Collection::TOTAL_VARIABLES_COUNT - 5; $i++ ) { + for ( $i = 0; $i < Constants::TOTAL_VARIABLES_COUNT - 5; $i++ ) { $variables[ "id-{$i}" ] = [ 'type' => 'color', 'label' => "Label {$i}", From b0057f61938e698cb63b31b1a3d642ebf57d2a57 Mon Sep 17 00:00:00 2001 From: Nami printz <144731882+nami-p@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:43:19 +0200 Subject: [PATCH 10/38] Fix: Update disabled trigger option label [ED-22093] (#34844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR Checklist - [ ] The commit message follows our guidelines: https://github.com/elementor/elementor/blob/master/.github/CONTRIBUTING.md ## PR Type What kind of change does this PR introduce? - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, local variables) - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] CI related changes - [ ] Documentation content changes - [ ] Other... Please describe: ## Summary This PR can be summarized in the following changelog entry: * ## Description An explanation of what is done in this PR * ## Test instructions This PR can be tested by following these steps: * ## Quality assurance - [ ] I have tested this code to the best of my abilities - [ ] I have added unittests to verify the code works as intended - [ ] Docs have been added / updated (for bug fixes / features) Fixes # ### ✨ PR Description Purpose: Fix inconsistent label capitalization for the "While scrolling" disabled trigger option to maintain consistent UI text formatting across the trigger component. Main changes: - Updated DISABLED_OPTIONS label from "While Scrolling" to "While scrolling" for consistent sentence-case capitalization across all trigger options _Generated by LinearB AI and added by gitStream._ AI-generated content may contain inaccuracies. Please verify before using. 💡 **Tip:** You can customize your AI Description using **Guidelines** [Learn how](https://docs.gitstream.cm/automation-actions/#describe-changes) --- .../editor-interactions/src/components/controls/trigger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx b/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx index 4ab319213d93..300f67da2d26 100644 --- a/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx +++ b/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx @@ -11,7 +11,7 @@ const BASE_OPTIONS = { }; const DISABLED_OPTIONS = { - scrollOn: __( 'While Scrolling', 'elementor' ), + scrollOn: __( 'While scrolling', 'elementor' ), hover: __( 'On hover', 'elementor' ), click: __( 'On click', 'elementor' ), }; From 1b3af8e693f4e2dd17654fcb7607adc8aa78d021 Mon Sep 17 00:00:00 2001 From: Netanel Baba <50736016+Ntnelbaba@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:06:43 +0200 Subject: [PATCH 11/38] Fix: Core enqueue script warnings when V4 is disabled [ED-23052] (#34847) --- modules/components/module.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/components/module.php b/modules/components/module.php index f5d599799aa2..c6d066925d81 100644 --- a/modules/components/module.php +++ b/modules/components/module.php @@ -3,6 +3,8 @@ use Elementor\Core\Base\Module as BaseModule; use Elementor\Core\Experiments\Manager as Experiments_Manager; +use Elementor\Modules\AtomicWidgets\Module as AtomicWidgetsModule; +use Elementor\Plugin; use Elementor\Modules\AtomicWidgets\PropsResolver\Transformers_Registry; use Elementor\Modules\Components\Styles\Component_Styles; use Elementor\Modules\Components\Documents\Component as Component_Document; @@ -30,6 +32,10 @@ public function get_name() { public function __construct() { parent::__construct(); + if ( ! $this->is_experiment_active() ) { + return; + } + $this->register_component_post_type(); add_filter( 'elementor/editor/v2/packages', fn ( $packages ) => $this->add_packages( $packages ) ); @@ -46,6 +52,11 @@ public function __construct() { ( new Components_REST_API() )->register_hooks(); } + public function is_experiment_active() { + return Plugin::$instance->experiments->is_feature_active( self::EXPERIMENT_NAME ) + && Plugin::$instance->experiments->is_feature_active( AtomicWidgetsModule::EXPERIMENT_NAME ); + } + public static function get_experimental_data() { return [ 'name' => self::EXPERIMENT_NAME, From 3020041b8a356a9cf5dde5757db96e6b91cd0e29 Mon Sep 17 00:00:00 2001 From: Nami printz <144731882+nami-p@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:33:04 +0200 Subject: [PATCH 12/38] Internal: Centralize interaction control options as single source of truth [ED-22093] (#34849) ## PR Checklist - [ ] The commit message follows our guidelines: https://github.com/elementor/elementor/blob/master/.github/CONTRIBUTING.md ## PR Type What kind of change does this PR introduce? - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, local variables) - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] CI related changes - [ ] Documentation content changes - [ ] Other... Please describe: ## Summary This PR can be summarized in the following changelog entry: * ## Description An explanation of what is done in this PR * ## Test instructions This PR can be tested by following these steps: * ## Quality assurance - [ ] I have tested this code to the best of my abilities - [ ] I have added unittests to verify the code works as intended - [ ] Docs have been added / updated (for bug fixes / features) Fixes # [ED-22093]: https://elementor.atlassian.net/browse/ED-22093?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../src/components/controls/easing.tsx | 19 +++++++++++------ .../src/components/controls/replay.tsx | 11 ++++++++-- .../src/components/controls/trigger.tsx | 21 ++++++++++++------- .../core/editor-interactions/src/index.ts | 5 ++++- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/packages/core/editor-interactions/src/components/controls/easing.tsx b/packages/packages/core/editor-interactions/src/components/controls/easing.tsx index 03d9da9aab6a..edeadda6c2a7 100644 --- a/packages/packages/core/editor-interactions/src/components/controls/easing.tsx +++ b/packages/packages/core/editor-interactions/src/components/controls/easing.tsx @@ -5,11 +5,8 @@ import { type FieldProps } from '../../types'; import { PromotionSelect } from '../../ui/promotion-select'; import { DEFAULT_VALUES } from '../interaction-details'; -const BASE_OPTIONS = { +export const EASING_OPTIONS = { easeIn: __( 'Ease In', 'elementor' ), -}; - -const DISABLED_OPTIONS = { easeInOut: __( 'Ease In Out', 'elementor' ), easeOut: __( 'Ease Out', 'elementor' ), backIn: __( 'Back In', 'elementor' ), @@ -18,12 +15,22 @@ const DISABLED_OPTIONS = { linear: __( 'Linear', 'elementor' ), }; +export const BASE_EASINGS: string[] = [ 'easeIn' ]; + export function Easing( {}: FieldProps ) { + const baseOptions = Object.fromEntries( + Object.entries( EASING_OPTIONS ).filter( ( [ key ] ) => BASE_EASINGS.includes( key ) ) + ); + + const disabledOptions = Object.fromEntries( + Object.entries( EASING_OPTIONS ).filter( ( [ key ] ) => ! BASE_EASINGS.includes( key ) ) + ); + return ( , showTooltip: true, }, { value: true, disabled: true, - label: __( 'Yes', 'elementor' ), + label: REPLAY_OPTIONS.yes, renderContent: ( { size } ) => , showTooltip: true, }, diff --git a/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx b/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx index 300f67da2d26..39b700e69020 100644 --- a/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx +++ b/packages/packages/core/editor-interactions/src/components/controls/trigger.tsx @@ -5,24 +5,31 @@ import { type FieldProps } from '../../types'; import { PromotionSelect } from '../../ui/promotion-select'; import { DEFAULT_VALUES } from '../interaction-details'; -const BASE_OPTIONS = { +export const TRIGGER_OPTIONS = { load: __( 'Page load', 'elementor' ), scrollIn: __( 'Scroll into view', 'elementor' ), -}; - -const DISABLED_OPTIONS = { scrollOn: __( 'While scrolling', 'elementor' ), hover: __( 'On hover', 'elementor' ), click: __( 'On click', 'elementor' ), }; +export const BASE_TRIGGERS: string[] = [ 'load', 'scrollIn' ]; + export function Trigger( { value, onChange }: FieldProps ) { + const baseOptions = Object.fromEntries( + Object.entries( TRIGGER_OPTIONS ).filter( ( [ key ] ) => BASE_TRIGGERS.includes( key ) ) + ); + + const disabledOptions = Object.fromEntries( + Object.entries( TRIGGER_OPTIONS ).filter( ( [ key ] ) => ! BASE_TRIGGERS.includes( key ) ) + ); + return ( Date: Sun, 22 Feb 2026 17:05:44 +0200 Subject: [PATCH 13/38] Internal: Prepare components extended folder [ED-23032] (#34848) --- .../prevent-non-atomic-nesting.test.ts | 2 +- .../__tests__/components-tab.test.tsx | 6 +- .../components-tab/components-item.tsx | 9 ++- .../components-tab/components-list.tsx | 18 ++--- .../components-tab/loading-components.tsx | 5 +- .../instance-editing-panel.tsx | 17 +++- .../use-resolved-origin-value.tsx | 2 +- .../components}/component-introduction.tsx | 0 .../__tests__/component-introdaction.test.tsx | 2 +- .../__tests__/component-panel-header.test.tsx | 2 +- .../component-badge.tsx | 0 .../component-panel-header.tsx | 8 +- .../component-properties-panel.test.tsx | 4 +- .../__tests__/validate-group-label.test.ts | 0 .../component-properties-panel-content.tsx | 4 +- .../component-properties-panel.tsx | 0 .../properties-empty-state.tsx | 2 +- .../properties-group.tsx | 2 +- .../property-item.tsx | 2 +- .../component-properties-panel/sortable.tsx | 0 .../use-current-editable-item.ts | 2 +- .../utils/generate-unique-label.ts | 2 +- .../utils/validate-group-label.ts | 2 +- .../__tests__/create-component-form.test.tsx | 12 +-- .../create-component-form.tsx | 10 +-- .../create-component-form/hooks/use-form.ts | 0 .../utils/component-form-schema.ts | 0 .../utils/get-component-event-data.ts | 0 .../__tests__/component-modal.test.tsx | 4 +- .../__tests__/edit-component.test.tsx | 8 +- .../edit-component/component-modal.tsx | 4 +- .../edit-component/edit-component.tsx | 6 +- .../edit-component}/use-canvas-document.ts | 0 .../edit-component}/use-element-rect.ts | 0 .../overridable-prop-control.test.tsx | 8 +- .../overridable-prop-indicator.test.tsx | 18 ++--- .../overridable-props/indicator.tsx | 0 .../overridable-prop-control.tsx | 14 ++-- .../overridable-prop-form.tsx | 2 +- .../overridable-prop-indicator.tsx | 14 ++-- .../utils/validate-prop-label.ts | 0 .../src/{components => extended}/consts.ts | 3 +- .../{ => extended}/hooks/use-navigate-back.ts | 4 +- .../editor-components/src/extended/init.ts | 77 +++++++++++++++++++ .../__tests__/save-as-component-tool.test.ts | 4 +- .../src/{ => extended}/mcp/index.ts | 0 .../mcp/save-as-component-tool.ts | 4 +- .../prevent-non-atomic-nesting.ts | 2 +- .../store/actions/add-overridable-group.ts | 6 +- .../actions/create-unpublished-component.ts | 8 +- .../store/actions/delete-overridable-group.ts | 4 +- .../store/actions/delete-overridable-prop.ts | 6 +- .../store/actions/rename-overridable-group.ts | 4 +- .../store/actions/reorder-group-props.ts | 4 +- .../actions/reorder-overridable-groups.ts | 4 +- .../actions/reset-sanitized-components.ts | 2 +- .../store/actions/set-overridable-prop.ts | 6 +- .../update-component-sanitized-attribute.ts | 4 +- .../store/actions/update-current-component.ts | 16 +--- .../actions/update-overridable-prop-params.ts | 4 +- .../store/utils/groups-transformers.ts | 4 +- ...leanup-overridable-props-on-delete.test.ts | 4 +- .../create-components-before-save.test.ts | 8 +- ...ndle-component-edit-mode-container.test.ts | 2 +- ...ate-archived-component-before-save.test.ts | 8 +- .../src/extended/sync/before-save.ts | 52 +++++++++++++ .../cleanup-overridable-props-on-delete.ts | 2 +- .../sync/create-components-before-save.ts | 8 +- .../handle-component-edit-mode-container.ts | 2 +- ...evert-overridables-on-copy-or-duplicate.ts | 2 +- ...-overridable-props-settings-before-save.ts | 4 +- .../update-archived-component-before-save.ts | 6 +- .../update-component-title-before-save.ts | 6 +- .../__tests__/is-editing-component.test.ts | 2 +- .../utils/is-editing-component.ts | 2 +- .../utils/replace-element-with-component.ts | 11 +++ .../utils/revert-overridable-settings.ts | 12 +-- .../hooks/use-component-instance-settings.ts | 15 ---- .../hooks/use-sanitize-overridable-props.ts | 4 +- .../core/editor-components/src/init.ts | 75 +----------------- .../__tests__/delete-overridable-prop.test.ts | 2 +- .../__tests__/set-overridable-prop.test.ts | 2 +- .../editor-components/src/sync/before-save.ts | 31 +------- .../filter-valid-overridable-props.test.ts | 5 +- .../__tests__/revert-all-overridables.test.ts | 5 +- ...revert-element-overridable-setting.test.ts | 2 +- .../src/utils/component-name-validation.ts | 2 +- .../create-component-model.ts} | 12 +-- .../src/utils/expand-navigator.ts | 5 -- .../utils/filter-valid-overridable-props.ts | 2 +- .../utils/get-overridable-prop.ts | 4 +- .../src/utils/switch-to-component.ts | 7 +- 92 files changed, 338 insertions(+), 323 deletions(-) rename packages/packages/core/editor-components/src/{components/components-tab => extended/components}/component-introduction.tsx (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-panel-header/__tests__/component-introdaction.test.tsx (96%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-panel-header/__tests__/component-panel-header.test.tsx (99%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-panel-header/component-badge.tsx (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-panel-header/component-panel-header.tsx (92%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/__tests__/component-properties-panel.test.tsx (99%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/__tests__/validate-group-label.test.ts (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/component-properties-panel-content.tsx (97%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/component-properties-panel.tsx (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/properties-empty-state.tsx (94%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/properties-group.tsx (99%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/property-item.tsx (98%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/sortable.tsx (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/use-current-editable-item.ts (99%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/utils/generate-unique-label.ts (88%) rename packages/packages/core/editor-components/src/{ => extended}/components/component-properties-panel/utils/validate-group-label.ts (90%) rename packages/packages/core/editor-components/src/{components => extended/components/create-component-form}/__tests__/create-component-form.test.tsx (98%) rename packages/packages/core/editor-components/src/{ => extended}/components/create-component-form/create-component-form.tsx (96%) rename packages/packages/core/editor-components/src/{ => extended}/components/create-component-form/hooks/use-form.ts (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/create-component-form/utils/component-form-schema.ts (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/create-component-form/utils/get-component-event-data.ts (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/edit-component/__tests__/component-modal.test.tsx (94%) rename packages/packages/core/editor-components/src/{ => extended}/components/edit-component/__tests__/edit-component.test.tsx (97%) rename packages/packages/core/editor-components/src/{ => extended}/components/edit-component/component-modal.tsx (95%) rename packages/packages/core/editor-components/src/{ => extended}/components/edit-component/edit-component.tsx (97%) rename packages/packages/core/editor-components/src/{hooks => extended/components/edit-component}/use-canvas-document.ts (100%) rename packages/packages/core/editor-components/src/{hooks => extended/components/edit-component}/use-element-rect.ts (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/overridable-props/__tests__/overridable-prop-control.test.tsx (98%) rename packages/packages/core/editor-components/src/{ => extended}/components/overridable-props/__tests__/overridable-prop-indicator.test.tsx (97%) rename packages/packages/core/editor-components/src/{ => extended}/components/overridable-props/indicator.tsx (100%) rename packages/packages/core/editor-components/src/{ => extended}/components/overridable-props/overridable-prop-control.tsx (86%) rename packages/packages/core/editor-components/src/{ => extended}/components/overridable-props/overridable-prop-form.tsx (98%) rename packages/packages/core/editor-components/src/{ => extended}/components/overridable-props/overridable-prop-indicator.tsx (89%) rename packages/packages/core/editor-components/src/{ => extended}/components/overridable-props/utils/validate-prop-label.ts (100%) rename packages/packages/core/editor-components/src/{components => extended}/consts.ts (99%) rename packages/packages/core/editor-components/src/{ => extended}/hooks/use-navigate-back.ts (85%) create mode 100644 packages/packages/core/editor-components/src/extended/init.ts rename packages/packages/core/editor-components/src/{ => extended}/mcp/__tests__/save-as-component-tool.test.ts (99%) rename packages/packages/core/editor-components/src/{ => extended}/mcp/index.ts (100%) rename packages/packages/core/editor-components/src/{ => extended}/mcp/save-as-component-tool.ts (99%) rename packages/packages/core/editor-components/src/{ => extended}/prevent-non-atomic-nesting.ts (98%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/add-overridable-group.ts (90%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/create-unpublished-component.ts (92%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/delete-overridable-group.ts (87%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/delete-overridable-prop.ts (91%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/rename-overridable-group.ts (87%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/reorder-group-props.ts (87%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/reorder-overridable-groups.ts (83%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/reset-sanitized-components.ts (77%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/set-overridable-prop.ts (96%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/update-component-sanitized-attribute.ts (68%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/update-current-component.ts (50%) rename packages/packages/core/editor-components/src/{ => extended}/store/actions/update-overridable-prop-params.ts (89%) rename packages/packages/core/editor-components/src/{ => extended}/store/utils/groups-transformers.ts (96%) rename packages/packages/core/editor-components/src/{ => extended}/sync/__tests__/cleanup-overridable-props-on-delete.test.ts (99%) rename packages/packages/core/editor-components/src/{ => extended}/sync/__tests__/create-components-before-save.test.ts (97%) rename packages/packages/core/editor-components/src/{ => extended}/sync/__tests__/handle-component-edit-mode-container.test.ts (99%) rename packages/packages/core/editor-components/src/{ => extended}/sync/__tests__/update-archived-component-before-save.test.ts (91%) create mode 100644 packages/packages/core/editor-components/src/extended/sync/before-save.ts rename packages/packages/core/editor-components/src/{ => extended}/sync/cleanup-overridable-props-on-delete.ts (98%) rename packages/packages/core/editor-components/src/{ => extended}/sync/create-components-before-save.ts (93%) rename packages/packages/core/editor-components/src/{ => extended}/sync/handle-component-edit-mode-container.ts (97%) rename packages/packages/core/editor-components/src/{ => extended}/sync/revert-overridables-on-copy-or-duplicate.ts (97%) rename packages/packages/core/editor-components/src/{ => extended}/sync/set-component-overridable-props-settings-before-save.ts (84%) rename packages/packages/core/editor-components/src/{ => extended}/sync/update-archived-component-before-save.ts (84%) rename packages/packages/core/editor-components/src/{ => extended}/sync/update-component-title-before-save.ts (74%) rename packages/packages/core/editor-components/src/{ => extended}/utils/__tests__/is-editing-component.test.ts (96%) rename packages/packages/core/editor-components/src/{ => extended}/utils/is-editing-component.ts (94%) create mode 100644 packages/packages/core/editor-components/src/extended/utils/replace-element-with-component.ts rename packages/packages/core/editor-components/src/{ => extended}/utils/revert-overridable-settings.ts (93%) delete mode 100644 packages/packages/core/editor-components/src/hooks/use-component-instance-settings.ts rename packages/packages/core/editor-components/src/{components/create-component-form/utils/replace-element-with-component.ts => utils/create-component-model.ts} (55%) delete mode 100644 packages/packages/core/editor-components/src/utils/expand-navigator.ts rename packages/packages/core/editor-components/src/{components/overridable-props => }/utils/get-overridable-prop.ts (76%) diff --git a/packages/packages/core/editor-components/src/__tests__/prevent-non-atomic-nesting.test.ts b/packages/packages/core/editor-components/src/__tests__/prevent-non-atomic-nesting.test.ts index 73fc53386714..b13cef33dc91 100644 --- a/packages/packages/core/editor-components/src/__tests__/prevent-non-atomic-nesting.test.ts +++ b/packages/packages/core/editor-components/src/__tests__/prevent-non-atomic-nesting.test.ts @@ -6,7 +6,7 @@ import { findNonAtomicElementsInElement, hasNonAtomicElementsInTree, isElementAtomic, -} from '../prevent-non-atomic-nesting'; +} from '../extended/prevent-non-atomic-nesting'; jest.mock( '@elementor/editor-documents', () => ( { getV1CurrentDocument: jest.fn(), diff --git a/packages/packages/core/editor-components/src/components/__tests__/components-tab.test.tsx b/packages/packages/core/editor-components/src/components/__tests__/components-tab.test.tsx index 045a81d5efd5..7a6cb3beb83c 100644 --- a/packages/packages/core/editor-components/src/components/__tests__/components-tab.test.tsx +++ b/packages/packages/core/editor-components/src/components/__tests__/components-tab.test.tsx @@ -64,7 +64,7 @@ jest.mock( '../../utils/get-container-for-new-element', () => ( { } ) ), } ) ); -jest.mock( '../create-component-form/utils/replace-element-with-component', () => ( { +jest.mock( '../../utils/create-component-model', () => ( { createComponentModel: jest.fn( ( { id, name } ) => ( { id, name, elType: 'component' } ) ), } ) ); @@ -159,7 +159,7 @@ describe( 'ComponentsTab', () => { const buttonComponent = mockComponents[ 0 ]; // Act - renderWithStore( , store ); + renderWithStore( , store ); // Assert const componentItem = screen.getByRole( 'button', { name: /Button Component/ } ); @@ -172,7 +172,7 @@ describe( 'ComponentsTab', () => { const [ buttonComponent ] = mockComponents; // Act - renderWithStore( , store ); + renderWithStore( , store ); const componentItem = screen.getByRole( 'button', { name: /Button Component/ } ); fireEvent.dragStart( componentItem ); diff --git a/packages/packages/core/editor-components/src/components/components-tab/components-item.tsx b/packages/packages/core/editor-components/src/components/components-tab/components-item.tsx index 9e996ed8ce7e..703620ae68c1 100644 --- a/packages/packages/core/editor-components/src/components/components-tab/components-item.tsx +++ b/packages/packages/core/editor-components/src/components/components-tab/components-item.tsx @@ -23,18 +23,18 @@ import { __ } from '@wordpress/i18n'; import { useComponentsPermissions } from '../../hooks/use-components-permissions'; import { archiveComponent } from '../../store/actions/archive-component'; import { loadComponentsAssets } from '../../store/actions/load-components-assets'; +import { renameComponent } from '../../store/actions/rename-component'; import { type Component } from '../../types'; import { validateComponentName } from '../../utils/component-name-validation'; +import { createComponentModel } from '../../utils/create-component-model'; import { getContainerForNewElement } from '../../utils/get-container-for-new-element'; -import { createComponentModel } from '../create-component-form/utils/replace-element-with-component'; import { DeleteConfirmationDialog } from './delete-confirmation-dialog'; type ComponentItemProps = { component: Omit< Component, 'id' > & { id?: number }; - renameComponent: ( newName: string ) => void; }; -export const ComponentItem = ( { component, renameComponent }: ComponentItemProps ) => { +export const ComponentItem = ( { component }: ComponentItemProps ) => { const itemRef = useRef< HTMLElement >( null ); const [ isDeleteDialogOpen, setIsDeleteDialogOpen ] = useState( false ); const { canRename, canDelete } = useComponentsPermissions(); @@ -49,9 +49,10 @@ export const ComponentItem = ( { component, renameComponent }: ComponentItemProp getProps: getEditableProps, } = useEditable( { value: component.name, - onSubmit: renameComponent, + onSubmit: ( newName: string ) => renameComponent( component.uid, newName ), validation: validateComponentTitle, } ); + const componentModel = createComponentModel( component ); const popupState = usePopupState( { diff --git a/packages/packages/core/editor-components/src/components/components-tab/components-list.tsx b/packages/packages/core/editor-components/src/components/components-tab/components-list.tsx index d22265c4ff0f..d4d730b38698 100644 --- a/packages/packages/core/editor-components/src/components/components-tab/components-list.tsx +++ b/packages/packages/core/editor-components/src/components/components-tab/components-list.tsx @@ -5,7 +5,6 @@ import { __ } from '@wordpress/i18n'; import { useComponents } from '../../hooks/use-components'; import { useComponentsPermissions } from '../../hooks/use-components-permissions'; -import { renameComponent } from '../../store/actions/rename-component'; import { ComponentItem } from './components-item'; import { LoadingComponents } from './loading-components'; import { useSearch } from './search-provider'; @@ -25,24 +24,17 @@ export function ComponentsList() { if ( isLoading ) { return ; } - const isEmpty = ! components || components.length === 0; + + const isEmpty = ! components?.length; + if ( isEmpty ) { - if ( searchValue.length > 0 ) { - return ; - } - return ; + return searchValue.length ? : ; } return ( { components.map( ( component ) => ( - { - renameComponent( component.uid, newName ); - } } - /> + ) ) } ); diff --git a/packages/packages/core/editor-components/src/components/components-tab/loading-components.tsx b/packages/packages/core/editor-components/src/components/components-tab/loading-components.tsx index ab2e04dde97b..d26f9d0630a8 100644 --- a/packages/packages/core/editor-components/src/components/components-tab/loading-components.tsx +++ b/packages/packages/core/editor-components/src/components/components-tab/loading-components.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { Box, ListItemButton, Skeleton, Stack } from '@elementor/ui'; -const ROWS_COUNT = 6; -const rows = Array.from( { length: ROWS_COUNT }, ( _, index ) => index ); +const ROWS = Array.from( { length: 6 }, ( _, index ) => index ); export const LoadingComponents = () => { return ( @@ -26,7 +25,7 @@ export const LoadingComponents = () => { }, } } > - { rows.map( ( row ) => ( + { ROWS.map( ( row ) => ( ); } + +function useComponentInstanceSettings() { + const { element } = useElement(); + + const settings = useElementSetting< ComponentInstancePropValue >( element.id, 'component_instance' ); + + return componentInstancePropTypeUtil.extract( settings ); +} diff --git a/packages/packages/core/editor-components/src/components/instance-editing-panel/use-resolved-origin-value.tsx b/packages/packages/core/editor-components/src/components/instance-editing-panel/use-resolved-origin-value.tsx index 67ad22a7a85a..79e344b0e1e0 100644 --- a/packages/packages/core/editor-components/src/components/instance-editing-panel/use-resolved-origin-value.tsx +++ b/packages/packages/core/editor-components/src/components/instance-editing-panel/use-resolved-origin-value.tsx @@ -5,8 +5,8 @@ import { type ComponentInstanceOverride } from '../../prop-types/component-insta import { componentOverridablePropTypeUtil } from '../../prop-types/component-overridable-prop-type'; import { selectData } from '../../store/store'; import { type OverridableProp, type PublishedComponent } from '../../types'; +import { getOverridableProp } from '../../utils/get-overridable-prop'; import { extractInnerOverrideInfo } from '../../utils/overridable-props-utils'; -import { getOverridableProp } from '../overridable-props/utils/get-overridable-prop'; export function useResolvedOriginValue( override: ComponentInstanceOverride | null, overridableProp: OverridableProp ) { const components = useSelector( selectData ); diff --git a/packages/packages/core/editor-components/src/components/components-tab/component-introduction.tsx b/packages/packages/core/editor-components/src/extended/components/component-introduction.tsx similarity index 100% rename from packages/packages/core/editor-components/src/components/components-tab/component-introduction.tsx rename to packages/packages/core/editor-components/src/extended/components/component-introduction.tsx diff --git a/packages/packages/core/editor-components/src/components/component-panel-header/__tests__/component-introdaction.test.tsx b/packages/packages/core/editor-components/src/extended/components/component-panel-header/__tests__/component-introdaction.test.tsx similarity index 96% rename from packages/packages/core/editor-components/src/components/component-panel-header/__tests__/component-introdaction.test.tsx rename to packages/packages/core/editor-components/src/extended/components/component-panel-header/__tests__/component-introdaction.test.tsx index dcab05db1bde..770e361b252c 100644 --- a/packages/packages/core/editor-components/src/components/component-panel-header/__tests__/component-introdaction.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-panel-header/__tests__/component-introdaction.test.tsx @@ -3,7 +3,7 @@ import { renderWithTheme } from 'test-utils'; import { fireEvent, screen } from '@testing-library/react'; import { __ } from '@wordpress/i18n'; -import { ComponentIntroduction } from '../../components-tab/component-introduction'; +import { ComponentIntroduction } from '../../component-introduction'; jest.mock( '@wordpress/i18n' ); diff --git a/packages/packages/core/editor-components/src/components/component-panel-header/__tests__/component-panel-header.test.tsx b/packages/packages/core/editor-components/src/extended/components/component-panel-header/__tests__/component-panel-header.test.tsx similarity index 99% rename from packages/packages/core/editor-components/src/components/component-panel-header/__tests__/component-panel-header.test.tsx rename to packages/packages/core/editor-components/src/extended/components/component-panel-header/__tests__/component-panel-header.test.tsx index 6c5f8c00022b..e88a50e2c842 100644 --- a/packages/packages/core/editor-components/src/components/component-panel-header/__tests__/component-panel-header.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-panel-header/__tests__/component-panel-header.test.tsx @@ -13,7 +13,7 @@ import { describe } from '@jest/globals'; import { fireEvent, screen } from '@testing-library/react'; import { __ } from '@wordpress/i18n'; -import { slice } from '../../../store/store'; +import { slice } from '../../../../store/store'; import { ComponentPanelHeader } from '../component-panel-header'; const mockOpenPropertiesPanel = jest.fn(); diff --git a/packages/packages/core/editor-components/src/components/component-panel-header/component-badge.tsx b/packages/packages/core/editor-components/src/extended/components/component-panel-header/component-badge.tsx similarity index 100% rename from packages/packages/core/editor-components/src/components/component-panel-header/component-badge.tsx rename to packages/packages/core/editor-components/src/extended/components/component-panel-header/component-badge.tsx diff --git a/packages/packages/core/editor-components/src/components/component-panel-header/component-panel-header.tsx b/packages/packages/core/editor-components/src/extended/components/component-panel-header/component-panel-header.tsx similarity index 92% rename from packages/packages/core/editor-components/src/components/component-panel-header/component-panel-header.tsx rename to packages/packages/core/editor-components/src/extended/components/component-panel-header/component-panel-header.tsx index 106207aee9a8..0693642a4155 100644 --- a/packages/packages/core/editor-components/src/components/component-panel-header/component-panel-header.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-panel-header/component-panel-header.tsx @@ -8,12 +8,12 @@ import { __getState as getState } from '@elementor/store'; import { Box, Divider, IconButton, Tooltip, Typography } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; +import { useSanitizeOverridableProps } from '../../../hooks/use-sanitize-overridable-props'; +import { type ComponentsSlice, SLICE_NAME, useCurrentComponent } from '../../../store/store'; +import { trackComponentEvent } from '../../../utils/tracking'; import { useNavigateBack } from '../../hooks/use-navigate-back'; -import { useSanitizeOverridableProps } from '../../hooks/use-sanitize-overridable-props'; -import { type ComponentsSlice, SLICE_NAME, useCurrentComponent } from '../../store/store'; -import { trackComponentEvent } from '../../utils/tracking'; +import { ComponentIntroduction } from '../component-introduction'; import { usePanelActions } from '../component-properties-panel/component-properties-panel'; -import { ComponentIntroduction } from '../components-tab/component-introduction'; import { ComponentsBadge } from './component-badge'; const MESSAGE_KEY = 'components-properties-introduction'; diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/__tests__/component-properties-panel.test.tsx b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/__tests__/component-properties-panel.test.tsx similarity index 99% rename from packages/packages/core/editor-components/src/components/component-properties-panel/__tests__/component-properties-panel.test.tsx rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/__tests__/component-properties-panel.test.tsx index b7560c591d26..ed2eaa4e1775 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/__tests__/component-properties-panel.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/__tests__/component-properties-panel.test.tsx @@ -10,8 +10,8 @@ import { } from '@elementor/store'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { type ComponentsSlice, selectOverridableProps, slice } from '../../../store/store'; -import { type OverridableProps, type PublishedComponent } from '../../../types'; +import { type ComponentsSlice, selectOverridableProps, slice } from '../../../../store/store'; +import { type OverridableProps, type PublishedComponent } from '../../../../types'; import { ComponentPropertiesPanelContent as ComponentPropertiesPanel } from '../component-properties-panel-content'; jest.mock( '@elementor/editor-documents', () => ( { diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/__tests__/validate-group-label.test.ts b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/__tests__/validate-group-label.test.ts similarity index 100% rename from packages/packages/core/editor-components/src/components/component-properties-panel/__tests__/validate-group-label.test.ts rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/__tests__/validate-group-label.test.ts diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/component-properties-panel-content.tsx b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/component-properties-panel-content.tsx similarity index 97% rename from packages/packages/core/editor-components/src/components/component-properties-panel/component-properties-panel-content.tsx rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/component-properties-panel-content.tsx index e797b33545c1..d314f75cfb86 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/component-properties-panel-content.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/component-properties-panel-content.tsx @@ -7,14 +7,14 @@ import { Divider, IconButton, List, Stack, Tooltip } from '@elementor/ui'; import { generateUniqueId } from '@elementor/utils'; import { __ } from '@wordpress/i18n'; -import { useSanitizeOverridableProps } from '../../hooks/use-sanitize-overridable-props'; +import { useSanitizeOverridableProps } from '../../../hooks/use-sanitize-overridable-props'; +import { useCurrentComponentId } from '../../../store/store'; import { addOverridableGroup } from '../../store/actions/add-overridable-group'; import { deleteOverridableGroup } from '../../store/actions/delete-overridable-group'; import { deleteOverridableProp } from '../../store/actions/delete-overridable-prop'; import { reorderGroupProps } from '../../store/actions/reorder-group-props'; import { reorderOverridableGroups } from '../../store/actions/reorder-overridable-groups'; import { updateOverridablePropParams } from '../../store/actions/update-overridable-prop-params'; -import { useCurrentComponentId } from '../../store/store'; import { PropertiesEmptyState } from './properties-empty-state'; import { PropertiesGroup } from './properties-group'; import { SortableItem, SortableProvider } from './sortable'; diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/component-properties-panel.tsx b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/component-properties-panel.tsx similarity index 100% rename from packages/packages/core/editor-components/src/components/component-properties-panel/component-properties-panel.tsx rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/component-properties-panel.tsx diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/properties-empty-state.tsx b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/properties-empty-state.tsx similarity index 94% rename from packages/packages/core/editor-components/src/components/component-properties-panel/properties-empty-state.tsx rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/properties-empty-state.tsx index 96b4e62be419..1d86daf08f68 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/properties-empty-state.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/properties-empty-state.tsx @@ -4,7 +4,7 @@ import { ComponentPropListIcon } from '@elementor/icons'; import { Link, Stack, Typography } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; -import { ComponentIntroduction } from '../components-tab/component-introduction'; +import { ComponentIntroduction } from '../component-introduction'; export function PropertiesEmptyState( { introductionRef }: { introductionRef: React.RefObject< HTMLButtonElement > } ) { const [ isOpen, setIsOpen ] = useState( false ); diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/properties-group.tsx b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/properties-group.tsx similarity index 99% rename from packages/packages/core/editor-components/src/components/component-properties-panel/properties-group.tsx rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/properties-group.tsx index 5f0b9bf913d9..c95e28ae9c7b 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/properties-group.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/properties-group.tsx @@ -15,7 +15,7 @@ import { } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; -import { type OverridableProp, type OverridablePropsGroup } from '../../types'; +import { type OverridableProp, type OverridablePropsGroup } from '../../../types'; import { PropertyItem } from './property-item'; import { SortableItem, SortableProvider, SortableTrigger, type SortableTriggerProps } from './sortable'; import { type GroupLabelEditableState } from './use-current-editable-item'; diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/property-item.tsx b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/property-item.tsx similarity index 98% rename from packages/packages/core/editor-components/src/components/component-properties-panel/property-item.tsx rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/property-item.tsx index e781f0b92744..bd3c37150adf 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/property-item.tsx +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/property-item.tsx @@ -3,7 +3,7 @@ import { getWidgetsCache } from '@elementor/editor-elements'; import { XIcon } from '@elementor/icons'; import { bindPopover, bindTrigger, Box, IconButton, Popover, Typography, usePopupState } from '@elementor/ui'; -import { type OverridableProp } from '../../types'; +import { type OverridableProp } from '../../../types'; import { OverridablePropForm } from '../overridable-props/overridable-prop-form'; import { SortableTrigger, type SortableTriggerProps } from './sortable'; diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/sortable.tsx b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/sortable.tsx similarity index 100% rename from packages/packages/core/editor-components/src/components/component-properties-panel/sortable.tsx rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/sortable.tsx diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/use-current-editable-item.ts b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/use-current-editable-item.ts similarity index 99% rename from packages/packages/core/editor-components/src/components/component-properties-panel/use-current-editable-item.ts rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/use-current-editable-item.ts index 7e2b144a6da3..c31a376d9861 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/use-current-editable-item.ts +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/use-current-editable-item.ts @@ -4,8 +4,8 @@ import { setDocumentModifiedStatus } from '@elementor/editor-documents'; import { useEditable } from '@elementor/editor-ui'; import { __ } from '@wordpress/i18n'; +import { useCurrentComponentId, useOverridableProps } from '../../../store/store'; import { renameOverridableGroup } from '../../store/actions/rename-overridable-group'; -import { useCurrentComponentId, useOverridableProps } from '../../store/store'; import { validateGroupLabel } from './utils/validate-group-label'; export type GroupLabelEditableState = { diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/utils/generate-unique-label.ts b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/utils/generate-unique-label.ts similarity index 88% rename from packages/packages/core/editor-components/src/components/component-properties-panel/utils/generate-unique-label.ts rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/utils/generate-unique-label.ts index c99294e616a7..6c7a4d0ebef9 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/utils/generate-unique-label.ts +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/utils/generate-unique-label.ts @@ -1,4 +1,4 @@ -import { type OverridablePropsGroup } from '../../../types'; +import { type OverridablePropsGroup } from '../../../../types'; const DEFAULT_NEW_GROUP_LABEL = 'New group'; diff --git a/packages/packages/core/editor-components/src/components/component-properties-panel/utils/validate-group-label.ts b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/utils/validate-group-label.ts similarity index 90% rename from packages/packages/core/editor-components/src/components/component-properties-panel/utils/validate-group-label.ts rename to packages/packages/core/editor-components/src/extended/components/component-properties-panel/utils/validate-group-label.ts index 65bb4db92362..1961ad0ecf72 100644 --- a/packages/packages/core/editor-components/src/components/component-properties-panel/utils/validate-group-label.ts +++ b/packages/packages/core/editor-components/src/extended/components/component-properties-panel/utils/validate-group-label.ts @@ -1,6 +1,6 @@ import { __ } from '@wordpress/i18n'; -import { type OverridablePropsGroup } from '../../../types'; +import { type OverridablePropsGroup } from '../../../../types'; export const ERROR_MESSAGES = { EMPTY_NAME: __( 'Group name is required', 'elementor' ), diff --git a/packages/packages/core/editor-components/src/components/__tests__/create-component-form.test.tsx b/packages/packages/core/editor-components/src/extended/components/create-component-form/__tests__/create-component-form.test.tsx similarity index 98% rename from packages/packages/core/editor-components/src/components/__tests__/create-component-form.test.tsx rename to packages/packages/core/editor-components/src/extended/components/create-component-form/__tests__/create-component-form.test.tsx index 7ec593824353..a8d6f88c744d 100644 --- a/packages/packages/core/editor-components/src/components/__tests__/create-component-form.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/create-component-form/__tests__/create-component-form.test.tsx @@ -17,16 +17,16 @@ import { generateUniqueId } from '@elementor/utils'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; -import { apiClient } from '../../api'; -import { selectComponents, slice } from '../../store/store'; -import { switchToComponent } from '../../utils/switch-to-component'; -import { CreateComponentForm } from '../create-component-form/create-component-form'; +import { apiClient } from '../../../../api'; +import { selectComponents, slice } from '../../../../store/store'; +import { switchToComponent } from '../../../../utils/switch-to-component'; +import { CreateComponentForm } from '../create-component-form'; jest.mock( '@elementor/editor-elements' ); -jest.mock( '../../api' ); +jest.mock( '../../../../api' ); jest.mock( '@elementor/utils' ); jest.mock( '@elementor/editor-v1-adapters' ); -jest.mock( '../../utils/switch-to-component' ); +jest.mock( '../../../../utils/switch-to-component' ); jest.mock( '@elementor/editor-notifications' ); const mockGetElementLabel = jest.mocked( getElementLabel ); diff --git a/packages/packages/core/editor-components/src/components/create-component-form/create-component-form.tsx b/packages/packages/core/editor-components/src/extended/components/create-component-form/create-component-form.tsx similarity index 96% rename from packages/packages/core/editor-components/src/components/create-component-form/create-component-form.tsx rename to packages/packages/core/editor-components/src/extended/components/create-component-form/create-component-form.tsx index 9001f1c721dd..a67e6dfa7bb8 100644 --- a/packages/packages/core/editor-components/src/components/create-component-form/create-component-form.tsx +++ b/packages/packages/core/editor-components/src/extended/components/create-component-form/create-component-form.tsx @@ -8,13 +8,13 @@ import { __getState as getState } from '@elementor/store'; import { Button, FormLabel, Grid, Popover, Stack, TextField, Typography } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; -import { useComponents } from '../../hooks/use-components'; +import { useComponents } from '../../../hooks/use-components'; +import { selectComponentByUid } from '../../../store/store'; +import { type ComponentFormValues, type PublishedComponent } from '../../../types'; +import { switchToComponent } from '../../../utils/switch-to-component'; +import { trackComponentEvent } from '../../../utils/tracking'; import { findNonAtomicElementsInElement } from '../../prevent-non-atomic-nesting'; import { createUnpublishedComponent } from '../../store/actions/create-unpublished-component'; -import { selectComponentByUid } from '../../store/store'; -import { type ComponentFormValues, type PublishedComponent } from '../../types'; -import { switchToComponent } from '../../utils/switch-to-component'; -import { trackComponentEvent } from '../../utils/tracking'; import { useForm } from './hooks/use-form'; import { createBaseComponentSchema, createSubmitComponentSchema } from './utils/component-form-schema'; import { diff --git a/packages/packages/core/editor-components/src/components/create-component-form/hooks/use-form.ts b/packages/packages/core/editor-components/src/extended/components/create-component-form/hooks/use-form.ts similarity index 100% rename from packages/packages/core/editor-components/src/components/create-component-form/hooks/use-form.ts rename to packages/packages/core/editor-components/src/extended/components/create-component-form/hooks/use-form.ts diff --git a/packages/packages/core/editor-components/src/components/create-component-form/utils/component-form-schema.ts b/packages/packages/core/editor-components/src/extended/components/create-component-form/utils/component-form-schema.ts similarity index 100% rename from packages/packages/core/editor-components/src/components/create-component-form/utils/component-form-schema.ts rename to packages/packages/core/editor-components/src/extended/components/create-component-form/utils/component-form-schema.ts diff --git a/packages/packages/core/editor-components/src/components/create-component-form/utils/get-component-event-data.ts b/packages/packages/core/editor-components/src/extended/components/create-component-form/utils/get-component-event-data.ts similarity index 100% rename from packages/packages/core/editor-components/src/components/create-component-form/utils/get-component-event-data.ts rename to packages/packages/core/editor-components/src/extended/components/create-component-form/utils/get-component-event-data.ts diff --git a/packages/packages/core/editor-components/src/components/edit-component/__tests__/component-modal.test.tsx b/packages/packages/core/editor-components/src/extended/components/edit-component/__tests__/component-modal.test.tsx similarity index 94% rename from packages/packages/core/editor-components/src/components/edit-component/__tests__/component-modal.test.tsx rename to packages/packages/core/editor-components/src/extended/components/edit-component/__tests__/component-modal.test.tsx index f110e94dfd84..7b7e2a914bb0 100644 --- a/packages/packages/core/editor-components/src/components/edit-component/__tests__/component-modal.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/edit-component/__tests__/component-modal.test.tsx @@ -2,12 +2,12 @@ import * as React from 'react'; import { renderWithTheme } from 'test-utils'; import { fireEvent, screen } from '@testing-library/react'; -import { useCanvasDocument } from '../../../hooks/use-canvas-document'; import { ComponentModal } from '../component-modal'; +import { useCanvasDocument } from '../use-canvas-document'; jest.mock( '@elementor/editor-canvas' ); jest.mock( '@elementor/editor-v1-adapters' ); -jest.mock( '../../../hooks/use-canvas-document' ); +jest.mock( '../use-canvas-document' ); describe( '', () => { let mockOnClose: jest.Mock; diff --git a/packages/packages/core/editor-components/src/components/edit-component/__tests__/edit-component.test.tsx b/packages/packages/core/editor-components/src/extended/components/edit-component/__tests__/edit-component.test.tsx similarity index 97% rename from packages/packages/core/editor-components/src/components/edit-component/__tests__/edit-component.test.tsx rename to packages/packages/core/editor-components/src/extended/components/edit-component/__tests__/edit-component.test.tsx index bfc281f55675..deaf1b7ebefe 100644 --- a/packages/packages/core/editor-components/src/components/edit-component/__tests__/edit-component.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/edit-component/__tests__/edit-component.test.tsx @@ -9,9 +9,9 @@ import { import { __createStore, __registerSlice as registerSlice, type SliceState, type Store } from '@elementor/store'; import { act, fireEvent, screen } from '@testing-library/react'; -import { apiClient } from '../../../api'; -import { slice } from '../../../store/store'; -import { COMPONENT_DOCUMENT_TYPE } from '../../consts'; +import { apiClient } from '../../../../api'; +import { slice } from '../../../../store/store'; +import { COMPONENT_DOCUMENT_TYPE } from '../../../consts'; import { EditComponent } from '../edit-component'; jest.mock( '../component-modal', () => ( { @@ -28,7 +28,7 @@ jest.mock( '@elementor/editor-documents', () => ( { invalidateDocumentData: jest.fn(), } ) ); -jest.mock( '../../../api' ); +jest.mock( '../../../../api' ); const MOCK_DOCUMENT_ID = 1; const MOCK_COMPONENT_ID = 123; diff --git a/packages/packages/core/editor-components/src/components/edit-component/component-modal.tsx b/packages/packages/core/editor-components/src/extended/components/edit-component/component-modal.tsx similarity index 95% rename from packages/packages/core/editor-components/src/components/edit-component/component-modal.tsx rename to packages/packages/core/editor-components/src/extended/components/edit-component/component-modal.tsx index ad4aad352f66..1da7597847a4 100644 --- a/packages/packages/core/editor-components/src/components/edit-component/component-modal.tsx +++ b/packages/packages/core/editor-components/src/extended/components/edit-component/component-modal.tsx @@ -3,8 +3,8 @@ import { type CSSProperties, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { __ } from '@wordpress/i18n'; -import { useCanvasDocument } from '../../hooks/use-canvas-document'; -import { useElementRect } from '../../hooks/use-element-rect'; +import { useCanvasDocument } from './use-canvas-document'; +import { useElementRect } from './use-element-rect'; type ModalProps = { topLevelElementDom: HTMLElement | null; diff --git a/packages/packages/core/editor-components/src/components/edit-component/edit-component.tsx b/packages/packages/core/editor-components/src/extended/components/edit-component/edit-component.tsx similarity index 97% rename from packages/packages/core/editor-components/src/components/edit-component/edit-component.tsx rename to packages/packages/core/editor-components/src/extended/components/edit-component/edit-component.tsx index a636ad63fc80..800882816d3a 100644 --- a/packages/packages/core/editor-components/src/components/edit-component/edit-component.tsx +++ b/packages/packages/core/editor-components/src/extended/components/edit-component/edit-component.tsx @@ -6,12 +6,12 @@ import { __privateListenTo as listenTo, commandEndEvent } from '@elementor/edito import { __useSelector as useSelector } from '@elementor/store'; import { throttle } from '@elementor/utils'; -import { apiClient } from '../../api'; +import { apiClient } from '../../../api'; +import { type ComponentsPathItem, selectPath, useCurrentComponentId } from '../../../store/store'; +import { COMPONENT_DOCUMENT_TYPE } from '../../consts'; import { useNavigateBack } from '../../hooks/use-navigate-back'; import { resetSanitizedComponents } from '../../store/actions/reset-sanitized-components'; import { updateCurrentComponent } from '../../store/actions/update-current-component'; -import { type ComponentsPathItem, selectPath, useCurrentComponentId } from '../../store/store'; -import { COMPONENT_DOCUMENT_TYPE } from '../consts'; import { ComponentModal } from './component-modal'; export function EditComponent() { diff --git a/packages/packages/core/editor-components/src/hooks/use-canvas-document.ts b/packages/packages/core/editor-components/src/extended/components/edit-component/use-canvas-document.ts similarity index 100% rename from packages/packages/core/editor-components/src/hooks/use-canvas-document.ts rename to packages/packages/core/editor-components/src/extended/components/edit-component/use-canvas-document.ts diff --git a/packages/packages/core/editor-components/src/hooks/use-element-rect.ts b/packages/packages/core/editor-components/src/extended/components/edit-component/use-element-rect.ts similarity index 100% rename from packages/packages/core/editor-components/src/hooks/use-element-rect.ts rename to packages/packages/core/editor-components/src/extended/components/edit-component/use-element-rect.ts diff --git a/packages/packages/core/editor-components/src/components/overridable-props/__tests__/overridable-prop-control.test.tsx b/packages/packages/core/editor-components/src/extended/components/overridable-props/__tests__/overridable-prop-control.test.tsx similarity index 98% rename from packages/packages/core/editor-components/src/components/overridable-props/__tests__/overridable-prop-control.test.tsx rename to packages/packages/core/editor-components/src/extended/components/overridable-props/__tests__/overridable-prop-control.test.tsx index fecf88a40685..85e782f3e48e 100644 --- a/packages/packages/core/editor-components/src/components/overridable-props/__tests__/overridable-prop-control.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/overridable-props/__tests__/overridable-prop-control.test.tsx @@ -15,10 +15,10 @@ import { import { ErrorBoundary } from '@elementor/ui'; import { fireEvent, screen } from '@testing-library/react'; -import { componentOverridablePropTypeUtil } from '../../../prop-types/component-overridable-prop-type'; -import { useOverridablePropValue } from '../../../provider/overridable-prop-context'; -import { type ComponentsSlice, selectOverridableProps, slice } from '../../../store/store'; -import { type OverridableProp, type OverridableProps, type PublishedComponent } from '../../../types'; +import { componentOverridablePropTypeUtil } from '../../../../prop-types/component-overridable-prop-type'; +import { useOverridablePropValue } from '../../../../provider/overridable-prop-context'; +import { type ComponentsSlice, selectOverridableProps, slice } from '../../../../store/store'; +import { type OverridableProp, type OverridableProps, type PublishedComponent } from '../../../../types'; import { OverridablePropControl } from '../overridable-prop-control'; const mockGetControlReplacements = jest.fn< ControlReplacement[], [] >( () => [] ); diff --git a/packages/packages/core/editor-components/src/components/overridable-props/__tests__/overridable-prop-indicator.test.tsx b/packages/packages/core/editor-components/src/extended/components/overridable-props/__tests__/overridable-prop-indicator.test.tsx similarity index 97% rename from packages/packages/core/editor-components/src/components/overridable-props/__tests__/overridable-prop-indicator.test.tsx rename to packages/packages/core/editor-components/src/extended/components/overridable-props/__tests__/overridable-prop-indicator.test.tsx index 2c266dcb4d45..b4e4fbc936d0 100644 --- a/packages/packages/core/editor-components/src/components/overridable-props/__tests__/overridable-prop-indicator.test.tsx +++ b/packages/packages/core/editor-components/src/extended/components/overridable-props/__tests__/overridable-prop-indicator.test.tsx @@ -13,14 +13,14 @@ import { } from '@elementor/store'; import { fireEvent, screen } from '@testing-library/react'; -import { componentInstanceOverridePropTypeUtil } from '../../../prop-types/component-instance-override-prop-type'; -import { componentInstanceOverridesPropTypeUtil } from '../../../prop-types/component-instance-overrides-prop-type'; -import { componentInstancePropTypeUtil } from '../../../prop-types/component-instance-prop-type'; -import { componentOverridablePropTypeUtil } from '../../../prop-types/component-overridable-prop-type'; -import { OverridablePropProvider } from '../../../provider/overridable-prop-context'; -import { type ComponentsSlice, selectOverridableProps, slice } from '../../../store/store'; -import { type PublishedComponent } from '../../../types'; -import { getContainerByOriginId } from '../../../utils/get-container-by-origin-id'; +import { componentInstanceOverridePropTypeUtil } from '../../../../prop-types/component-instance-override-prop-type'; +import { componentInstanceOverridesPropTypeUtil } from '../../../../prop-types/component-instance-overrides-prop-type'; +import { componentInstancePropTypeUtil } from '../../../../prop-types/component-instance-prop-type'; +import { componentOverridablePropTypeUtil } from '../../../../prop-types/component-overridable-prop-type'; +import { OverridablePropProvider } from '../../../../provider/overridable-prop-context'; +import { type ComponentsSlice, selectOverridableProps, slice } from '../../../../store/store'; +import { type PublishedComponent } from '../../../../types'; +import { getContainerByOriginId } from '../../../../utils/get-container-by-origin-id'; import { OverridablePropIndicator } from '../overridable-prop-indicator'; jest.mock( '@elementor/editor-controls', () => ( { @@ -36,7 +36,7 @@ jest.mock( '@elementor/editor-elements', () => ( { getWidgetsCache: jest.fn(), getElementSetting: jest.fn(), } ) ); -jest.mock( '../../../utils/get-container-by-origin-id', () => ( { +jest.mock( '../../../../utils/get-container-by-origin-id', () => ( { getContainerByOriginId: jest.fn(), } ) ); diff --git a/packages/packages/core/editor-components/src/components/overridable-props/indicator.tsx b/packages/packages/core/editor-components/src/extended/components/overridable-props/indicator.tsx similarity index 100% rename from packages/packages/core/editor-components/src/components/overridable-props/indicator.tsx rename to packages/packages/core/editor-components/src/extended/components/overridable-props/indicator.tsx diff --git a/packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-control.tsx b/packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-control.tsx similarity index 86% rename from packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-control.tsx rename to packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-control.tsx index 37a6dd92e566..25e479aea4da 100644 --- a/packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-control.tsx +++ b/packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-control.tsx @@ -11,16 +11,16 @@ import { import { createTopLevelObjectType, useElement } from '@elementor/editor-editing-panel'; import { type PropValue } from '@elementor/editor-props'; -import { type ComponentInstanceOverridePropValue } from '../../prop-types/component-instance-override-prop-type'; +import { type ComponentInstanceOverridePropValue } from '../../../prop-types/component-instance-override-prop-type'; import { componentOverridablePropTypeUtil, type ComponentOverridablePropValue, -} from '../../prop-types/component-overridable-prop-type'; -import { OverridablePropProvider } from '../../provider/overridable-prop-context'; -import { updateOverridableProp } from '../../store/actions/update-overridable-prop'; -import { useCurrentComponentId, useOverridableProps } from '../../store/store'; -import { getPropTypeForComponentOverride } from '../../utils/get-prop-type-for-component-override'; -import { OVERRIDABLE_PROP_REPLACEMENT_ID } from '../consts'; +} from '../../../prop-types/component-overridable-prop-type'; +import { OverridablePropProvider } from '../../../provider/overridable-prop-context'; +import { updateOverridableProp } from '../../../store/actions/update-overridable-prop'; +import { useCurrentComponentId, useOverridableProps } from '../../../store/store'; +import { getPropTypeForComponentOverride } from '../../../utils/get-prop-type-for-component-override'; +import { OVERRIDABLE_PROP_REPLACEMENT_ID } from '../../consts'; export function OverridablePropControl< T extends object >( { OriginalControl, diff --git a/packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-form.tsx b/packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-form.tsx similarity index 98% rename from packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-form.tsx rename to packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-form.tsx index 3c1fb95e1882..f099630bcbbe 100644 --- a/packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-form.tsx +++ b/packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-form.tsx @@ -4,7 +4,7 @@ import { Form, MenuListItem } from '@elementor/editor-ui'; import { Button, FormLabel, Grid, Select, Stack, type SxProps, TextField, Typography } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; -import { type OverridableProp } from '../../types'; +import { type OverridableProp } from '../../../types'; import { validatePropLabel } from './utils/validate-prop-label'; const SIZE = 'tiny'; diff --git a/packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-indicator.tsx b/packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-indicator.tsx similarity index 89% rename from packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-indicator.tsx rename to packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-indicator.tsx index 8973ff1059ce..c98abdbe0774 100644 --- a/packages/packages/core/editor-components/src/components/overridable-props/overridable-prop-indicator.tsx +++ b/packages/packages/core/editor-components/src/extended/components/overridable-props/overridable-prop-indicator.tsx @@ -6,16 +6,16 @@ import { type PropType, type TransformablePropValue } from '@elementor/editor-pr import { bindPopover, bindTrigger, Popover, Tooltip, usePopupState } from '@elementor/ui'; import { __ } from '@wordpress/i18n'; -import { useSanitizeOverridableProps } from '../../hooks/use-sanitize-overridable-props'; -import { componentOverridablePropTypeUtil } from '../../prop-types/component-overridable-prop-type'; -import { useComponentInstanceElement, useOverridablePropValue } from '../../provider/overridable-prop-context'; +import { useSanitizeOverridableProps } from '../../../hooks/use-sanitize-overridable-props'; +import { componentOverridablePropTypeUtil } from '../../../prop-types/component-overridable-prop-type'; +import { useComponentInstanceElement, useOverridablePropValue } from '../../../provider/overridable-prop-context'; +import { useCurrentComponentId } from '../../../store/store'; +import { type OverridableProps } from '../../../types'; +import { getOverridableProp } from '../../../utils/get-overridable-prop'; +import { resolveOverridePropValue } from '../../../utils/resolve-override-prop-value'; import { setOverridableProp } from '../../store/actions/set-overridable-prop'; -import { useCurrentComponentId } from '../../store/store'; -import { type OverridableProps } from '../../types'; -import { resolveOverridePropValue } from '../../utils/resolve-override-prop-value'; import { Indicator } from './indicator'; import { OverridablePropForm } from './overridable-prop-form'; -import { getOverridableProp } from './utils/get-overridable-prop'; export function OverridablePropIndicator() { const { propType } = useBoundProp(); diff --git a/packages/packages/core/editor-components/src/components/overridable-props/utils/validate-prop-label.ts b/packages/packages/core/editor-components/src/extended/components/overridable-props/utils/validate-prop-label.ts similarity index 100% rename from packages/packages/core/editor-components/src/components/overridable-props/utils/validate-prop-label.ts rename to packages/packages/core/editor-components/src/extended/components/overridable-props/utils/validate-prop-label.ts diff --git a/packages/packages/core/editor-components/src/components/consts.ts b/packages/packages/core/editor-components/src/extended/consts.ts similarity index 99% rename from packages/packages/core/editor-components/src/components/consts.ts rename to packages/packages/core/editor-components/src/extended/consts.ts index df2e0f5d130a..a40b2b5a5f14 100644 --- a/packages/packages/core/editor-components/src/components/consts.ts +++ b/packages/packages/core/editor-components/src/extended/consts.ts @@ -1,2 +1,3 @@ -export const COMPONENT_DOCUMENT_TYPE = 'elementor_component'; export const OVERRIDABLE_PROP_REPLACEMENT_ID = 'overridable-prop'; + +export const COMPONENT_DOCUMENT_TYPE = 'elementor_component'; diff --git a/packages/packages/core/editor-components/src/hooks/use-navigate-back.ts b/packages/packages/core/editor-components/src/extended/hooks/use-navigate-back.ts similarity index 85% rename from packages/packages/core/editor-components/src/hooks/use-navigate-back.ts rename to packages/packages/core/editor-components/src/extended/hooks/use-navigate-back.ts index e58e79282e8f..9a4f2243231a 100644 --- a/packages/packages/core/editor-components/src/hooks/use-navigate-back.ts +++ b/packages/packages/core/editor-components/src/extended/hooks/use-navigate-back.ts @@ -2,8 +2,8 @@ import { useCallback } from 'react'; import { getV1DocumentsManager } from '@elementor/editor-documents'; import { __useSelector as useSelector } from '@elementor/store'; -import { selectPath } from '../store/store'; -import { switchToComponent } from '../utils/switch-to-component'; +import { selectPath } from '../../store/store'; +import { switchToComponent } from '../../utils/switch-to-component'; export function useNavigateBack() { const path = useSelector( selectPath ); diff --git a/packages/packages/core/editor-components/src/extended/init.ts b/packages/packages/core/editor-components/src/extended/init.ts new file mode 100644 index 000000000000..7fbfa6a00ee0 --- /dev/null +++ b/packages/packages/core/editor-components/src/extended/init.ts @@ -0,0 +1,77 @@ +import { injectIntoTop } from '@elementor/editor'; +import { registerControlReplacement } from '@elementor/editor-controls'; +import { getV1CurrentDocument } from '@elementor/editor-documents'; +import { FIELD_TYPE, injectIntoPanelHeaderTop, registerFieldIndicator } from '@elementor/editor-editing-panel'; +import { __registerPanel as registerPanel } from '@elementor/editor-panels'; +import { registerDataHook } from '@elementor/editor-v1-adapters'; + +import { componentOverridablePropTypeUtil } from '../prop-types/component-overridable-prop-type'; +import { type ExtendedWindow } from '../types'; +import { onElementDrop } from '../utils/tracking'; +import { ComponentPanelHeader } from './components/component-panel-header/component-panel-header'; +import { panel as componentPropertiesPanel } from './components/component-properties-panel/component-properties-panel'; +import { CreateComponentForm } from './components/create-component-form/create-component-form'; +import { EditComponent } from './components/edit-component/edit-component'; +import { OverridablePropControl } from './components/overridable-props/overridable-prop-control'; +import { OverridablePropIndicator } from './components/overridable-props/overridable-prop-indicator'; +import { COMPONENT_DOCUMENT_TYPE, OVERRIDABLE_PROP_REPLACEMENT_ID } from './consts'; +import { initMcp } from './mcp'; +import { initNonAtomicNestingPrevention } from './prevent-non-atomic-nesting'; +import { beforeSave } from './sync/before-save'; +import { initCleanupOverridablePropsOnDelete } from './sync/cleanup-overridable-props-on-delete'; +import { initHandleComponentEditModeContainer } from './sync/handle-component-edit-mode-container'; +import { initRevertOverridablesOnCopyOrDuplicate } from './sync/revert-overridables-on-copy-or-duplicate'; + +export function initExtended() { + registerPanel( componentPropertiesPanel ); + + registerDataHook( 'dependency', 'editor/documents/close', ( args ) => { + const document = getV1CurrentDocument(); + if ( document.config.type === COMPONENT_DOCUMENT_TYPE ) { + args.mode = 'autosave'; + } + return true; + } ); + + registerDataHook( 'after', 'preview/drop', onElementDrop ); + + ( window as unknown as ExtendedWindow ).elementorCommon.__beforeSave = beforeSave; + + injectIntoTop( { + id: 'create-component-popup', + component: CreateComponentForm, + } ); + + injectIntoTop( { + id: 'edit-component', + component: EditComponent, + } ); + + injectIntoPanelHeaderTop( { + id: 'component-panel-header', + component: ComponentPanelHeader, + } ); + + registerFieldIndicator( { + fieldType: FIELD_TYPE.SETTINGS, + id: 'component-overridable-prop', + priority: 1, + indicator: OverridablePropIndicator, + } ); + + registerControlReplacement( { + id: OVERRIDABLE_PROP_REPLACEMENT_ID, + component: OverridablePropControl, + condition: ( { value } ) => componentOverridablePropTypeUtil.isValid( value ), + } ); + + initCleanupOverridablePropsOnDelete(); + + initMcp(); + + initNonAtomicNestingPrevention(); + + initHandleComponentEditModeContainer(); + + initRevertOverridablesOnCopyOrDuplicate(); +} diff --git a/packages/packages/core/editor-components/src/mcp/__tests__/save-as-component-tool.test.ts b/packages/packages/core/editor-components/src/extended/mcp/__tests__/save-as-component-tool.test.ts similarity index 99% rename from packages/packages/core/editor-components/src/mcp/__tests__/save-as-component-tool.test.ts rename to packages/packages/core/editor-components/src/extended/mcp/__tests__/save-as-component-tool.test.ts index c067e4a3afcf..beaa4490b7f3 100644 --- a/packages/packages/core/editor-components/src/mcp/__tests__/save-as-component-tool.test.ts +++ b/packages/packages/core/editor-components/src/extended/mcp/__tests__/save-as-component-tool.test.ts @@ -7,7 +7,7 @@ import { } from '@elementor/editor-elements'; import { AxiosError } from '@elementor/http-client'; -import { apiClient } from '../../api'; +import { apiClient } from '../../../api'; import { createUnpublishedComponent } from '../../store/actions/create-unpublished-component'; import { ERROR_MESSAGES, handleSaveAsComponent } from '../save-as-component-tool'; @@ -19,7 +19,7 @@ jest.mock( '@elementor/editor-mcp', () => ( { } ), } ) ); jest.mock( '../../store/actions/create-unpublished-component' ); -jest.mock( '../../api' ); +jest.mock( '../../../api' ); const mockGetContainer = jest.mocked( getContainer ); const mockGetElementType = jest.mocked( getElementType ); diff --git a/packages/packages/core/editor-components/src/mcp/index.ts b/packages/packages/core/editor-components/src/extended/mcp/index.ts similarity index 100% rename from packages/packages/core/editor-components/src/mcp/index.ts rename to packages/packages/core/editor-components/src/extended/mcp/index.ts diff --git a/packages/packages/core/editor-components/src/mcp/save-as-component-tool.ts b/packages/packages/core/editor-components/src/extended/mcp/save-as-component-tool.ts similarity index 99% rename from packages/packages/core/editor-components/src/mcp/save-as-component-tool.ts rename to packages/packages/core/editor-components/src/extended/mcp/save-as-component-tool.ts index d2eeef6c24b8..eb842a370263 100644 --- a/packages/packages/core/editor-components/src/mcp/save-as-component-tool.ts +++ b/packages/packages/core/editor-components/src/extended/mcp/save-as-component-tool.ts @@ -6,9 +6,9 @@ import { AxiosError } from '@elementor/http-client'; import { z } from '@elementor/schema'; import { generateUniqueId } from '@elementor/utils'; -import { apiClient } from '../api'; +import { apiClient } from '../../api'; +import { type OverridableProps } from '../../types'; import { createUnpublishedComponent } from '../store/actions/create-unpublished-component'; -import { type OverridableProps } from '../types'; const InputSchema = { element_id: z diff --git a/packages/packages/core/editor-components/src/prevent-non-atomic-nesting.ts b/packages/packages/core/editor-components/src/extended/prevent-non-atomic-nesting.ts similarity index 98% rename from packages/packages/core/editor-components/src/prevent-non-atomic-nesting.ts rename to packages/packages/core/editor-components/src/extended/prevent-non-atomic-nesting.ts index e16b8c4b0d09..2876e627e0ec 100644 --- a/packages/packages/core/editor-components/src/prevent-non-atomic-nesting.ts +++ b/packages/packages/core/editor-components/src/extended/prevent-non-atomic-nesting.ts @@ -4,7 +4,7 @@ import { type NotificationData, notify } from '@elementor/editor-notifications'; import { blockCommand } from '@elementor/editor-v1-adapters'; import { __ } from '@wordpress/i18n'; -import { type ExtendedWindow } from './types'; +import { type ExtendedWindow } from '../types'; import { isEditingComponent } from './utils/is-editing-component'; type CreateArgs = { diff --git a/packages/packages/core/editor-components/src/store/actions/add-overridable-group.ts b/packages/packages/core/editor-components/src/extended/store/actions/add-overridable-group.ts similarity index 90% rename from packages/packages/core/editor-components/src/store/actions/add-overridable-group.ts rename to packages/packages/core/editor-components/src/extended/store/actions/add-overridable-group.ts index c59f03d5f472..32d383f31a83 100644 --- a/packages/packages/core/editor-components/src/store/actions/add-overridable-group.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/add-overridable-group.ts @@ -1,8 +1,8 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId, type OverridablePropsGroup } from '../../types'; -import { type Source, trackComponentEvent } from '../../utils/tracking'; -import { selectCurrentComponent, selectOverridableProps, slice } from '../store'; +import { selectCurrentComponent, selectOverridableProps, slice } from '../../../store/store'; +import { type ComponentId, type OverridablePropsGroup } from '../../../types'; +import { type Source, trackComponentEvent } from '../../../utils/tracking'; type AddGroupParams = { componentId: ComponentId; diff --git a/packages/packages/core/editor-components/src/store/actions/create-unpublished-component.ts b/packages/packages/core/editor-components/src/extended/store/actions/create-unpublished-component.ts similarity index 92% rename from packages/packages/core/editor-components/src/store/actions/create-unpublished-component.ts rename to packages/packages/core/editor-components/src/extended/store/actions/create-unpublished-component.ts index 476a0be9270d..64417efbece5 100644 --- a/packages/packages/core/editor-components/src/store/actions/create-unpublished-component.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/create-unpublished-component.ts @@ -4,12 +4,12 @@ import { __dispatch as dispatch } from '@elementor/store'; import { generateUniqueId } from '@elementor/utils'; import { __ } from '@wordpress/i18n'; +import { slice } from '../../../store/store'; +import { type OriginalElementData, type OverridableProps } from '../../../types'; +import { type Source, trackComponentEvent } from '../../../utils/tracking'; import { type ComponentEventData } from '../../components/create-component-form/utils/get-component-event-data'; -import { replaceElementWithComponent } from '../../components/create-component-form/utils/replace-element-with-component'; -import { type OriginalElementData, type OverridableProps } from '../../types'; +import { replaceElementWithComponent } from '../../utils/replace-element-with-component'; import { revertAllOverridablesInElementData } from '../../utils/revert-overridable-settings'; -import { type Source, trackComponentEvent } from '../../utils/tracking'; -import { slice } from '../store'; type CreateUnpublishedComponentParams = { name: string; diff --git a/packages/packages/core/editor-components/src/store/actions/delete-overridable-group.ts b/packages/packages/core/editor-components/src/extended/store/actions/delete-overridable-group.ts similarity index 87% rename from packages/packages/core/editor-components/src/store/actions/delete-overridable-group.ts rename to packages/packages/core/editor-components/src/extended/store/actions/delete-overridable-group.ts index 2f0470dfc56f..76df370ce1fd 100644 --- a/packages/packages/core/editor-components/src/store/actions/delete-overridable-group.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/delete-overridable-group.ts @@ -1,7 +1,7 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId } from '../../types'; -import { selectOverridableProps, slice } from '../store'; +import { selectOverridableProps, slice } from '../../../store/store'; +import { type ComponentId } from '../../../types'; import { deleteGroup } from '../utils/groups-transformers'; type DeleteGroupParams = { diff --git a/packages/packages/core/editor-components/src/store/actions/delete-overridable-prop.ts b/packages/packages/core/editor-components/src/extended/store/actions/delete-overridable-prop.ts similarity index 91% rename from packages/packages/core/editor-components/src/store/actions/delete-overridable-prop.ts rename to packages/packages/core/editor-components/src/extended/store/actions/delete-overridable-prop.ts index 5b026b1f93fe..000cea8acee4 100644 --- a/packages/packages/core/editor-components/src/store/actions/delete-overridable-prop.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/delete-overridable-prop.ts @@ -1,9 +1,9 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId, type OverridableProp } from '../../types'; +import { selectCurrentComponent, selectOverridableProps, slice } from '../../../store/store'; +import { type ComponentId, type OverridableProp } from '../../../types'; +import { type Source, trackComponentEvent } from '../../../utils/tracking'; import { revertElementOverridableSetting } from '../../utils/revert-overridable-settings'; -import { type Source, trackComponentEvent } from '../../utils/tracking'; -import { selectCurrentComponent, selectOverridableProps, slice } from '../store'; import { removePropFromAllGroups } from '../utils/groups-transformers'; type DeletePropParams = { diff --git a/packages/packages/core/editor-components/src/store/actions/rename-overridable-group.ts b/packages/packages/core/editor-components/src/extended/store/actions/rename-overridable-group.ts similarity index 87% rename from packages/packages/core/editor-components/src/store/actions/rename-overridable-group.ts rename to packages/packages/core/editor-components/src/extended/store/actions/rename-overridable-group.ts index 09a1bc189428..efdbcadf60bc 100644 --- a/packages/packages/core/editor-components/src/store/actions/rename-overridable-group.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/rename-overridable-group.ts @@ -1,7 +1,7 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId } from '../../types'; -import { selectOverridableProps, slice } from '../store'; +import { selectOverridableProps, slice } from '../../../store/store'; +import { type ComponentId } from '../../../types'; import { renameGroup } from '../utils/groups-transformers'; type RenameGroupParams = { diff --git a/packages/packages/core/editor-components/src/store/actions/reorder-group-props.ts b/packages/packages/core/editor-components/src/extended/store/actions/reorder-group-props.ts similarity index 87% rename from packages/packages/core/editor-components/src/store/actions/reorder-group-props.ts rename to packages/packages/core/editor-components/src/extended/store/actions/reorder-group-props.ts index 9147f356f876..94aadad73a50 100644 --- a/packages/packages/core/editor-components/src/store/actions/reorder-group-props.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/reorder-group-props.ts @@ -1,7 +1,7 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId } from '../../types'; -import { selectOverridableProps, slice } from '../store'; +import { selectOverridableProps, slice } from '../../../store/store'; +import { type ComponentId } from '../../../types'; type ReorderGroupPropsParams = { componentId: ComponentId; diff --git a/packages/packages/core/editor-components/src/store/actions/reorder-overridable-groups.ts b/packages/packages/core/editor-components/src/extended/store/actions/reorder-overridable-groups.ts similarity index 83% rename from packages/packages/core/editor-components/src/store/actions/reorder-overridable-groups.ts rename to packages/packages/core/editor-components/src/extended/store/actions/reorder-overridable-groups.ts index a0fcade77f43..8858417cbf01 100644 --- a/packages/packages/core/editor-components/src/store/actions/reorder-overridable-groups.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/reorder-overridable-groups.ts @@ -1,7 +1,7 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId } from '../../types'; -import { selectOverridableProps, slice } from '../store'; +import { selectOverridableProps, slice } from '../../../store/store'; +import { type ComponentId } from '../../../types'; type ReorderGroupsParams = { componentId: ComponentId; diff --git a/packages/packages/core/editor-components/src/store/actions/reset-sanitized-components.ts b/packages/packages/core/editor-components/src/extended/store/actions/reset-sanitized-components.ts similarity index 77% rename from packages/packages/core/editor-components/src/store/actions/reset-sanitized-components.ts rename to packages/packages/core/editor-components/src/extended/store/actions/reset-sanitized-components.ts index 6c7a9e113dd1..bca0f158c57e 100644 --- a/packages/packages/core/editor-components/src/store/actions/reset-sanitized-components.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/reset-sanitized-components.ts @@ -1,6 +1,6 @@ import { __dispatch as dispatch } from '@elementor/store'; -import { slice } from '../store'; +import { slice } from '../../../store/store'; export function resetSanitizedComponents() { dispatch( slice.actions.resetSanitizedComponents() ); diff --git a/packages/packages/core/editor-components/src/store/actions/set-overridable-prop.ts b/packages/packages/core/editor-components/src/extended/store/actions/set-overridable-prop.ts similarity index 96% rename from packages/packages/core/editor-components/src/store/actions/set-overridable-prop.ts rename to packages/packages/core/editor-components/src/extended/store/actions/set-overridable-prop.ts index 90bdc288adcb..2b93f4bf9edb 100644 --- a/packages/packages/core/editor-components/src/store/actions/set-overridable-prop.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/set-overridable-prop.ts @@ -2,9 +2,9 @@ import { type PropValue } from '@elementor/editor-props'; import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; import { generateUniqueId } from '@elementor/utils'; -import { type OriginPropFields, type OverridableProp } from '../../types'; -import { type Source, trackComponentEvent } from '../../utils/tracking'; -import { selectCurrentComponent, selectOverridableProps, slice } from '../store'; +import { selectCurrentComponent, selectOverridableProps, slice } from '../../../store/store'; +import { type OriginPropFields, type OverridableProp } from '../../../types'; +import { type Source, trackComponentEvent } from '../../../utils/tracking'; import { addPropToGroup, ensureGroupInOrder, diff --git a/packages/packages/core/editor-components/src/store/actions/update-component-sanitized-attribute.ts b/packages/packages/core/editor-components/src/extended/store/actions/update-component-sanitized-attribute.ts similarity index 68% rename from packages/packages/core/editor-components/src/store/actions/update-component-sanitized-attribute.ts rename to packages/packages/core/editor-components/src/extended/store/actions/update-component-sanitized-attribute.ts index bf4a20e102d2..82f028a75a41 100644 --- a/packages/packages/core/editor-components/src/store/actions/update-component-sanitized-attribute.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/update-component-sanitized-attribute.ts @@ -1,7 +1,7 @@ import { __dispatch as dispatch } from '@elementor/store'; -import { type ComponentId } from '../../types'; -import { type SanitizeAttributes, slice } from '../store'; +import { type SanitizeAttributes, slice } from '../../../store/store'; +import { type ComponentId } from '../../../types'; export function updateComponentSanitizedAttribute( componentId: ComponentId, attribute: SanitizeAttributes ) { dispatch( slice.actions.updateComponentSanitizedAttribute( { componentId, attribute } ) ); diff --git a/packages/packages/core/editor-components/src/store/actions/update-current-component.ts b/packages/packages/core/editor-components/src/extended/store/actions/update-current-component.ts similarity index 50% rename from packages/packages/core/editor-components/src/store/actions/update-current-component.ts rename to packages/packages/core/editor-components/src/extended/store/actions/update-current-component.ts index 5f6fdd1439d4..2fdb2523936b 100644 --- a/packages/packages/core/editor-components/src/store/actions/update-current-component.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/update-current-component.ts @@ -1,7 +1,7 @@ -import { setDocumentModifiedStatus, type V1Document } from '@elementor/editor-documents'; +import { type V1Document } from '@elementor/editor-documents'; import { __getStore as getStore } from '@elementor/store'; -import { type ComponentsPathItem, slice } from '../store'; +import { type ComponentsPathItem, slice } from '../../../store/store'; export function updateCurrentComponent( { path, @@ -19,15 +19,3 @@ export function updateCurrentComponent( { dispatch( slice.actions.setPath( path ) ); dispatch( slice.actions.setCurrentComponentId( currentComponentId ) ); } - -export const archiveComponent = ( componentId: number ) => { - const store = getStore(); - const dispatch = store?.dispatch; - - if ( ! dispatch ) { - return; - } - - dispatch( slice.actions.archive( componentId ) ); - setDocumentModifiedStatus( true ); -}; diff --git a/packages/packages/core/editor-components/src/store/actions/update-overridable-prop-params.ts b/packages/packages/core/editor-components/src/extended/store/actions/update-overridable-prop-params.ts similarity index 89% rename from packages/packages/core/editor-components/src/store/actions/update-overridable-prop-params.ts rename to packages/packages/core/editor-components/src/extended/store/actions/update-overridable-prop-params.ts index 6004ce5f7d1d..2d5154b8420d 100644 --- a/packages/packages/core/editor-components/src/store/actions/update-overridable-prop-params.ts +++ b/packages/packages/core/editor-components/src/extended/store/actions/update-overridable-prop-params.ts @@ -1,7 +1,7 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { type ComponentId, type OverridableProp } from '../../types'; -import { selectOverridableProps, slice } from '../store'; +import { selectOverridableProps, slice } from '../../../store/store'; +import { type ComponentId, type OverridableProp } from '../../../types'; import { movePropBetweenGroups } from '../utils/groups-transformers'; type UpdatePropParams = { diff --git a/packages/packages/core/editor-components/src/store/utils/groups-transformers.ts b/packages/packages/core/editor-components/src/extended/store/utils/groups-transformers.ts similarity index 96% rename from packages/packages/core/editor-components/src/store/utils/groups-transformers.ts rename to packages/packages/core/editor-components/src/extended/store/utils/groups-transformers.ts index fefac49059e0..3bd48f4c1f0f 100644 --- a/packages/packages/core/editor-components/src/store/utils/groups-transformers.ts +++ b/packages/packages/core/editor-components/src/extended/store/utils/groups-transformers.ts @@ -1,7 +1,7 @@ import { generateUniqueId } from '@elementor/utils'; import { __ } from '@wordpress/i18n'; -import { type OverridableProp, type OverridableProps, type OverridablePropsGroup } from '../../types'; +import { type OverridableProp, type OverridableProps, type OverridablePropsGroup } from '../../../types'; type Groups = OverridableProps[ 'groups' ]; @@ -95,7 +95,7 @@ export function resolveOrCreateGroup( groups: Groups, requestedGroupId?: string return createGroup( groups, requestedGroupId ); } -export function createGroup( groups: Groups, groupId?: string, label?: string ): ResolvedGroup { +function createGroup( groups: Groups, groupId?: string, label?: string ): ResolvedGroup { const newGroupId = groupId || generateUniqueId( 'group' ); const newLabel = label || __( 'Default', 'elementor' ); diff --git a/packages/packages/core/editor-components/src/sync/__tests__/cleanup-overridable-props-on-delete.test.ts b/packages/packages/core/editor-components/src/extended/sync/__tests__/cleanup-overridable-props-on-delete.test.ts similarity index 99% rename from packages/packages/core/editor-components/src/sync/__tests__/cleanup-overridable-props-on-delete.test.ts rename to packages/packages/core/editor-components/src/extended/sync/__tests__/cleanup-overridable-props-on-delete.test.ts index a8e86ecd1cbd..c8725edfaea7 100644 --- a/packages/packages/core/editor-components/src/sync/__tests__/cleanup-overridable-props-on-delete.test.ts +++ b/packages/packages/core/editor-components/src/extended/sync/__tests__/cleanup-overridable-props-on-delete.test.ts @@ -2,9 +2,9 @@ import { createHooksRegistry, createMockElement, setupHooksRegistry, type Window import { getAllDescendants, type V1Element } from '@elementor/editor-elements'; import { __getState as getState } from '@elementor/store'; +import { SLICE_NAME } from '../../../store/store'; +import type { OverridableProps, PublishedComponent } from '../../../types'; import { deleteOverridableProp } from '../../store/actions/delete-overridable-prop'; -import { SLICE_NAME } from '../../store/store'; -import type { OverridableProps, PublishedComponent } from '../../types'; import { initCleanupOverridablePropsOnDelete } from '../cleanup-overridable-props-on-delete'; jest.mock( '@elementor/store', () => ( { diff --git a/packages/packages/core/editor-components/src/sync/__tests__/create-components-before-save.test.ts b/packages/packages/core/editor-components/src/extended/sync/__tests__/create-components-before-save.test.ts similarity index 97% rename from packages/packages/core/editor-components/src/sync/__tests__/create-components-before-save.test.ts rename to packages/packages/core/editor-components/src/extended/sync/__tests__/create-components-before-save.test.ts index 8ffb6210feec..7ef6af2f5889 100644 --- a/packages/packages/core/editor-components/src/sync/__tests__/create-components-before-save.test.ts +++ b/packages/packages/core/editor-components/src/extended/sync/__tests__/create-components-before-save.test.ts @@ -1,12 +1,12 @@ import { updateElementSettings, type V1ElementData } from '@elementor/editor-elements'; import { __createStore, __dispatch, __getState as getState, __registerSlice } from '@elementor/store'; -import { apiClient } from '../../api'; -import { selectUnpublishedComponents, slice } from '../../store/store'; -import { createComponentsBeforeSave } from '../create-components-before-save'; +import { apiClient } from '../../../api'; +import { selectUnpublishedComponents, slice } from '../../../store/store'; +import { createComponentsBeforeSave } from '../../sync/create-components-before-save'; jest.mock( '@elementor/editor-elements' ); -jest.mock( '../../api' ); +jest.mock( '../../../api' ); const mockUpdateElementSettings = jest.mocked( updateElementSettings ); const mockCreateComponents = jest.mocked( apiClient.create ); diff --git a/packages/packages/core/editor-components/src/sync/__tests__/handle-component-edit-mode-container.test.ts b/packages/packages/core/editor-components/src/extended/sync/__tests__/handle-component-edit-mode-container.test.ts similarity index 99% rename from packages/packages/core/editor-components/src/sync/__tests__/handle-component-edit-mode-container.test.ts rename to packages/packages/core/editor-components/src/extended/sync/__tests__/handle-component-edit-mode-container.test.ts index 5be2d634a0f3..182a5e9a602d 100644 --- a/packages/packages/core/editor-components/src/sync/__tests__/handle-component-edit-mode-container.test.ts +++ b/packages/packages/core/editor-components/src/extended/sync/__tests__/handle-component-edit-mode-container.test.ts @@ -2,7 +2,7 @@ import { createHooksRegistry, createMockElement, setupHooksRegistry } from 'test import { type V1Document } from '@elementor/editor-documents'; import { createElement, selectElement, type V1Element } from '@elementor/editor-elements'; -import { COMPONENT_DOCUMENT_TYPE } from '../../components/consts'; +import { COMPONENT_DOCUMENT_TYPE } from '../../consts'; import { isEditingComponent } from '../../utils/is-editing-component'; import { type DeleteArgs, initHandleComponentEditModeContainer } from '../handle-component-edit-mode-container'; diff --git a/packages/packages/core/editor-components/src/sync/__tests__/update-archived-component-before-save.test.ts b/packages/packages/core/editor-components/src/extended/sync/__tests__/update-archived-component-before-save.test.ts similarity index 91% rename from packages/packages/core/editor-components/src/sync/__tests__/update-archived-component-before-save.test.ts rename to packages/packages/core/editor-components/src/extended/sync/__tests__/update-archived-component-before-save.test.ts index 69b4048d4e04..be8b5b2d22ef 100644 --- a/packages/packages/core/editor-components/src/sync/__tests__/update-archived-component-before-save.test.ts +++ b/packages/packages/core/editor-components/src/extended/sync/__tests__/update-archived-component-before-save.test.ts @@ -2,11 +2,11 @@ import { notify } from '@elementor/editor-notifications'; import { __createStore, __dispatch, __registerSlice } from '@elementor/store'; -import { apiClient } from '../../api'; -import { slice } from '../../store/store'; -import { updateArchivedComponentBeforeSave } from '../update-archived-component-before-save'; +import { apiClient } from '../../../api'; +import { slice } from '../../../store/store'; +import { updateArchivedComponentBeforeSave } from '../../sync/update-archived-component-before-save'; -jest.mock( '../../api' ); +jest.mock( '../../../api' ); jest.mock( '@elementor/editor-notifications' ); const mockUpdateArchivedComponents = jest.mocked( apiClient.updateArchivedComponents ); diff --git a/packages/packages/core/editor-components/src/extended/sync/before-save.ts b/packages/packages/core/editor-components/src/extended/sync/before-save.ts new file mode 100644 index 000000000000..9b5cef2908bc --- /dev/null +++ b/packages/packages/core/editor-components/src/extended/sync/before-save.ts @@ -0,0 +1,52 @@ +import { type V1Document } from '@elementor/editor-documents'; +import { type V1Element, type V1ElementData } from '@elementor/editor-elements'; + +import { publishDraftComponentsInPageBeforeSave } from '../../sync/publish-draft-components-in-page-before-save'; +import { type DocumentSaveStatus } from '../../types'; +import { setComponentOverridablePropsSettingsBeforeSave } from '../sync/set-component-overridable-props-settings-before-save'; +import { updateArchivedComponentBeforeSave } from '../sync/update-archived-component-before-save'; +import { updateComponentTitleBeforeSave } from '../sync/update-component-title-before-save'; +import { createComponentsBeforeSave } from './create-components-before-save'; + +type Options = { + container: V1Element & { + document: V1Document; + model: { + get: ( key: 'elements' ) => { + toJSON: () => V1ElementData[]; + }; + }; + }; + status: DocumentSaveStatus; +}; + +export const beforeSave = ( { container, status }: Options ) => { + const elements = container?.model.get( 'elements' ).toJSON?.() ?? []; + + return Promise.all( [ + syncComponents( { elements, status } ), + setComponentOverridablePropsSettingsBeforeSave( { container } ), + ] ); +}; + +// These operations run sequentially to prevent race conditions when multiple +// edits occur on the same component simultaneously. +// TODO: Consolidate these into a single PUT /components endpoint. +const syncComponents = async ( { elements, status }: { elements: V1ElementData[]; status: DocumentSaveStatus } ) => { + // This order is important - first update existing components, then create new components, + // Since new component validation depends on the existing components (preventing duplicate names). + await updateExistingComponentsBeforeSave( { elements, status } ); + await createComponentsBeforeSave( { elements, status } ); +}; + +const updateExistingComponentsBeforeSave = async ( { + elements, + status, +}: { + elements: V1ElementData[]; + status: DocumentSaveStatus; +} ) => { + await updateComponentTitleBeforeSave( status ); + await updateArchivedComponentBeforeSave( status ); + await publishDraftComponentsInPageBeforeSave( { elements, status } ); +}; diff --git a/packages/packages/core/editor-components/src/sync/cleanup-overridable-props-on-delete.ts b/packages/packages/core/editor-components/src/extended/sync/cleanup-overridable-props-on-delete.ts similarity index 98% rename from packages/packages/core/editor-components/src/sync/cleanup-overridable-props-on-delete.ts rename to packages/packages/core/editor-components/src/extended/sync/cleanup-overridable-props-on-delete.ts index b554fa403d76..82e413f759e5 100644 --- a/packages/packages/core/editor-components/src/sync/cleanup-overridable-props-on-delete.ts +++ b/packages/packages/core/editor-components/src/extended/sync/cleanup-overridable-props-on-delete.ts @@ -2,8 +2,8 @@ import { getAllDescendants, type V1Element } from '@elementor/editor-elements'; import { type HookOptions, registerDataHook } from '@elementor/editor-v1-adapters'; import { __getState as getState } from '@elementor/store'; +import { type ComponentsSlice, selectCurrentComponentId, selectOverridableProps } from '../../store/store'; import { deleteOverridableProp } from '../store/actions/delete-overridable-prop'; -import { type ComponentsSlice, selectCurrentComponentId, selectOverridableProps } from '../store/store'; type DeleteCommandArgs = { container?: V1Element; diff --git a/packages/packages/core/editor-components/src/sync/create-components-before-save.ts b/packages/packages/core/editor-components/src/extended/sync/create-components-before-save.ts similarity index 93% rename from packages/packages/core/editor-components/src/sync/create-components-before-save.ts rename to packages/packages/core/editor-components/src/extended/sync/create-components-before-save.ts index 97973c0f1809..50a46c688538 100644 --- a/packages/packages/core/editor-components/src/sync/create-components-before-save.ts +++ b/packages/packages/core/editor-components/src/extended/sync/create-components-before-save.ts @@ -1,10 +1,10 @@ import { updateElementSettings, type V1ElementData } from '@elementor/editor-elements'; import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { apiClient } from '../api'; -import { type ComponentInstanceProp } from '../prop-types/component-instance-prop-type'; -import { selectUnpublishedComponents, slice } from '../store/store'; -import { type DocumentSaveStatus, type UnpublishedComponent } from '../types'; +import { apiClient } from '../../api'; +import { type ComponentInstanceProp } from '../../prop-types/component-instance-prop-type'; +import { selectUnpublishedComponents, slice } from '../../store/store'; +import { type DocumentSaveStatus, type UnpublishedComponent } from '../../types'; export async function createComponentsBeforeSave( { elements, diff --git a/packages/packages/core/editor-components/src/sync/handle-component-edit-mode-container.ts b/packages/packages/core/editor-components/src/extended/sync/handle-component-edit-mode-container.ts similarity index 97% rename from packages/packages/core/editor-components/src/sync/handle-component-edit-mode-container.ts rename to packages/packages/core/editor-components/src/extended/sync/handle-component-edit-mode-container.ts index cda621219a25..8dc47300f5a5 100644 --- a/packages/packages/core/editor-components/src/sync/handle-component-edit-mode-container.ts +++ b/packages/packages/core/editor-components/src/extended/sync/handle-component-edit-mode-container.ts @@ -2,7 +2,7 @@ import { type V1Document } from '@elementor/editor-documents'; import { createElement, selectElement, type V1Element } from '@elementor/editor-elements'; import { registerDataHook } from '@elementor/editor-v1-adapters'; -import { COMPONENT_DOCUMENT_TYPE } from '../components/consts'; +import { COMPONENT_DOCUMENT_TYPE } from '../consts'; import { isEditingComponent } from '../utils/is-editing-component'; const V4_DEFAULT_CONTAINER_TYPE = 'e-flexbox'; diff --git a/packages/packages/core/editor-components/src/sync/revert-overridables-on-copy-or-duplicate.ts b/packages/packages/core/editor-components/src/extended/sync/revert-overridables-on-copy-or-duplicate.ts similarity index 97% rename from packages/packages/core/editor-components/src/sync/revert-overridables-on-copy-or-duplicate.ts rename to packages/packages/core/editor-components/src/extended/sync/revert-overridables-on-copy-or-duplicate.ts index f6b63bc7411d..d0db42c79bab 100644 --- a/packages/packages/core/editor-components/src/sync/revert-overridables-on-copy-or-duplicate.ts +++ b/packages/packages/core/editor-components/src/extended/sync/revert-overridables-on-copy-or-duplicate.ts @@ -1,7 +1,7 @@ import { type V1Element, type V1ElementData } from '@elementor/editor-elements'; import { registerDataHook } from '@elementor/editor-v1-adapters'; -import { type ExtendedWindow } from '../types'; +import { type ExtendedWindow } from '../../types'; import { isEditingComponent } from '../utils/is-editing-component'; import { revertAllOverridablesInContainer, diff --git a/packages/packages/core/editor-components/src/sync/set-component-overridable-props-settings-before-save.ts b/packages/packages/core/editor-components/src/extended/sync/set-component-overridable-props-settings-before-save.ts similarity index 84% rename from packages/packages/core/editor-components/src/sync/set-component-overridable-props-settings-before-save.ts rename to packages/packages/core/editor-components/src/extended/sync/set-component-overridable-props-settings-before-save.ts index dfb4bbfacfe0..bada11938e27 100644 --- a/packages/packages/core/editor-components/src/sync/set-component-overridable-props-settings-before-save.ts +++ b/packages/packages/core/editor-components/src/extended/sync/set-component-overridable-props-settings-before-save.ts @@ -2,8 +2,8 @@ import { type V1Document } from '@elementor/editor-documents'; import { type V1Element } from '@elementor/editor-elements'; import { __getState as getState } from '@elementor/store'; -import { COMPONENT_DOCUMENT_TYPE } from '../components/consts'; -import { selectOverridableProps } from '../store/store'; +import { selectOverridableProps } from '../../store/store'; +import { COMPONENT_DOCUMENT_TYPE } from '../consts'; export const setComponentOverridablePropsSettingsBeforeSave = ( { container, diff --git a/packages/packages/core/editor-components/src/sync/update-archived-component-before-save.ts b/packages/packages/core/editor-components/src/extended/sync/update-archived-component-before-save.ts similarity index 84% rename from packages/packages/core/editor-components/src/sync/update-archived-component-before-save.ts rename to packages/packages/core/editor-components/src/extended/sync/update-archived-component-before-save.ts index 702a4930fd48..04adefa5e06a 100644 --- a/packages/packages/core/editor-components/src/sync/update-archived-component-before-save.ts +++ b/packages/packages/core/editor-components/src/extended/sync/update-archived-component-before-save.ts @@ -1,9 +1,9 @@ import { type NotificationData, notify } from '@elementor/editor-notifications'; import { __getState as getState } from '@elementor/store'; -import { apiClient } from '../api'; -import { selectArchivedThisSession } from '../store/store'; -import { type DocumentSaveStatus } from '../types'; +import { apiClient } from '../../api'; +import { selectArchivedThisSession } from '../../store/store'; +import { type DocumentSaveStatus } from '../../types'; const failedNotification = ( message: string ): NotificationData => ( { type: 'error', diff --git a/packages/packages/core/editor-components/src/sync/update-component-title-before-save.ts b/packages/packages/core/editor-components/src/extended/sync/update-component-title-before-save.ts similarity index 74% rename from packages/packages/core/editor-components/src/sync/update-component-title-before-save.ts rename to packages/packages/core/editor-components/src/extended/sync/update-component-title-before-save.ts index 860e01bf72ff..899aef1b7db9 100644 --- a/packages/packages/core/editor-components/src/sync/update-component-title-before-save.ts +++ b/packages/packages/core/editor-components/src/extended/sync/update-component-title-before-save.ts @@ -1,8 +1,8 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; -import { apiClient } from '../api'; -import { selectUpdatedComponentNames, slice } from '../store/store'; -import { type DocumentSaveStatus } from '../types'; +import { apiClient } from '../../api'; +import { selectUpdatedComponentNames, slice } from '../../store/store'; +import { type DocumentSaveStatus } from '../../types'; export const updateComponentTitleBeforeSave = async ( status: DocumentSaveStatus ) => { const updatedComponentNames = selectUpdatedComponentNames( getState() ); diff --git a/packages/packages/core/editor-components/src/utils/__tests__/is-editing-component.test.ts b/packages/packages/core/editor-components/src/extended/utils/__tests__/is-editing-component.test.ts similarity index 96% rename from packages/packages/core/editor-components/src/utils/__tests__/is-editing-component.test.ts rename to packages/packages/core/editor-components/src/extended/utils/__tests__/is-editing-component.test.ts index b742784b1d30..53a89d758623 100644 --- a/packages/packages/core/editor-components/src/utils/__tests__/is-editing-component.test.ts +++ b/packages/packages/core/editor-components/src/extended/utils/__tests__/is-editing-component.test.ts @@ -1,6 +1,6 @@ import { __getStore as getStore } from '@elementor/store'; -import { SLICE_NAME } from '../../store/store'; +import { SLICE_NAME } from '../../../store/store'; import { isEditingComponent } from '../is-editing-component'; jest.mock( '@elementor/store', () => ( { diff --git a/packages/packages/core/editor-components/src/utils/is-editing-component.ts b/packages/packages/core/editor-components/src/extended/utils/is-editing-component.ts similarity index 94% rename from packages/packages/core/editor-components/src/utils/is-editing-component.ts rename to packages/packages/core/editor-components/src/extended/utils/is-editing-component.ts index a68b651909d9..26911fa33624 100644 --- a/packages/packages/core/editor-components/src/utils/is-editing-component.ts +++ b/packages/packages/core/editor-components/src/extended/utils/is-editing-component.ts @@ -1,6 +1,6 @@ import { __getStore as getStore } from '@elementor/store'; -import { type ComponentsSlice, selectCurrentComponentId } from '../store/store'; +import { type ComponentsSlice, selectCurrentComponentId } from '../../store/store'; export function isEditingComponent(): boolean { const state = getStore()?.getState() as ComponentsSlice | undefined; diff --git a/packages/packages/core/editor-components/src/extended/utils/replace-element-with-component.ts b/packages/packages/core/editor-components/src/extended/utils/replace-element-with-component.ts new file mode 100644 index 000000000000..66d8f6c78f6e --- /dev/null +++ b/packages/packages/core/editor-components/src/extended/utils/replace-element-with-component.ts @@ -0,0 +1,11 @@ +import { replaceElement, type V1ElementData } from '@elementor/editor-elements'; + +import { type ComponentInstanceParams, createComponentModel } from '../../utils/create-component-model'; + +export const replaceElementWithComponent = async ( element: V1ElementData, component: ComponentInstanceParams ) => { + return await replaceElement( { + currentElement: element, + newElement: createComponentModel( component ), + withHistory: false, + } ); +}; diff --git a/packages/packages/core/editor-components/src/utils/revert-overridable-settings.ts b/packages/packages/core/editor-components/src/extended/utils/revert-overridable-settings.ts similarity index 93% rename from packages/packages/core/editor-components/src/utils/revert-overridable-settings.ts rename to packages/packages/core/editor-components/src/extended/utils/revert-overridable-settings.ts index a9840f8cb5d2..1bef676c3afb 100644 --- a/packages/packages/core/editor-components/src/utils/revert-overridable-settings.ts +++ b/packages/packages/core/editor-components/src/extended/utils/revert-overridable-settings.ts @@ -8,22 +8,22 @@ import { type V1ElementSettingsProps, } from '@elementor/editor-elements'; -import { COMPONENT_WIDGET_TYPE } from '../create-component-type'; +import { COMPONENT_WIDGET_TYPE } from '../../create-component-type'; import { type ComponentInstanceOverrideProp, componentInstanceOverridePropTypeUtil, -} from '../prop-types/component-instance-override-prop-type'; +} from '../../prop-types/component-instance-override-prop-type'; import { componentInstanceOverridesPropTypeUtil, type ComponentInstanceOverridesPropValue, -} from '../prop-types/component-instance-overrides-prop-type'; +} from '../../prop-types/component-instance-overrides-prop-type'; import { type ComponentInstanceProp, componentInstancePropTypeUtil, type ComponentInstancePropValue, -} from '../prop-types/component-instance-prop-type'; -import { componentOverridablePropTypeUtil } from '../prop-types/component-overridable-prop-type'; -import { isComponentInstance } from './is-component-instance'; +} from '../../prop-types/component-instance-prop-type'; +import { componentOverridablePropTypeUtil } from '../../prop-types/component-overridable-prop-type'; +import { isComponentInstance } from '../../utils/is-component-instance'; type RevertSettingsResult = { hasChanges: boolean; diff --git a/packages/packages/core/editor-components/src/hooks/use-component-instance-settings.ts b/packages/packages/core/editor-components/src/hooks/use-component-instance-settings.ts deleted file mode 100644 index 104aa5cdf4fb..000000000000 --- a/packages/packages/core/editor-components/src/hooks/use-component-instance-settings.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useElement } from '@elementor/editor-editing-panel'; -import { useElementSetting } from '@elementor/editor-elements'; - -import { - componentInstancePropTypeUtil, - type ComponentInstancePropValue, -} from '../prop-types/component-instance-prop-type'; - -export function useComponentInstanceSettings() { - const { element } = useElement(); - - const settings = useElementSetting< ComponentInstancePropValue >( element.id, 'component_instance' ); - - return componentInstancePropTypeUtil.extract( settings ); -} diff --git a/packages/packages/core/editor-components/src/hooks/use-sanitize-overridable-props.ts b/packages/packages/core/editor-components/src/hooks/use-sanitize-overridable-props.ts index 4ceb5d987c03..c508dba9ef80 100644 --- a/packages/packages/core/editor-components/src/hooks/use-sanitize-overridable-props.ts +++ b/packages/packages/core/editor-components/src/hooks/use-sanitize-overridable-props.ts @@ -1,5 +1,5 @@ -import { deleteOverridableProp } from '../store/actions/delete-overridable-prop'; -import { updateComponentSanitizedAttribute } from '../store/actions/update-component-sanitized-attribute'; +import { deleteOverridableProp } from '../extended/store/actions/delete-overridable-prop'; +import { updateComponentSanitizedAttribute } from '../extended/store/actions/update-component-sanitized-attribute'; import { useIsSanitizedComponent, useOverridableProps } from '../store/store'; import { type ComponentId, type OverridableProps } from '../types'; import { filterValidOverridableProps } from '../utils/filter-valid-overridable-props'; diff --git a/packages/packages/core/editor-components/src/init.ts b/packages/packages/core/editor-components/src/init.ts index 8719aa62130a..c7176eadde85 100644 --- a/packages/packages/core/editor-components/src/init.ts +++ b/packages/packages/core/editor-components/src/init.ts @@ -1,20 +1,13 @@ -import { injectIntoLogic, injectIntoTop } from '@elementor/editor'; +import { injectIntoLogic } from '@elementor/editor'; import { type CreateTemplatedElementTypeOptions, registerElementType, settingsTransformersRegistry, } from '@elementor/editor-canvas'; -import { registerControlReplacement } from '@elementor/editor-controls'; import { getV1CurrentDocument } from '@elementor/editor-documents'; -import { - FIELD_TYPE, - injectIntoPanelHeaderTop, - registerEditingPanelReplacement, - registerFieldIndicator, -} from '@elementor/editor-editing-panel'; +import { registerEditingPanelReplacement } from '@elementor/editor-editing-panel'; import { type V1ElementData } from '@elementor/editor-elements'; import { injectTab } from '@elementor/editor-elements-panel'; -import { __registerPanel as registerPanel } from '@elementor/editor-panels'; import { stylesRepository } from '@elementor/editor-styles-repository'; import { registerDataHook } from '@elementor/editor-v1-adapters'; import { __registerSlice as registerSlice } from '@elementor/store'; @@ -23,55 +16,31 @@ import { __ } from '@wordpress/i18n'; import { componentInstanceTransformer } from './component-instance-transformer'; import { componentOverridableTransformer } from './component-overridable-transformer'; import { componentOverrideTransformer } from './component-override-transformer'; -import { ComponentPanelHeader } from './components/component-panel-header/component-panel-header'; -import { panel as componentPropertiesPanel } from './components/component-properties-panel/component-properties-panel'; import { Components } from './components/components-tab/components'; -import { COMPONENT_DOCUMENT_TYPE, OVERRIDABLE_PROP_REPLACEMENT_ID } from './components/consts'; -import { CreateComponentForm } from './components/create-component-form/create-component-form'; -import { EditComponent } from './components/edit-component/edit-component'; import { openEditModeDialog } from './components/in-edit-mode'; import { InstanceEditingPanel } from './components/instance-editing-panel/instance-editing-panel'; import { LoadTemplateComponents } from './components/load-template-components'; -import { OverridablePropControl } from './components/overridable-props/overridable-prop-control'; -import { OverridablePropIndicator } from './components/overridable-props/overridable-prop-indicator'; import { COMPONENT_WIDGET_TYPE, createComponentType } from './create-component-type'; -import { initMcp } from './mcp'; +import { initExtended } from './extended/init'; import { PopulateStore } from './populate-store'; import { initCircularNestingPrevention } from './prevent-circular-nesting'; -import { initNonAtomicNestingPrevention } from './prevent-non-atomic-nesting'; -import { componentOverridablePropTypeUtil } from './prop-types/component-overridable-prop-type'; import { loadComponentsAssets } from './store/actions/load-components-assets'; import { removeComponentStyles } from './store/actions/remove-component-styles'; import { componentsStylesProvider } from './store/components-styles-provider'; import { slice } from './store/store'; import { beforeSave } from './sync/before-save'; -import { initCleanupOverridablePropsOnDelete } from './sync/cleanup-overridable-props-on-delete'; -import { initHandleComponentEditModeContainer } from './sync/handle-component-edit-mode-container'; import { initLoadComponentDataAfterInstanceAdded } from './sync/load-component-data-after-instance-added'; -import { initRevertOverridablesOnCopyOrDuplicate } from './sync/revert-overridables-on-copy-or-duplicate'; import { type ExtendedWindow } from './types'; -import { onElementDrop } from './utils/tracking'; export function init() { stylesRepository.register( componentsStylesProvider ); registerSlice( slice ); - registerPanel( componentPropertiesPanel ); registerElementType( COMPONENT_WIDGET_TYPE, ( options: CreateTemplatedElementTypeOptions ) => createComponentType( { ...options, showLockedByModal: openEditModeDialog } ) ); - registerDataHook( 'dependency', 'editor/documents/close', ( args ) => { - const document = getV1CurrentDocument(); - if ( document.config.type === COMPONENT_DOCUMENT_TYPE ) { - args.mode = 'autosave'; - } - return true; - } ); - - registerDataHook( 'after', 'preview/drop', onElementDrop ); - ( window as unknown as ExtendedWindow ).elementorCommon.__beforeSave = beforeSave; injectTab( { @@ -81,26 +50,11 @@ export function init() { position: 1, } ); - injectIntoTop( { - id: 'create-component-popup', - component: CreateComponentForm, - } ); - injectIntoLogic( { id: 'components-populate-store', component: PopulateStore, } ); - injectIntoTop( { - id: 'edit-component', - component: EditComponent, - } ); - - injectIntoPanelHeaderTop( { - id: 'component-panel-header', - component: ComponentPanelHeader, - } ); - registerDataHook( 'after', 'editor/documents/attach-preview', async () => { const { id, config } = getV1CurrentDocument(); @@ -116,19 +70,6 @@ export function init() { component: LoadTemplateComponents, } ); - registerFieldIndicator( { - fieldType: FIELD_TYPE.SETTINGS, - id: 'component-overridable-prop', - priority: 1, - indicator: OverridablePropIndicator, - } ); - - registerControlReplacement( { - id: OVERRIDABLE_PROP_REPLACEMENT_ID, - component: OverridablePropControl, - condition: ( { value } ) => componentOverridablePropTypeUtil.isValid( value ), - } ); - registerEditingPanelReplacement( { id: 'component-instance-edit-panel', condition: ( _, elementType ) => elementType.key === 'e-component', @@ -139,17 +80,9 @@ export function init() { settingsTransformersRegistry.register( 'overridable', componentOverridableTransformer ); settingsTransformersRegistry.register( 'override', componentOverrideTransformer ); - initCleanupOverridablePropsOnDelete(); - - initMcp(); - initCircularNestingPrevention(); - initNonAtomicNestingPrevention(); - initLoadComponentDataAfterInstanceAdded(); - initHandleComponentEditModeContainer(); - - initRevertOverridablesOnCopyOrDuplicate(); + initExtended(); } diff --git a/packages/packages/core/editor-components/src/store/__tests__/delete-overridable-prop.test.ts b/packages/packages/core/editor-components/src/store/__tests__/delete-overridable-prop.test.ts index fd30b62b4568..d0825ebc63ec 100644 --- a/packages/packages/core/editor-components/src/store/__tests__/delete-overridable-prop.test.ts +++ b/packages/packages/core/editor-components/src/store/__tests__/delete-overridable-prop.test.ts @@ -3,6 +3,7 @@ import { getContainer, getElementSetting, updateElementSettings, type V1ElementD import { numberPropTypeUtil, type PropValue, type TransformablePropValue } from '@elementor/editor-props'; import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; +import { deleteOverridableProp } from '../../extended/store/actions/delete-overridable-prop'; import { componentInstanceOverridePropTypeUtil } from '../../prop-types/component-instance-override-prop-type'; import { componentInstanceOverridesPropTypeUtil, @@ -11,7 +12,6 @@ import { import { componentInstancePropTypeUtil } from '../../prop-types/component-instance-prop-type'; import { componentOverridablePropTypeUtil } from '../../prop-types/component-overridable-prop-type'; import type { OverridableProp, OverridableProps, PublishedComponent } from '../../types'; -import { deleteOverridableProp } from '../actions/delete-overridable-prop'; import { SLICE_NAME } from '../store'; jest.mock( '@elementor/editor-elements' ); diff --git a/packages/packages/core/editor-components/src/store/__tests__/set-overridable-prop.test.ts b/packages/packages/core/editor-components/src/store/__tests__/set-overridable-prop.test.ts index 41527eb7732d..d7c769c6e7de 100644 --- a/packages/packages/core/editor-components/src/store/__tests__/set-overridable-prop.test.ts +++ b/packages/packages/core/editor-components/src/store/__tests__/set-overridable-prop.test.ts @@ -1,8 +1,8 @@ import { __dispatch as dispatch, __getState as getState } from '@elementor/store'; import { generateUniqueId } from '@elementor/utils'; +import { setOverridableProp } from '../../extended/store/actions/set-overridable-prop'; import type { PublishedComponent } from '../../types'; -import { setOverridableProp } from '../actions/set-overridable-prop'; import { SLICE_NAME } from '../store'; jest.mock( '@elementor/store', () => ( { diff --git a/packages/packages/core/editor-components/src/sync/before-save.ts b/packages/packages/core/editor-components/src/sync/before-save.ts index ab86159d2683..2350406b0bc7 100644 --- a/packages/packages/core/editor-components/src/sync/before-save.ts +++ b/packages/packages/core/editor-components/src/sync/before-save.ts @@ -2,11 +2,7 @@ import { type V1Document } from '@elementor/editor-documents'; import { type V1Element, type V1ElementData } from '@elementor/editor-elements'; import { type DocumentSaveStatus } from '../types'; -import { createComponentsBeforeSave } from './create-components-before-save'; import { publishDraftComponentsInPageBeforeSave } from './publish-draft-components-in-page-before-save'; -import { setComponentOverridablePropsSettingsBeforeSave } from './set-component-overridable-props-settings-before-save'; -import { updateArchivedComponentBeforeSave } from './update-archived-component-before-save'; -import { updateComponentTitleBeforeSave } from './update-component-title-before-save'; type Options = { container: V1Element & { @@ -23,30 +19,5 @@ type Options = { export const beforeSave = ( { container, status }: Options ) => { const elements = container?.model.get( 'elements' ).toJSON?.() ?? []; - return Promise.all( [ - syncComponents( { elements, status } ), - setComponentOverridablePropsSettingsBeforeSave( { container } ), - ] ); -}; - -// These operations run sequentially to prevent race conditions when multiple -// edits occur on the same component simultaneously. -// TODO: Consolidate these into a single PUT /components endpoint. -const syncComponents = async ( { elements, status }: { elements: V1ElementData[]; status: DocumentSaveStatus } ) => { - // This order is important - first update existing components, then create new components, - // Since new component validation depends on the existing components (preventing duplicate names). - await updateExistingComponentsBeforeSave( { elements, status } ); - await createComponentsBeforeSave( { elements, status } ); -}; - -const updateExistingComponentsBeforeSave = async ( { - elements, - status, -}: { - elements: V1ElementData[]; - status: DocumentSaveStatus; -} ) => { - await updateComponentTitleBeforeSave( status ); - await updateArchivedComponentBeforeSave( status ); - await publishDraftComponentsInPageBeforeSave( { elements, status } ); + return publishDraftComponentsInPageBeforeSave( { elements, status } ); }; diff --git a/packages/packages/core/editor-components/src/utils/__tests__/filter-valid-overridable-props.test.ts b/packages/packages/core/editor-components/src/utils/__tests__/filter-valid-overridable-props.test.ts index 9becd6a5b8ef..666e4e3363c2 100644 --- a/packages/packages/core/editor-components/src/utils/__tests__/filter-valid-overridable-props.test.ts +++ b/packages/packages/core/editor-components/src/utils/__tests__/filter-valid-overridable-props.test.ts @@ -1,7 +1,6 @@ import { createMockElement } from 'test-utils'; import { type PropValue } from '@elementor/editor-props'; -import { getOverridableProp } from '../../components/overridable-props/utils/get-overridable-prop'; import { componentInstanceOverridePropTypeUtil } from '../../prop-types/component-instance-override-prop-type'; import { componentInstanceOverridesPropTypeUtil } from '../../prop-types/component-instance-overrides-prop-type'; import { componentInstancePropTypeUtil } from '../../prop-types/component-instance-prop-type'; @@ -9,11 +8,13 @@ import { componentOverridablePropTypeUtil } from '../../prop-types/component-ove import { type OverridableProp, type OverridableProps } from '../../types'; import { filterValidOverridableProps, isExposedPropValid } from '../filter-valid-overridable-props'; import { getContainerByOriginId } from '../get-container-by-origin-id'; +import { getOverridableProp } from '../get-overridable-prop'; jest.mock( '../get-container-by-origin-id', () => ( { getContainerByOriginId: jest.fn(), } ) ); -jest.mock( '../../components/overridable-props/utils/get-overridable-prop', () => ( { + +jest.mock( '../get-overridable-prop', () => ( { getOverridableProp: jest.fn(), } ) ); diff --git a/packages/packages/core/editor-components/src/utils/__tests__/revert-all-overridables.test.ts b/packages/packages/core/editor-components/src/utils/__tests__/revert-all-overridables.test.ts index e1a6b7f42ca3..d329c1a7c4ed 100644 --- a/packages/packages/core/editor-components/src/utils/__tests__/revert-all-overridables.test.ts +++ b/packages/packages/core/editor-components/src/utils/__tests__/revert-all-overridables.test.ts @@ -2,11 +2,14 @@ import { createMockElement, createMockElementData } from 'test-utils'; import { getAllDescendants, getContainer, updateElementSettings, type V1Element } from '@elementor/editor-elements'; import { numberPropTypeUtil } from '@elementor/editor-props'; +import { + revertAllOverridablesInContainer, + revertAllOverridablesInElementData, +} from '../../extended/utils/revert-overridable-settings'; import { componentInstanceOverridePropTypeUtil } from '../../prop-types/component-instance-override-prop-type'; import { componentInstanceOverridesPropTypeUtil } from '../../prop-types/component-instance-overrides-prop-type'; import { componentInstancePropTypeUtil } from '../../prop-types/component-instance-prop-type'; import { componentOverridablePropTypeUtil } from '../../prop-types/component-overridable-prop-type'; -import { revertAllOverridablesInContainer, revertAllOverridablesInElementData } from '../revert-overridable-settings'; jest.mock( '@elementor/editor-elements', () => ( { ...jest.requireActual( '@elementor/editor-elements' ), diff --git a/packages/packages/core/editor-components/src/utils/__tests__/revert-element-overridable-setting.test.ts b/packages/packages/core/editor-components/src/utils/__tests__/revert-element-overridable-setting.test.ts index bd28aaa88b64..f1a5d5430d8a 100644 --- a/packages/packages/core/editor-components/src/utils/__tests__/revert-element-overridable-setting.test.ts +++ b/packages/packages/core/editor-components/src/utils/__tests__/revert-element-overridable-setting.test.ts @@ -2,11 +2,11 @@ import { createMockElement } from 'test-utils'; import { getContainer, getElementSetting, updateElementSettings } from '@elementor/editor-elements'; import { numberPropTypeUtil } from '@elementor/editor-props'; +import { revertElementOverridableSetting } from '../../extended/utils/revert-overridable-settings'; import { componentInstanceOverridePropTypeUtil } from '../../prop-types/component-instance-override-prop-type'; import { componentInstanceOverridesPropTypeUtil } from '../../prop-types/component-instance-overrides-prop-type'; import { componentInstancePropTypeUtil } from '../../prop-types/component-instance-prop-type'; import { componentOverridablePropTypeUtil } from '../../prop-types/component-overridable-prop-type'; -import { revertElementOverridableSetting } from '../revert-overridable-settings'; jest.mock( '@elementor/editor-elements', () => ( { ...jest.requireActual( '@elementor/editor-elements' ), diff --git a/packages/packages/core/editor-components/src/utils/component-name-validation.ts b/packages/packages/core/editor-components/src/utils/component-name-validation.ts index c85596768dcc..66a0aa003322 100644 --- a/packages/packages/core/editor-components/src/utils/component-name-validation.ts +++ b/packages/packages/core/editor-components/src/utils/component-name-validation.ts @@ -1,6 +1,6 @@ import { __getState as getState } from '@elementor/store'; -import { createSubmitComponentSchema } from '../components/create-component-form/utils/component-form-schema'; +import { createSubmitComponentSchema } from '../extended/components/create-component-form/utils/component-form-schema'; import { selectComponents } from '../store/store'; type ValidationResult = { isValid: true; errorMessage: null } | { isValid: false; errorMessage: string }; diff --git a/packages/packages/core/editor-components/src/components/create-component-form/utils/replace-element-with-component.ts b/packages/packages/core/editor-components/src/utils/create-component-model.ts similarity index 55% rename from packages/packages/core/editor-components/src/components/create-component-form/utils/replace-element-with-component.ts rename to packages/packages/core/editor-components/src/utils/create-component-model.ts index 3440637c3ec6..b5a962107965 100644 --- a/packages/packages/core/editor-components/src/components/create-component-form/utils/replace-element-with-component.ts +++ b/packages/packages/core/editor-components/src/utils/create-component-model.ts @@ -1,19 +1,11 @@ -import { replaceElement, type V1ElementData, type V1ElementModelProps } from '@elementor/editor-elements'; +import { type V1ElementModelProps } from '@elementor/editor-elements'; -type ComponentInstanceParams = { +export type ComponentInstanceParams = { id?: number; name: string; uid: string; }; -export const replaceElementWithComponent = async ( element: V1ElementData, component: ComponentInstanceParams ) => { - return await replaceElement( { - currentElement: element, - newElement: createComponentModel( component ), - withHistory: false, - } ); -}; - export const createComponentModel = ( component: ComponentInstanceParams ): Omit< V1ElementModelProps, 'id' > => { return { elType: 'widget', diff --git a/packages/packages/core/editor-components/src/utils/expand-navigator.ts b/packages/packages/core/editor-components/src/utils/expand-navigator.ts deleted file mode 100644 index bf5554d1a812..000000000000 --- a/packages/packages/core/editor-components/src/utils/expand-navigator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { __privateRunCommand as runCommand } from '@elementor/editor-v1-adapters'; - -export async function expandNavigator() { - await runCommand( 'navigator/expand-all' ); -} diff --git a/packages/packages/core/editor-components/src/utils/filter-valid-overridable-props.ts b/packages/packages/core/editor-components/src/utils/filter-valid-overridable-props.ts index 23cd5e9976b9..f5d0d2223872 100644 --- a/packages/packages/core/editor-components/src/utils/filter-valid-overridable-props.ts +++ b/packages/packages/core/editor-components/src/utils/filter-valid-overridable-props.ts @@ -1,10 +1,10 @@ -import { getOverridableProp } from '../components/overridable-props/utils/get-overridable-prop'; import { type ComponentInstanceOverride } from '../prop-types/component-instance-overrides-prop-type'; import { componentInstanceOverridesPropTypeUtil } from '../prop-types/component-instance-overrides-prop-type'; import { componentInstancePropTypeUtil } from '../prop-types/component-instance-prop-type'; import { componentOverridablePropTypeUtil } from '../prop-types/component-overridable-prop-type'; import { type OverridableProp, type OverridableProps } from '../types'; import { getContainerByOriginId } from './get-container-by-origin-id'; +import { getOverridableProp } from './get-overridable-prop'; import { extractInnerOverrideInfo } from './overridable-props-utils'; export function filterValidOverridableProps( diff --git a/packages/packages/core/editor-components/src/components/overridable-props/utils/get-overridable-prop.ts b/packages/packages/core/editor-components/src/utils/get-overridable-prop.ts similarity index 76% rename from packages/packages/core/editor-components/src/components/overridable-props/utils/get-overridable-prop.ts rename to packages/packages/core/editor-components/src/utils/get-overridable-prop.ts index fc2666f33415..f4998f33f091 100644 --- a/packages/packages/core/editor-components/src/components/overridable-props/utils/get-overridable-prop.ts +++ b/packages/packages/core/editor-components/src/utils/get-overridable-prop.ts @@ -1,7 +1,7 @@ import { __getState as getState } from '@elementor/store'; -import { selectOverridableProps } from '../../../store/store'; -import { type OverridableProp } from '../../../types'; +import { selectOverridableProps } from '../store/store'; +import { type OverridableProp } from '../types'; export function getOverridableProp( { componentId, diff --git a/packages/packages/core/editor-components/src/utils/switch-to-component.ts b/packages/packages/core/editor-components/src/utils/switch-to-component.ts index e00bdc1ff7a2..c35154553e9c 100644 --- a/packages/packages/core/editor-components/src/utils/switch-to-component.ts +++ b/packages/packages/core/editor-components/src/utils/switch-to-component.ts @@ -1,7 +1,6 @@ import { invalidateDocumentData, switchToDocument } from '@elementor/editor-documents'; import { getCurrentDocumentContainer, selectElement } from '@elementor/editor-elements'; - -import { expandNavigator } from './expand-navigator'; +import { __privateRunCommand as runCommand } from '@elementor/editor-v1-adapters'; export async function switchToComponent( componentId: number, @@ -28,6 +27,10 @@ export async function switchToComponent( } } +export async function expandNavigator() { + await runCommand( 'navigator/expand-all' ); +} + function getSelector( element?: HTMLElement | null, componentInstanceId?: string | null ): string | undefined { if ( element ) { return buildUniqueSelector( element ); From bc5977949e7dfcae841bcbb875936db9bdd015a0 Mon Sep 17 00:00:00 2001 From: Mike Kokhanov Date: Sun, 22 Feb 2026 17:56:44 +0200 Subject: [PATCH 14/38] Internal: Use priority system for floating actions [ED-22673] (#34850) ## PR Checklist - [ ] The commit message follows our guidelines: https://github.com/elementor/elementor/blob/master/.github/CONTRIBUTING.md ## PR Type What kind of change does this PR introduce? - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, local variables) - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] CI related changes - [ ] Documentation content changes - [ ] Other... Please describe: ## Summary This PR can be summarized in the following changelog entry: * ## Description An explanation of what is done in this PR * ## Test instructions This PR can be tested by following these steps: * ## Quality assurance - [ ] I have tested this code to the best of my abilities - [ ] I have added unittests to verify the code works as intended - [ ] Docs have been added / updated (for bug fixes / features) Fixes # --- packages/packages/core/editor-editing-panel/src/dynamics/init.ts | 1 + .../packages/core/editor-editing-panel/src/reset-style-props.tsx | 1 + packages/packages/core/editor-variables/src/init.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/packages/core/editor-editing-panel/src/dynamics/init.ts b/packages/packages/core/editor-editing-panel/src/dynamics/init.ts index ae188b060821..ba1979e2fd87 100644 --- a/packages/packages/core/editor-editing-panel/src/dynamics/init.ts +++ b/packages/packages/core/editor-editing-panel/src/dynamics/init.ts @@ -42,6 +42,7 @@ export const init = () => { registerPopoverAction( { id: 'dynamic-tags', + priority: 20, useProps: usePropDynamicAction, } ); diff --git a/packages/packages/core/editor-editing-panel/src/reset-style-props.tsx b/packages/packages/core/editor-editing-panel/src/reset-style-props.tsx index b59d49c4bff2..22eaad8ba0f2 100644 --- a/packages/packages/core/editor-editing-panel/src/reset-style-props.tsx +++ b/packages/packages/core/editor-editing-panel/src/reset-style-props.tsx @@ -13,6 +13,7 @@ const { registerAction } = controlActionsMenu; export function initResetStyleProps() { registerAction( { id: 'reset-style-value', + priority: 10, useProps: useResetStyleValueProps, } ); } diff --git a/packages/packages/core/editor-variables/src/init.ts b/packages/packages/core/editor-variables/src/init.ts index 0e9b442c6d22..a83307c7a6a5 100644 --- a/packages/packages/core/editor-variables/src/init.ts +++ b/packages/packages/core/editor-variables/src/init.ts @@ -38,6 +38,7 @@ export function init() { registerPopoverAction( { id: 'variables', + priority: 40, useProps: usePropVariableAction, } ); From 716213a86e49922414da80d691e32abeb5f87c34 Mon Sep 17 00:00:00 2001 From: Maksim Zubov Date: Mon, 23 Feb 2026 09:48:10 +0100 Subject: [PATCH 15/38] Internal: Events implementation - Editor [Activation] [ED-22167] (#34521) --- .../components/template-library/component.js | 26 +- .../template-library/views/template/remote.js | 13 + .../views/container/empty-component.js | 11 +- .../panel/pages/elements/views/search.js | 34 +- .../dev/js/editor/utils/editor-one-events.js | 295 ++++++++++++++++++ .../dev/js/editor/views/add-section/base.js | 26 +- .../editor/views/add-section/independent.js | 5 + .../dev/js/editor/views/add-section/inline.js | 5 + .../events-manager/assets/js/events-config.js | 68 ++++ .../finder/assets/js/modal/views/content.js | 27 ++ .../finder/assets/js/modal/views/item.js | 9 + core/kits/assets/js/commands/back.js | 45 +++ core/kits/assets/js/commands/close.js | 39 ++- core/kits/assets/js/component.js | 32 +- .../js/hooks/ui/document/save/save/after.js | 28 ++ .../ai/assets/js/editor/ai-layout-behavior.js | 5 + .../library/apply-template-for-ai-behavior.js | 14 + .../js/editor/views/add-section-area.js | 11 +- .../assets/js/editor/views/select-preset.js | 22 +- packages/package-lock.json | 1 + .../use-document-copy-and-share-props.ts | 20 +- .../hooks/use-document-save-draft-props.ts | 20 +- .../hooks/use-document-save-template-props.ts | 20 +- .../hooks/use-document-view-page-props.ts | 28 +- .../core/editor-site-navigation/package.json | 1 + .../top-bar/create-post-list-item.tsx | 16 + .../src/components/top-bar/post-list-item.tsx | 16 + packages/types/global.d.ts | 22 +- 28 files changed, 826 insertions(+), 33 deletions(-) create mode 100644 assets/dev/js/editor/utils/editor-one-events.js diff --git a/assets/dev/js/editor/components/template-library/component.js b/assets/dev/js/editor/components/template-library/component.js index d8d8f37662cd..cf9ed5a2aa1e 100644 --- a/assets/dev/js/editor/components/template-library/component.js +++ b/assets/dev/js/editor/components/template-library/component.js @@ -2,6 +2,7 @@ import ComponentModalBase from 'elementor-api/modules/component-modal-base'; import * as commands from './commands/'; import * as commandsData from './commands-data/'; import { SAVE_CONTEXTS } from './constants'; +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; const TemplateLibraryLayoutView = require( 'elementor-templates/views/library-layout' ); @@ -110,11 +111,17 @@ export default class Component extends ComponentModalBase { const currentTab = this.tabs[ tab ]; const filter = currentTab.getFilter ? currentTab.getFilter() : currentTab.filter; + this.trackLibraryNavigation( tab, currentTab.title ); + this.currentTab = tab; this.manager.setScreen( filter ); } + trackLibraryNavigation( tab, tabTitle ) { + EditorOneEventManager.sendELibraryNav( tabTitle || tab ); + } + activateTab( tab ) { $e.routes.saveState( 'library' ); @@ -178,13 +185,28 @@ export default class Component extends ComponentModalBase { // TODO: Move function to 'insert-template' command. insertTemplate( args ) { this.downloadTemplate( args, ( data, callbackParams ) => { + const model = callbackParams.model; + const source = model.get( 'source' ) ?? 'local'; + const templateType = model.get( 'type' ); + const templateTitle = model.get( 'title' ); + const templateId = model.get( 'template_id' ); + const baseTier = elementor.config.library_connect?.base_access_tier; + const templateTier = model.get( 'accessTier' ); + $e.run( 'document/elements/import', { - model: callbackParams.model, + model, data, options: callbackParams.importOptions, onAfter: () => { this.manager.eventManager.sendTemplateInsertedEvent( { - library_type: callbackParams.model.get( 'source' ) ?? 'local', + library_type: source, + } ); + + EditorOneEventManager.sendELibraryInsert( { + assetId: templateId, + assetName: templateTitle, + libraryType: templateType || source, + proRequired: baseTier !== templateTier, } ); }, } ); diff --git a/assets/dev/js/editor/components/template-library/views/template/remote.js b/assets/dev/js/editor/components/template-library/views/template/remote.js index 55391a154c19..68cc743c49c6 100644 --- a/assets/dev/js/editor/components/template-library/views/template/remote.js +++ b/assets/dev/js/editor/components/template-library/views/template/remote.js @@ -1,3 +1,5 @@ +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; + var TemplateLibraryTemplateView = require( 'elementor-templates/views/template/base' ), TemplateLibraryTemplateRemoteView; @@ -36,6 +38,17 @@ TemplateLibraryTemplateRemoteView = TemplateLibraryTemplateView.extend( { elementor.templates.markAsFavorite( this.model, isFavorite ); + const baseTier = elementor.config.library_connect?.base_access_tier; + const templateTier = this.model.get( 'accessTier' ); + + EditorOneEventManager.sendELibraryFavorite( { + assetId: this.model.get( 'template_id' ), + assetName: this.model.get( 'title' ), + libraryType: this.model.get( 'type' ) || this.model.get( 'source' ), + isFavorite, + proRequired: baseTier !== templateTier, + } ); + if ( ! isFavorite && elementor.templates.getFilter( 'favorite' ) ) { elementor.channels.templates.trigger( 'filter:change' ); } diff --git a/assets/dev/js/editor/elements/views/container/empty-component.js b/assets/dev/js/editor/elements/views/container/empty-component.js index 169c31d98ecb..f4cf72a96512 100644 --- a/assets/dev/js/editor/elements/views/container/empty-component.js +++ b/assets/dev/js/editor/elements/views/container/empty-component.js @@ -1,9 +1,18 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; + export default function EmptyComponent() { + const handleClick = () => { + EditorOneEventManager.sendCanvasEmptyBoxAction( { + targetName: 'add_container', + } ); + $e.route( 'panel/elements/categories' ); + }; + return (
-
$e.route( 'panel/elements/categories' ) } /> +
); } diff --git a/assets/dev/js/editor/regions/panel/pages/elements/views/search.js b/assets/dev/js/editor/regions/panel/pages/elements/views/search.js index 441cfdb4ecbd..f82bb7cbea68 100644 --- a/assets/dev/js/editor/regions/panel/pages/elements/views/search.js +++ b/assets/dev/js/editor/regions/panel/pages/elements/views/search.js @@ -1,10 +1,14 @@ import LocalizedValueStore from 'elementor-editor-utils/localized-value-store'; +import { createDebouncedWidgetPanelSearch } from 'elementor-editor-utils/editor-one-events'; + +const WIDGET_PANEL_SEARCH_DEBOUNCE_MS = 2000; const PanelElementsSearchView = Marionette.ItemView.extend( { template: '#tmpl-elementor-panel-element-search', localizedValue: '', localizedValueStore: new LocalizedValueStore(), + debouncedTrackSearch: null, tagName: 'search', @@ -19,19 +23,47 @@ const PanelElementsSearchView = Marionette.ItemView.extend( { 'input @ui.input': 'onInputChanged', // Will capture the context menu paste }, + initialize() { + this.debouncedTrackSearch = createDebouncedWidgetPanelSearch( WIDGET_PANEL_SEARCH_DEBOUNCE_MS ); + }, + clearInput() { this.ui.input.val( '' ); }, + getVisibleWidgetsCount() { + const $widgetsContainer = jQuery( '#elementor-panel-elements' ); + return $widgetsContainer.find( '.elementor-element:visible' ).length; + }, + + trackWidgetSearch() { + const userInput = this.ui.input.val(); + if ( ! userInput ) { + return; + } + + setTimeout( () => { + const resultsCount = this.getVisibleWidgetsCount(); + this.debouncedTrackSearch( resultsCount, userInput ); + }, 100 ); + }, + onInputChanged( event ) { const ESC_KEY = 27; if ( ESC_KEY === event.keyCode ) { this.clearInput(); } this.localizedValue = this.localizedValueStore.appendAndParseLocalizedData( event ); - // Broadcast the localized value. elementor.channels.panelElements.reply( 'filter:localized', this.localizedValue ); this.triggerMethod( 'search:change:input' ); + + this.trackWidgetSearch(); + }, + + onDestroy() { + if ( this.debouncedTrackSearch?.cancel ) { + this.debouncedTrackSearch.cancel(); + } }, } ); diff --git a/assets/dev/js/editor/utils/editor-one-events.js b/assets/dev/js/editor/utils/editor-one-events.js new file mode 100644 index 000000000000..5fea95a96d37 --- /dev/null +++ b/assets/dev/js/editor/utils/editor-one-events.js @@ -0,0 +1,295 @@ +export class EditorOneEventManager { + static getEventsManager() { + return elementorCommon?.eventsManager; + } + + static getConfig() { + return this.getEventsManager()?.config; + } + + static canSendEvents() { + return elementorCommon?.config?.editor_events?.can_send_events || false; + } + + static isEventsManagerAvailable() { + const eventsManager = this.getEventsManager(); + return eventsManager && 'function' === typeof eventsManager.dispatchEvent; + } + + static dispatchEvent( eventName, payload ) { + if ( ! this.isEventsManagerAvailable() || ! this.canSendEvents() ) { + return false; + } + + try { + return this.getEventsManager().dispatchEvent( eventName, payload ); + } catch ( error ) { + return false; + } + } + + static toLowerSnake( value ) { + if ( ! value || 'string' !== typeof value ) { + return value; + } + return value.replace( /\s+/g, '_' ).toLowerCase(); + } + + static decodeHtmlEntities( text ) { + if ( ! text || 'string' !== typeof text ) { + return text; + } + const doc = new DOMParser().parseFromString( text, 'text/html' ); + return doc.body.textContent || text; + } + + static isInEditorContext() { + return 'undefined' !== typeof window.elementor && !! window.elementor?.documents; + } + + static getFinderContext() { + const config = this.getConfig(); + const isEditor = this.isInEditorContext(); + + return { + windowName: isEditor ? config?.appTypes?.editor : config?.appTypes?.wpAdmin, + targetLocation: this.toLowerSnake( isEditor ? config?.locations?.topBar : config?.locations?.sidebar ), + }; + } + + static createBasePayload( overrides = {} ) { + const config = this.getConfig(); + return { + app_type: config?.appTypes?.editor ?? 'editor', + window_name: config?.appTypes?.editor ?? 'editor', + ...overrides, + }; + } + + static sendTopBarPublishDropdown( targetName ) { + const config = this.getConfig(); + return this.dispatchEvent( config?.names?.editorOne?.topBarPublishDropdown, this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.click ), + target_type: config?.targetTypes?.dropdownItem, + target_name: targetName, + interaction_result: config?.interactionResults?.actionSelected, + target_location: this.toLowerSnake( config?.locations?.topBar ), + location_l1: this.toLowerSnake( config?.secondaryLocations?.publishDropdown ), + location_l2: config?.targetTypes?.dropdownItem, + interaction_description: 'User selected an action from the publish dropdown', + } ) ); + } + + static sendTopBarPageList( targetName, isCreate = false ) { + const config = this.getConfig(); + return this.dispatchEvent( config?.names?.editorOne?.topBarPageList, this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.click ), + target_type: config?.targetTypes?.dropdownItem, + target_name: targetName, + interaction_result: isCreate ? config?.interactionResults?.create : config?.interactionResults?.navigate, + target_location: this.toLowerSnake( config?.locations?.topBar ), + location_l1: this.toLowerSnake( config?.secondaryLocations?.pageListDropdown ), + location_l2: config?.targetTypes?.dropdownItem, + interaction_description: 'User selected an action from the page list dropdown', + } ) ); + } + + static sendSiteSettingsSession( { targetType, visitedItems = [], savedItems = [], state } ) { + const config = this.getConfig(); + return this.dispatchEvent( config?.names?.editorOne?.siteSettingsSession, this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.click ), + target_type: targetType, + target_name: 'site_settings', + interaction_result: config?.interactionResults?.sessionEnd, + target_location: this.toLowerSnake( config?.locations?.leftPanel ), + location_l1: this.toLowerSnake( config?.secondaryLocations?.siteSettings ), + interaction_description: 'Records areas visited as part of the site setting session', + metadata: { + visited_items: visitedItems, + saved_items: savedItems, + }, + state, + } ) ); + } + + static sendELibraryNav( tabName ) { + const config = this.getConfig(); + return this.dispatchEvent( config?.names?.editorOne?.eLibraryNav, this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.tabSelect ), + target_type: config?.targetTypes?.tab, + target_name: this.toLowerSnake( tabName ), + interaction_result: config?.interactionResults?.tabChanged, + target_location: this.toLowerSnake( config?.locations?.elementorLibrary ), + location_l1: this.toLowerSnake( config?.secondaryLocations?.libraryTabs ), + interaction_description: 'User navigates within elementor library', + } ) ); + } + + static sendELibraryInsert( { assetId, assetName, libraryType, proRequired = false } ) { + const config = this.getConfig(); + const payload = this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.insert ), + target_type: config?.targetTypes?.button, + target_name: String( assetId ), + interaction_result: config?.interactionResults?.assetInserted, + target_location: this.toLowerSnake( config?.locations?.elementorLibrary ), + location_l1: this.toLowerSnake( libraryType ), + location_l2: this.toLowerSnake( config?.secondaryLocations?.assetCard ), + interaction_description: 'User inserts block/pages from elementor library', + metadata: { + template_id: String( assetId ), + template_name: this.decodeHtmlEntities( assetName ) || '', + }, + } ); + + if ( proRequired ) { + payload.state = 'pro_plan_required'; + } + + return this.dispatchEvent( config?.names?.editorOne?.eLibraryInsert, payload ); + } + + static sendELibraryFavorite( { assetId, assetName, libraryType, isFavorite, proRequired = false } ) { + const config = this.getConfig(); + const payload = this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.click ), + target_type: config?.targetTypes?.toggle, + target_name: String( assetId ), + interaction_result: config?.interactionResults?.assetFavorite, + target_value: Boolean( isFavorite ), + target_location: this.toLowerSnake( config?.locations?.elementorLibrary ), + location_l1: this.toLowerSnake( libraryType ), + location_l2: this.toLowerSnake( config?.secondaryLocations?.assetCard ), + interaction_description: 'User favorite block/pages from elementor library', + metadata: { + template_id: String( assetId ), + template_name: this.decodeHtmlEntities( assetName ) || '', + }, + } ); + + if ( proRequired ) { + payload.state = 'pro_plan_required'; + } + + return this.dispatchEvent( config?.names?.editorOne?.eLibraryFavorite, payload ); + } + + static sendELibraryGenerateAi( { assetId, assetName, libraryType } ) { + const config = this.getConfig(); + return this.dispatchEvent( config?.names?.editorOne?.eLibraryGenerateAi, this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.click ), + target_type: config?.targetTypes?.button, + target_name: String( assetId ), + interaction_result: config?.interactionResults?.aiGenerate, + target_location: this.toLowerSnake( config?.locations?.elementorLibrary ), + location_l1: this.toLowerSnake( libraryType ), + location_l2: this.toLowerSnake( config?.secondaryLocations?.assetCard ), + interaction_description: 'User generated block/page based on a library asset', + metadata: { + template_id: String( assetId ), + template_name: this.decodeHtmlEntities( assetName ) || '', + }, + } ) ); + } + + static sendFinderSearchInput( { resultsCount, searchTerm = null } ) { + const config = this.getConfig(); + const hasResults = resultsCount > 0; + const finderContext = this.getFinderContext(); + + const payload = this.createBasePayload( { + window_name: finderContext.windowName, + interaction_type: this.toLowerSnake( config?.triggers?.typing ), + target_type: config?.targetTypes?.searchInput, + target_name: 'finder', + interaction_result: hasResults ? config?.interactionResults?.resultsUpdated : config?.interactionResults?.noResults, + target_location: finderContext.targetLocation, + location_l1: this.toLowerSnake( config?.secondaryLocations?.finder ), + interaction_description: 'Finder search input, follows debounce behavior', + metadata: { + results_count: resultsCount, + }, + } ); + + if ( ! hasResults && searchTerm ) { + payload.metadata.search_term = searchTerm; + } + + return this.dispatchEvent( config?.names?.editorOne?.finderSearchInput, payload ); + } + + static sendFinderResultSelect( choice ) { + const config = this.getConfig(); + const finderContext = this.getFinderContext(); + + return this.dispatchEvent( config?.names?.editorOne?.finderResultSelect, this.createBasePayload( { + window_name: finderContext.windowName, + interaction_type: this.toLowerSnake( config?.triggers?.click ), + target_type: config?.targetTypes?.searchResult, + target_name: choice, + interaction_result: config?.interactionResults?.selected, + target_location: finderContext.targetLocation, + location_l1: this.toLowerSnake( config?.secondaryLocations?.finder ), + location_l2: this.toLowerSnake( config?.secondaryLocations?.finderResults ), + interaction_description: 'Finder search results was selected', + } ) ); + } + + static sendCanvasEmptyBoxAction( { targetName, metadata = {}, containerCreated = null } ) { + const config = this.getConfig(); + const payload = this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.click ), + target_type: config?.targetTypes?.buttons, + target_name: targetName, + interaction_result: config?.interactionResults?.selected, + target_location: this.toLowerSnake( config?.locations?.canvas ), + location_l1: this.toLowerSnake( config?.secondaryLocations?.emptyBox ), + interaction_description: 'Empty box on canvas actions', + } ); + + if ( Object.keys( metadata ).length > 0 ) { + payload.metadata = metadata; + } + + if ( containerCreated !== null ) { + payload.state = containerCreated; + } + + return this.dispatchEvent( config?.names?.editorOne?.canvasEmptyBoxAction, payload ); + } + + static sendWidgetPanelSearch( { resultsCount, userInput = null } ) { + const config = this.getConfig(); + const hasResults = resultsCount > 0; + const payload = this.createBasePayload( { + interaction_type: this.toLowerSnake( config?.triggers?.typing ), + target_type: config?.targetTypes?.searchWidget, + target_name: 'search_widget', + interaction_result: hasResults ? config?.interactionResults?.resultsUpdated : config?.interactionResults?.noResults, + target_location: this.toLowerSnake( config?.locations?.leftPanel ), + location_l1: this.toLowerSnake( config?.locations?.widgetPanel ), + location_l2: this.toLowerSnake( config?.secondaryLocations?.searchBar ), + interaction_description: 'Widget search input, follows debounce behavior', + } ); + + if ( ! hasResults && userInput ) { + payload.metadata = { user_input: userInput }; + } + + return this.dispatchEvent( config?.names?.editorOne?.widgetPanelSearch, payload ); + } +} + +export const createDebouncedFinderSearch = ( delay = 300 ) => { + return _.debounce( ( resultsCount, searchTerm ) => { + EditorOneEventManager.sendFinderSearchInput( { resultsCount, searchTerm } ); + }, delay ); +}; + +export const createDebouncedWidgetPanelSearch = ( delay = 2000 ) => { + return _.debounce( ( resultsCount, userInput ) => { + EditorOneEventManager.sendWidgetPanelSearch( { resultsCount, userInput } ); + }, delay ); +}; + +export default EditorOneEventManager; diff --git a/assets/dev/js/editor/views/add-section/base.js b/assets/dev/js/editor/views/add-section/base.js index 7e6a6ba040d9..ccbaef1aee2b 100644 --- a/assets/dev/js/editor/views/add-section/base.js +++ b/assets/dev/js/editor/views/add-section/base.js @@ -1,5 +1,6 @@ import ContainerHelper from 'elementor-editor-utils/container-helper'; import environment from 'elementor-common/utils/environment'; +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; /** * @typedef {import('../../container/container')} Container @@ -166,6 +167,9 @@ class AddSectionBase extends Marionette.ItemView { } onAddTemplateButtonClick() { + EditorOneEventManager.sendCanvasEmptyBoxAction( { + targetName: 'e_library', + } ); $e.run( 'library/open', this.getTemplatesModalOptions() ); } @@ -202,6 +206,15 @@ class AddSectionBase extends Marionette.ItemView { parsedStructure = elementor.presetsFactory.getParsedGridStructure( selectedStructure ), isAddedAboveAnotherContainer = !! this.options.at || 0 === this.options.at; + EditorOneEventManager.sendCanvasEmptyBoxAction( { + targetName: 'add_container', + metadata: { + container_type: 'grid', + structure_type: selectedStructure, + }, + containerCreated: true, + } ); + const newContainer = ContainerHelper.createContainer( { container_type: ContainerHelper.CONTAINER_TYPE_GRID, @@ -265,8 +278,19 @@ class AddSectionBase extends Marionette.ItemView { onFlexPresetSelected( e ) { this.closeSelectPresets(); + const preset = e.currentTarget.dataset.preset; + + EditorOneEventManager.sendCanvasEmptyBoxAction( { + targetName: 'add_container', + metadata: { + container_type: 'flexbox', + structure_type: preset, + }, + containerCreated: true, + } ); + return ContainerHelper.createContainerFromPreset( - e.currentTarget.dataset.preset, + preset, elementor.getPreviewContainer(), this.options, ); diff --git a/assets/dev/js/editor/views/add-section/independent.js b/assets/dev/js/editor/views/add-section/independent.js index ce2ac3e28d1c..de8d95e4b02f 100644 --- a/assets/dev/js/editor/views/add-section/independent.js +++ b/assets/dev/js/editor/views/add-section/independent.js @@ -1,4 +1,5 @@ import BaseAddSectionView from './base'; +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; export default class AddSectionView extends BaseAddSectionView { get id() { @@ -35,6 +36,10 @@ export default class AddSectionView extends BaseAddSectionView { } onCloseButtonClick() { + EditorOneEventManager.sendCanvasEmptyBoxAction( { + targetName: 'close', + containerCreated: false, + } ); this.closeSelectPresets(); } diff --git a/assets/dev/js/editor/views/add-section/inline.js b/assets/dev/js/editor/views/add-section/inline.js index 9d069f1c457b..300260d879ba 100644 --- a/assets/dev/js/editor/views/add-section/inline.js +++ b/assets/dev/js/editor/views/add-section/inline.js @@ -1,4 +1,5 @@ import BaseAddSectionView from './base'; +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; class AddSectionView extends BaseAddSectionView { className() { @@ -20,6 +21,10 @@ class AddSectionView extends BaseAddSectionView { } onCloseButtonClick() { + EditorOneEventManager.sendCanvasEmptyBoxAction( { + targetName: 'close', + containerCreated: false, + } ); this.fadeToDeath(); } diff --git a/core/common/modules/events-manager/assets/js/events-config.js b/core/common/modules/events-manager/assets/js/events-config.js index ff7b0e62fd4c..5ebfd7e56995 100644 --- a/core/common/modules/events-manager/assets/js/events-config.js +++ b/core/common/modules/events-manager/assets/js/events-config.js @@ -1,4 +1,46 @@ const eventsConfig = { + appTypes: { + editor: 'editor', + wpAdmin: 'wpadmin', + }, + + targetTypes: { + dropdownItem: 'dropdown_item', + button: 'button', + tab: 'tab', + toggle: 'toggle', + searchInput: 'search_input', + searchResult: 'search_result', + buttons: 'buttons', + searchWidget: 'search_widget', + }, + + interactionResults: { + actionSelected: 'action_selected', + navigate: 'navigate', + create: 'create', + sessionEnd: 'session_end', + tabChanged: 'tab_changed', + assetInserted: 'asset_inserted', + assetFavorite: 'asset_favorite', + aiGenerate: 'ai_generate', + resultsUpdated: 'results_updated', + noResults: 'no_results', + selected: 'selected', + }, + + targetNames: { + publishDropdown: { + saveDraft: 'save_draft', + saveAsTemplate: 'save_as_template', + viewPage: 'view_page', + copyAndShare: 'copy_and_share', + }, + pageList: { + addNewPage: 'add_new_page', + }, + }, + triggers: { click: 'Click', rightClick: 'Right Click', @@ -9,11 +51,15 @@ const eventsConfig = { editorLoaded: 'Editor Loaded', visible: 'Visible', pageLoaded: 'Page Loaded', + typing: 'Typing', + tabSelect: 'Tab Select', + insert: 'Insert', }, locations: { widgetPanel: 'Widget Panel', topBar: 'Top Bar', + sidebar: 'Sidebar', elementorEditor: 'Elementor Editor', templatesLibrary: { library: 'Templates Library', @@ -29,6 +75,8 @@ const eventsConfig = { admin: 'WP admin', structurePanel: 'Structure Panel', canvas: 'Canvas', + leftPanel: 'Left Panel', + elementorLibrary: 'Elementor Library', }, secondaryLocations: { @@ -105,6 +153,13 @@ const eventsConfig = { }, componentsTab: 'Components Tab', canvasElement: 'Canvas Element', + publishDropdown: 'Publish Dropdown', + pageListDropdown: 'Page List Dropdown', + emptyBox: 'Empty Box', + searchBar: 'Search Bar', + finderResults: 'Finder Results', + libraryTabs: 'Library Tabs', + assetCard: 'Asset Card', }, elements: { @@ -206,6 +261,19 @@ const eventsConfig = { classUsageClicked: 'class_usage_clicked', classDuplicate: 'class_duplicate', }, + editorOne: { + topBarPublishDropdown: 'top_bar_publish_dropdown', + topBarPageList: 'top_bar_page_list', + siteSettingsSession: 'site_settings_session', + eLibraryNav: 'e_library_nav', + eLibraryInsert: 'e_library_insert', + eLibraryFavorite: 'e_library_favorite', + eLibraryGenerateAi: 'e_library_generate_ai', + finderSearchInput: 'finder_search_input', + finderResultSelect: 'finder_result_select', + canvasEmptyBoxAction: 'canvas_empty_box_action', + widgetPanelSearch: 'widget_panel_search', + }, }, }; diff --git a/core/common/modules/finder/assets/js/modal/views/content.js b/core/common/modules/finder/assets/js/modal/views/content.js index 2f6a3113af08..16b6218012ca 100644 --- a/core/common/modules/finder/assets/js/modal/views/content.js +++ b/core/common/modules/finder/assets/js/modal/views/content.js @@ -1,4 +1,7 @@ import CategoriesView from './categories'; +import { createDebouncedFinderSearch } from 'elementor-editor-utils/editor-one-events'; + +const FINDER_SEARCH_DEBOUNCE_MS = 300; export default class extends Marionette.LayoutView { id() { @@ -27,10 +30,23 @@ export default class extends Marionette.LayoutView { }; } + initialize() { + this.debouncedTrackSearch = createDebouncedFinderSearch( FINDER_SEARCH_DEBOUNCE_MS ); + } + showCategoriesView() { this.content.show( new CategoriesView() ); } + getResultsCount() { + if ( ! this.content.currentView ) { + return 0; + } + + const $visibleItems = this.content.currentView.$el.find( '.elementor-finder__results__item:visible' ); + return $visibleItems.length; + } + onSearchInputInput() { const value = this.ui.searchInput.val(); @@ -42,8 +58,19 @@ export default class extends Marionette.LayoutView { if ( ! ( this.content.currentView instanceof CategoriesView ) ) { this.showCategoriesView(); } + + setTimeout( () => { + const resultsCount = this.getResultsCount(); + this.debouncedTrackSearch( resultsCount, value ); + }, 50 ); } this.content.currentView.$el.toggle( ! ! value ); } + + onDestroy() { + if ( this.debouncedTrackSearch?.cancel ) { + this.debouncedTrackSearch.cancel(); + } + } } diff --git a/core/common/modules/finder/assets/js/modal/views/item.js b/core/common/modules/finder/assets/js/modal/views/item.js index 7460c128b1d9..47129bbbc2ae 100644 --- a/core/common/modules/finder/assets/js/modal/views/item.js +++ b/core/common/modules/finder/assets/js/modal/views/item.js @@ -1,3 +1,5 @@ +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; + export default class extends Marionette.ItemView { className() { return 'elementor-finder__results__item'; @@ -11,10 +13,16 @@ export default class extends Marionette.ItemView { this.$el[ 0 ].addEventListener( 'click', this.onClick.bind( this ), true ); } + trackResultSelect() { + const title = this.model.get( 'title' ); + EditorOneEventManager.sendFinderResultSelect( title ); + } + onClick( e ) { const lockOptions = this.model.get( 'lock' ); if ( ! lockOptions?.is_locked ) { + this.trackResultSelect(); return; } @@ -34,6 +42,7 @@ export default class extends Marionette.ItemView { cancel: __( 'Cancel', 'elementor' ), }, onConfirm: () => { + this.trackResultSelect(); const link = this.replaceLockLinkPlaceholders( lockOptions.button.url ); window.open( link, '_blank' ); diff --git a/core/kits/assets/js/commands/back.js b/core/kits/assets/js/commands/back.js index 07865caaf7ad..c0f230eac627 100644 --- a/core/kits/assets/js/commands/back.js +++ b/core/kits/assets/js/commands/back.js @@ -1,3 +1,5 @@ +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; + export class Back extends $e.modules.CommandBase { document = null; confirmDialog = null; @@ -31,6 +33,45 @@ export class Back extends $e.modules.CommandBase { return $e.routes.back( 'panel' ); } + markSessionSaved() { + const globalComponent = this.component; + + if ( ! globalComponent ) { + return; + } + + globalComponent.siteSettingsSession.hasSaved = true; + + const currentTab = globalComponent.currentTab; + let activeSection = null; + + try { + const panelView = elementor.getPanelView(); + const currentPage = panelView?.getCurrentPageView?.(); + const contentView = currentPage?.content?.currentView; + activeSection = contentView?.activeSection || null; + } catch ( e ) {} + + const savedItem = activeSection ? `${ currentTab } - ${ activeSection }` : currentTab; + + if ( savedItem ) { + globalComponent.trackSavedItem( savedItem ); + } + } + + trackSiteSettingsSession( targetType, state ) { + const sessionData = this.component.getSiteSettingsSessionData?.() || {}; + + EditorOneEventManager.sendSiteSettingsSession( { + targetType, + visitedItems: sessionData.visitedItems || [], + savedItems: sessionData.savedItems || [], + state, + } ); + + this.component.resetSiteSettingsSession?.(); + } + getCloseConfirmDialog( event ) { if ( ! this.confirmDialog ) { const modalOptions = { @@ -46,6 +87,7 @@ export class Back extends $e.modules.CommandBase { cancel: __( 'Cancel', 'elementor' ), }, onConfirm: () => { + this.trackSiteSettingsSession( 'back', 'discard' ); $e.run( 'panel/global/close' ); }, }; @@ -98,12 +140,15 @@ export class Back extends $e.modules.CommandBase { cancel: __( 'Discard', 'elementor' ), }, onConfirm: () => { + this.markSessionSaved(); $e.run( 'document/save/update' ).then( () => { + this.trackSiteSettingsSession( 'save', 'saved' ); resolve(); } ); }, onCancel: () => { $e.run( 'document/save/discard', { document } ).then( () => { + this.trackSiteSettingsSession( 'back', 'discard' ); resolve(); } ); }, diff --git a/core/kits/assets/js/commands/close.js b/core/kits/assets/js/commands/close.js index 59a377873ff1..d4b6073ab0d7 100644 --- a/core/kits/assets/js/commands/close.js +++ b/core/kits/assets/js/commands/close.js @@ -1,12 +1,28 @@ +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; + export class Close extends $e.modules.CommandBase { apply( args ) { const { mode } = args; - // The kit is opened directly. + // The kit is opened directly — no document switch needed, safe to track immediately. if ( elementor.config.initial_document.id === parseInt( elementor.config.kit_id ) ) { + const hasSaved = this.component.siteSettingsSession?.hasSaved || false; + const sessionData = this.component.getSiteSettingsSessionData?.() || {}; + + EditorOneEventManager.sendSiteSettingsSession( { + targetType: 'close', + visitedItems: sessionData.visitedItems || [], + savedItems: sessionData.savedItems || [], + state: hasSaved ? 'saved' : 'discard', + } ); + + this.component.resetSiteSettingsSession?.(); return $e.run( 'panel/global/exit' ); } + // Capture session data before the switch (it may be reset during onClose). + const sessionSnapshot = this.component.getSiteSettingsSessionData?.() || {}; + $e.internal( 'panel/state-loading' ); return $e.run( 'editor/documents/switch', { @@ -14,7 +30,6 @@ export class Close extends $e.modules.CommandBase { id: elementor.config.initial_document.id, onClose: ( document ) => { if ( document.isDraft() ) { - // Restore published style. elementor.toggleDocumentCssFiles( document, true ); elementor.settings.page.destroyControlsCSS(); } @@ -25,7 +40,25 @@ export class Close extends $e.modules.CommandBase { // The kit shouldn't be cached for next open. (it may be changed via create colors/typography). elementor.documents.invalidateCache( elementor.config.kit_id ); }, - } ).finally( () => $e.internal( 'panel/state-ready' ) ); + } ).then( () => { + // Skip if session was already tracked and reset (e.g. by back.js dialog). + if ( ! sessionSnapshot.visitedItems?.length ) { + return; + } + + // Re-read hasSaved in case a save happened during the switch (e.g. "Save & leave"). + const hasSaved = sessionSnapshot.hasSaved || this.component.siteSettingsSession?.hasSaved || false; + const state = hasSaved ? 'saved' : 'discard'; + + EditorOneEventManager.sendSiteSettingsSession( { + targetType: 'close', + visitedItems: sessionSnapshot.visitedItems, + savedItems: sessionSnapshot.savedItems || [], + state, + } ); + + this.component.resetSiteSettingsSession?.(); + } ).catch( () => {} ).finally( () => $e.internal( 'panel/state-ready' ) ); } } diff --git a/core/kits/assets/js/component.js b/core/kits/assets/js/component.js index 3f6e6919a5d8..4115be4ddb2f 100644 --- a/core/kits/assets/js/component.js +++ b/core/kits/assets/js/component.js @@ -5,6 +5,11 @@ import ComponentBase from 'elementor-editor/component-base'; export default class extends ComponentBase { pages = {}; + siteSettingsSession = { + visitedItems: [], + savedItems: [], + hasSaved: false, + }; __construct( args ) { super.__construct( args ); @@ -63,11 +68,36 @@ export default class extends ComponentBase { } renderTab( tab, args ) { - if ( tab !== this.currentTab ) { // Prevent re-rendering the same tab (with just different args). + if ( tab !== this.currentTab ) { this.currentTab = tab; + this.trackVisitedTab( tab ); elementor.getPanelView().setPage( 'kit_settings' ).content.currentView.activateTab( tab ); } this.activateControl( args.activeControl ); } + + trackVisitedTab( tabName ) { + if ( tabName && ! this.siteSettingsSession.visitedItems.includes( tabName ) ) { + this.siteSettingsSession.visitedItems.push( tabName ); + } + } + + trackSavedItem( itemName ) { + if ( itemName && ! this.siteSettingsSession.savedItems.includes( itemName ) ) { + this.siteSettingsSession.savedItems.push( itemName ); + } + } + + getSiteSettingsSessionData() { + return { ...this.siteSettingsSession }; + } + + resetSiteSettingsSession() { + this.siteSettingsSession = { + visitedItems: [], + savedItems: [], + hasSaved: false, + }; + } } diff --git a/core/kits/assets/js/hooks/ui/document/save/save/after.js b/core/kits/assets/js/hooks/ui/document/save/save/after.js index 3656214f141f..c7ee6ac9c253 100644 --- a/core/kits/assets/js/hooks/ui/document/save/save/after.js +++ b/core/kits/assets/js/hooks/ui/document/save/save/after.js @@ -15,6 +15,8 @@ export class KitAfterSave extends After { } apply( args ) { + this.trackSiteSettingsSave(); + // On save clear cache of all edited documents and dynamic tags. // This is needed because when returning to the editor after saving the kit, it was still displaying the old data. this.clearDocumentCache(); @@ -54,6 +56,32 @@ export class KitAfterSave extends After { } } + trackSiteSettingsSave() { + const globalComponent = $e.components.get( 'panel/global' ); + + if ( ! globalComponent ) { + return; + } + + const currentTab = globalComponent.currentTab; + let activeSection = null; + + try { + const panelView = elementor.getPanelView(); + const currentPage = panelView?.getCurrentPageView?.(); + const contentView = currentPage?.content?.currentView; + activeSection = contentView?.activeSection || null; + } catch ( e ) {} + + const savedItem = activeSection ? `${ currentTab } - ${ activeSection }` : currentTab; + + if ( savedItem ) { + globalComponent.trackSavedItem( savedItem ); + } + + globalComponent.siteSettingsSession.hasSaved = true; + } + clearDocumentCache() { Object.keys( elementor.documents.documents ).forEach( ( id ) => { elementor.documents.invalidateCache( id ); diff --git a/modules/ai/assets/js/editor/ai-layout-behavior.js b/modules/ai/assets/js/editor/ai-layout-behavior.js index 9f88543e29d3..faff1725ff58 100644 --- a/modules/ai/assets/js/editor/ai-layout-behavior.js +++ b/modules/ai/assets/js/editor/ai-layout-behavior.js @@ -4,6 +4,7 @@ import { renderLayoutApp, } from './utils/editor-integration'; import { MODE_LAYOUT } from './pages/form-layout/context/config'; +import { EditorOneEventManager } from 'elementor-editor-utils/editor-one-events'; export default class AiLayoutBehavior extends Marionette.Behavior { previewContainer = null; @@ -24,6 +25,10 @@ export default class AiLayoutBehavior extends Marionette.Behavior { onAiButtonClick( e ) { e.stopPropagation(); + EditorOneEventManager.sendCanvasEmptyBoxAction( { + targetName: 'generate_with_ai', + } ); + window.elementorAiCurrentContext = this.getOption( 'context' ); renderLayoutApp( { diff --git a/modules/ai/assets/js/editor/integration/library/apply-template-for-ai-behavior.js b/modules/ai/assets/js/editor/integration/library/apply-template-for-ai-behavior.js index 8f6b7ed05644..3069a531121c 100644 --- a/modules/ai/assets/js/editor/integration/library/apply-template-for-ai-behavior.js +++ b/modules/ai/assets/js/editor/integration/library/apply-template-for-ai-behavior.js @@ -2,6 +2,8 @@ const { renderLayoutApp, importToEditor } = require( '../../utils/editor-integra const { MODE_VARIATION } = require( '../../pages/form-layout/context/config' ); const { __ } = require( '@wordpress/i18n' ); const { ATTACHMENT_TYPE_JSON, ELEMENTOR_LIBRARY_SOURCE } = require( '../../pages/form-layout/components/attachments' ); +const { EditorOneEventManager } = require( 'elementor-editor-utils/editor-one-events' ); + var ApplyTemplateForAiBehavior; ApplyTemplateForAiBehavior = Marionette.Behavior.extend( { @@ -15,11 +17,21 @@ ApplyTemplateForAiBehavior = Marionette.Behavior.extend( { 'click @ui.generateVariation': 'onGenerateVariationClick', }, + trackAiGenerate( model ) { + EditorOneEventManager.sendELibraryGenerateAi( { + assetId: model.get( 'template_id' ), + assetName: model.get( 'title' ), + libraryType: model.get( 'type' ) || model.get( 'source' ), + } ); + }, + onGenerateVariationClick() { const args = { model: this.view.model, }; + this.trackAiGenerate( this.view.model ); + const libraryComponent = $e.components.get( 'library' ); const at = libraryComponent.manager.modalConfig?.importOptions?.at; @@ -58,6 +70,8 @@ ApplyTemplateForAiBehavior = Marionette.Behavior.extend( { model: this.view.model, }; + this.trackAiGenerate( this.view.model ); + this.ui.applyButton.addClass( 'elementor-disabled' ); const activeSource = args.model.get( 'source' ); diff --git a/modules/nested-elements/assets/js/editor/views/add-section-area.js b/modules/nested-elements/assets/js/editor/views/add-section-area.js index b75bc0eeb8a4..fac813ba525b 100644 --- a/modules/nested-elements/assets/js/editor/views/add-section-area.js +++ b/modules/nested-elements/assets/js/editor/views/add-section-area.js @@ -5,25 +5,26 @@ export default function AddSectionArea( props ) { const addAreaElementRef = useRef(), containerHelper = elementor.helpers.container; - // Make droppable area. useEffect( () => { const $addAreaElementRef = jQuery( addAreaElementRef.current ), defaultDroppableOptions = props.container.view.getDroppableOptions(); - // Make some adjustments to behave like 'AddSectionArea', use default droppable options from container element. defaultDroppableOptions.placeholder = false; defaultDroppableOptions.items = '> .elementor-add-section-inner'; defaultDroppableOptions.hasDraggingOnChildClass = 'elementor-dragging-on-child'; - // Make element drop-able. $addAreaElementRef.html5Droppable( defaultDroppableOptions ); - // Cleanup. return () => { $addAreaElementRef.html5Droppable( 'destroy' ); }; }, [] ); + const handleAddContainerClick = ( event ) => { + event.stopPropagation(); + props.setIsRenderPresets( true ); + }; + return (
props.setIsRenderPresets( true ) } + onClick={ handleAddContainerClick } >