diff --git a/bin/cli-setup.sh b/bin/cli-setup.sh index 550873bcc..502cfc78a 100755 --- a/bin/cli-setup.sh +++ b/bin/cli-setup.sh @@ -4,6 +4,7 @@ wp --allow-root core install --url="http://localhost:8889" --admin_user="admin" mkdir -p /var/www/html/wp-content/uploads chmod -R 777 /var/www/html/wp-content/uploads/* wp --allow-root plugin install classic-editor +wp --allow-root plugin install elementor wp --allow-root theme install twentytwentyone # activate diff --git a/classes/Visualizer/Elementor/Widget.php b/classes/Visualizer/Elementor/Widget.php new file mode 100644 index 000000000..99b13b218 --- /dev/null +++ b/classes/Visualizer/Elementor/Widget.php @@ -0,0 +1,353 @@ + | +// +----------------------------------------------------------------------+ +/** + * Elementor widget for displaying Visualizer charts. + * + * @category Visualizer + * @package Elementor + * + * @since 3.11.16 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Visualizer Elementor Widget + */ +class Visualizer_Elementor_Widget extends \Elementor\Widget_Base { + + /** + * Get widget name. + * + * @return string Widget name. + */ + public function get_name() { + return 'visualizer-chart'; + } + + /** + * Get widget title. + * + * @return string Widget title. + */ + public function get_title() { + return esc_html__( 'Visualizer Chart', 'visualizer' ); + } + + /** + * Get widget icon. + * + * @return string Widget icon CSS class. + */ + public function get_icon() { + return 'visualizer-elementor-icon'; + } + + /** + * Get widget categories. + * + * @return array Widget categories. + */ + public function get_categories() { + return array( 'general' ); + } + + /** + * Get widget keywords. + * + * @return array Widget keywords. + */ + public function get_keywords() { + return array( 'visualizer', 'chart', 'graph', 'table', 'data' ); + } + + /** + * Build the select options from all published Visualizer charts. + * + * @return array Associative array of chart ID => label. + */ + private function get_chart_options() { + static $options_cache = null; + if ( null !== $options_cache ) { + return $options_cache; + } + + $options = array( + '' => esc_html__( '— Select a chart —', 'visualizer' ), + ); + + $charts = get_posts( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'orderby' => 'title', + 'order' => 'ASC', + 'no_found_rows' => true, + ) + ); + + foreach ( $charts as $chart ) { + $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS ); + $title = '#' . $chart->ID; + if ( ! empty( $settings[0]['title'] ) ) { + $title = $settings[0]['title']; + } + // ChartJS stores title as an array. + if ( is_array( $title ) && isset( $title['text'] ) ) { + $title = $title['text']; + } + if ( ! empty( $settings[0]['backend-title'] ) ) { + $title = $settings[0]['backend-title']; + } + if ( empty( $title ) ) { + $title = '#' . $chart->ID; + } + $options[ $chart->ID ] = $title; + } + + $options_cache = $options; + return $options_cache; + } + + /** + * Register widget controls. + * + * @return void + */ + protected function register_controls() { + $this->start_controls_section( + 'section_chart', + array( + 'label' => esc_html__( 'Chart', 'visualizer' ), + 'tab' => \Elementor\Controls_Manager::TAB_CONTENT, + ) + ); + + $admin_url = admin_url( 'admin.php?page=' . Visualizer_Plugin::NAME ); + $chart_options = $this->get_chart_options(); + $has_charts = count( $chart_options ) > 1; // More than just the placeholder option. + + if ( $has_charts ) { + $this->add_control( + 'chart_id', + array( + 'label' => esc_html__( 'Select Chart', 'visualizer' ), + 'type' => \Elementor\Controls_Manager::SELECT, + 'options' => $chart_options, + 'default' => '', + ) + ); + + $this->add_control( + 'chart_notice', + array( + 'type' => \Elementor\Controls_Manager::RAW_HTML, + 'raw' => sprintf( + /* translators: 1: opening anchor tag, 2: closing anchor tag */ + esc_html__( 'You can create and manage your charts from the %1$sVisualizer dashboard%2$s.', 'visualizer' ), + '', + '' + ), + 'content_classes' => 'elementor-panel-alert elementor-panel-alert-info', + ) + ); + } else { + $this->add_control( + 'no_charts_notice', + array( + 'type' => \Elementor\Controls_Manager::RAW_HTML, + 'raw' => sprintf( + /* translators: 1: opening anchor tag, 2: closing anchor tag */ + esc_html__( 'No charts found. %1$sCreate a chart%2$s in the Visualizer dashboard first.', 'visualizer' ), + '', + '' + ), + 'content_classes' => 'elementor-panel-alert elementor-panel-alert-warning', + ) + ); + } + + $this->end_controls_section(); + } + + /** + * Render the widget output on the frontend. + * + * @return void + */ + protected function render() { + $settings = $this->get_settings_for_display(); + $chart_id = ! empty( $settings['chart_id'] ) ? absint( $settings['chart_id'] ) : 0; + + if ( ! $chart_id ) { + if ( \Elementor\Plugin::$instance->editor->is_edit_mode() ) { + echo '

' . esc_html__( 'Please select a chart from the widget settings.', 'visualizer' ) . '

'; + } + return; + } + + // Detect Elementor edit / preview context early — needed before do_shortcode(). + $is_editor = \Elementor\Plugin::$instance->editor->is_edit_mode() || + \Elementor\Plugin::$instance->preview->is_preview_mode(); + + // In the editor, force lazy-loading off so the chart renders immediately in the + // preview iframe without requiring a user-interaction event (scroll, hover, etc.). + // Also suppress action buttons (edit, export, etc.) — they are meaningless inside + // the Elementor preview and the edit link does nothing there. + if ( $is_editor ) { + add_filter( 'visualizer_lazy_load_chart', '__return_false' ); + add_filter( 'visualizer_pro_add_actions', '__return_empty_array' ); + } + + // Ensure visualizer-customization is registered before the shortcode enqueues + // visualizer-render-{library} which depends on it. wp_enqueue_scripts never fires + // in admin or AJAX contexts (Elementor editor / AJAX re-render), so we trigger the + // action manually. It is a no-op when already registered. + do_action( 'visualizer_enqueue_scripts' ); + + // Capture the shortcode output so we can parse the generated element ID. + $html = do_shortcode( '[visualizer id="' . $chart_id . '"]' ); + + if ( $is_editor ) { + remove_filter( 'visualizer_lazy_load_chart', '__return_false' ); + remove_filter( 'visualizer_pro_add_actions', '__return_empty_array' ); + + // The shortcode enqueues visualizer-render-{library} (render-facade.js). + // Dequeue it so Elementor's AJAX response doesn't inject it into the preview + // iframe. The preview page already loads render-google.js / render-chartjs.js + // via elementor/preview/enqueue_scripts; injecting render-facade.js would add + // a second visualizer:render:chart:start trigger causing duplicate renders. + foreach ( wp_scripts()->queue as $handle ) { + if ( 0 === strpos( $handle, 'visualizer-render-' ) + && 'visualizer-render-google-lib' !== $handle + && 'visualizer-render-chartjs-lib' !== $handle + && 'visualizer-render-datatables-lib' !== $handle ) { + wp_dequeue_script( $handle ); + } + } + } + + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + if ( ! $is_editor ) { + return; + } + + // Extract the element ID generated by the shortcode (visualizer-{id}-{rand}). + if ( ! preg_match( '/\bid="(visualizer-' . $chart_id . '-\d+)"/', $html, $matches ) ) { + return; + } + $element_id = $matches[1]; + + $chart = get_post( $chart_id ); + if ( ! $chart || Visualizer_Plugin::CPT_VISUALIZER !== $chart->post_type ) { + return; + } + + $type = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, true ); + $series = get_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, true ); + $chart_settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true ); + $chart_data = Visualizer_Module::get_chart_data( $chart, $type ); + + if ( empty( $chart_settings['height'] ) ) { + $chart_settings['height'] = '400'; + } + + // Read library from meta and normalise to the lowercase slugs that + // render-google.js / render-chartjs.js / render-datatables.js and + // elementor-widget-preview.js expect. + $library = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, true ); + $library_map = array( + 'GoogleCharts' => 'google', + 'ChartJS' => 'chartjs', + 'DataTable' => 'datatables', + ); + if ( isset( $library_map[ $library ] ) ) { + $library = $library_map[ $library ]; + } elseif ( ! $library ) { + $library = 'google'; + } + + $series = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SERIES, $series, $chart_id, $type ); + $chart_settings = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $chart_settings, $chart_id, $type ); + $chart_settings = $this->apply_custom_css_class_names( $chart_settings, $chart_id ); + + $chart_entry = array( + 'type' => $type, + 'series' => $series, + 'settings' => $chart_settings, + 'data' => $chart_data, + 'library' => $library, + ); + + // Elementor injects widget HTML via innerHTML, so ', + esc_attr( $element_id ), + wp_json_encode( $chart_entry ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + } + + /** + * Ensure custom CSS class mappings are present in settings for preview rendering. + * + * @param array $settings Chart settings. + * @param int $chart_id Chart ID. + * @return array + */ + private function apply_custom_css_class_names( $settings, $chart_id ) { + if ( empty( $settings['customcss'] ) || ! is_array( $settings['customcss'] ) ) { + return $settings; + } + + $classes = array(); + $id = 'visualizer-' . $chart_id; + + foreach ( $settings['customcss'] as $name => $element ) { + if ( empty( $name ) || ! is_array( $element ) ) { + continue; + } + $has_properties = false; + foreach ( $element as $property => $value ) { + if ( '' !== $property && '' !== $value && null !== $value ) { + $has_properties = true; + break; + } + } + if ( ! $has_properties ) { + continue; + } + $classes[ $name ] = $id . $name; + } + + if ( ! empty( $classes ) ) { + $settings['cssClassNames'] = $classes; + } + + return $settings; + } +} diff --git a/composer.json b/composer.json index 1e76b0961..31a5cfe6e 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "scripts": { "format": "phpcbf --standard=phpcs.xml --report-summary --report-source", "lint": "phpcs --standard=phpcs.xml", - "phpstan": "phpstan", + "phpstan": "phpstan --memory-limit=2G", "phpstan:generate:baseline": "phpstan --generate-baseline --memory-limit=2G" }, "minimum-stability": "dev", diff --git a/images/visualizer-icon.svg b/images/visualizer-icon.svg new file mode 100644 index 000000000..fa7266629 --- /dev/null +++ b/images/visualizer-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/index.php b/index.php index f167948fe..8329fecb9 100644 --- a/index.php +++ b/index.php @@ -112,6 +112,72 @@ function visualizer_launch() { }} ); + // register Elementor widget + add_action( + 'elementor/widgets/register', + function ( $widgets_manager ) { + require_once VISUALIZER_ABSPATH . '/classes/Visualizer/Elementor/Widget.php'; + $widgets_manager->register( new Visualizer_Elementor_Widget() ); + } + ); + + // Register the Visualizer icon for the Elementor widget panel. + add_action( + 'elementor/editor/after_enqueue_styles', + function () { + $icon_url = VISUALIZER_ABSURL . 'images/visualizer-icon.svg'; + wp_add_inline_style( + 'elementor-icons', + '.visualizer-elementor-icon { display:inline-block; width:1em; height:1em; background:url("' . esc_url( $icon_url ) . '") no-repeat center/contain; }' + ); + } + ); + + // Enqueue Visualizer scripts inside the Elementor preview iframe. + // Elementor serves the preview iframe as a shell page and injects widget HTML via + // JavaScript (innerHTML), so wp_enqueue_script calls inside render() never reach the + // iframe. We load all chart render libraries here so they are available when + // elementor-widget-preview.js triggers visualizer:render:chart:start. + add_action( + 'elementor/preview/enqueue_scripts', + function () { + do_action( 'visualizer_enqueue_scripts' ); + + // ChartJS render library. + if ( ! wp_script_is( 'numeral', 'registered' ) ) { + wp_register_script( 'numeral', VISUALIZER_ABSURL . 'js/lib/numeral.min.js', array(), Visualizer_Plugin::VERSION, true ); + } + if ( ! wp_script_is( 'chartjs', 'registered' ) ) { + wp_register_script( 'chartjs', VISUALIZER_ABSURL . 'js/lib/chartjs.min.js', array( 'numeral' ), null, true ); + } + wp_enqueue_script( 'visualizer-render-chartjs-lib', VISUALIZER_ABSURL . 'js/render-chartjs.js', array( 'chartjs', 'visualizer-customization' ), Visualizer_Plugin::VERSION, true ); + + // Google Charts render library. + wp_enqueue_script( 'visualizer-google-jsapi', '//www.gstatic.com/charts/loader.js', array(), null, true ); + wp_enqueue_script( 'visualizer-render-google-lib', VISUALIZER_ABSURL . 'js/render-google.js', array( 'visualizer-google-jsapi', 'visualizer-customization' ), Visualizer_Plugin::VERSION, true ); + + // DataTable render library + styles. + if ( ! wp_script_is( 'visualizer-datatables', 'registered' ) ) { + wp_register_script( 'visualizer-datatables', VISUALIZER_ABSURL . 'js/lib/datatables.min.js', array( 'jquery' ), Visualizer_Plugin::VERSION, true ); + } + wp_enqueue_script( 'visualizer-render-datatables-lib', VISUALIZER_ABSURL . 'js/render-datatables.js', array( 'visualizer-datatables', 'visualizer-customization' ), Visualizer_Plugin::VERSION, true ); + wp_enqueue_style( 'visualizer-datatables', VISUALIZER_ABSURL . 'css/lib/datatables.min.css', array(), Visualizer_Plugin::VERSION ); + + // Elementor widget preview handler — uses frontend/element_ready hook. + wp_enqueue_script( 'visualizer-elementor-preview', VISUALIZER_ABSURL . 'js/elementor-widget-preview.js', array( 'jquery', 'elementor-frontend' ), Visualizer_Plugin::VERSION, true ); + + // Prevent Elementor's editor-preview CSS from hiding our widget. + // Elementor marks widgets without a content_template() as elementor-widget-empty + // and adds display:none to .elementor-widget-empty when the panel is hidden + // (.elementor-editor-preview on ). Our widget renders async (Google Charts + // loads via callback), so the empty class is always present. + wp_add_inline_style( + 'visualizer-datatables', + '.elementor-editor-preview .elementor-widget-visualizer-chart.elementor-widget-empty { display: block !important; }' + ); + } + ); + // set general modules $plugin->setModule( Visualizer_Module_Utility::NAME ); $plugin->setModule( Visualizer_Module_Setup::NAME ); diff --git a/js/elementor-widget-preview.js b/js/elementor-widget-preview.js new file mode 100644 index 000000000..7d27eded4 --- /dev/null +++ b/js/elementor-widget-preview.js @@ -0,0 +1,181 @@ +/* global elementorFrontend, jQuery */ +/** + * Elementor preview handler for Visualizer charts. + * + * @since 3.11.16 + */ +// Guard against the script being injected more than once into the preview iframe. +if ( ! window.visualizerElementorPreview ) { +window.visualizerElementorPreview = true; + +( function ( $ ) { + 'use strict'; + + /** + * Poll until `condition()` returns true, then call `callback`. + * Gives up after `maxAttempts` × 100 ms. + */ + function waitFor( condition, callback, maxAttempts ) { + maxAttempts = maxAttempts === undefined ? 50 : maxAttempts; + if ( condition() ) { + callback(); + return; + } + if ( maxAttempts <= 0 ) { + return; + } + setTimeout( function () { + waitFor( condition, callback, maxAttempts - 1 ); + }, 100 ); + } + + /** + * Given the Elementor widget wrapper element, extract chart data from the + * embedded JSON script element and trigger chart rendering. + */ + function renderWidget( widgetEl ) { + var $scope = $( widgetEl ); + var $dataEl = $scope.find( 'script.visualizer-chart-data[type="application/json"]' ); + + if ( ! $dataEl.length ) { + return; + } + + var elementId = $dataEl.attr( 'data-element-id' ); + var chartEntry; + + try { + chartEntry = JSON.parse( $dataEl.text() ); + } catch ( e ) { + return; + } + + if ( ! elementId || ! chartEntry ) { + return; + } + + window.visualizer = window.visualizer || {}; + window.visualizer.charts = window.visualizer.charts || {}; + window.visualizer.charts[ elementId ] = chartEntry; + + // Build the viz object that render-google.js / render-chartjs.js expect. + // is_front:true tells render-google.js to call renderChart(id) for just + // this element rather than render() for all charts. + var viz = $.extend( {}, window.visualizer, { id: elementId, is_front: true } ); + + function doTrigger() { + $( '#' + elementId ).removeClass( 'viz-facade-loaded' ); + $( 'body' ).trigger( 'visualizer:render:chart:start', viz ); + } + + if ( chartEntry.library === 'google' || chartEntry.library === 'GoogleCharts' ) { + // render-google.js bails silently when typeof google !== 'object'. + // Poll until the Google Charts loader script has executed. + waitFor( function () { return typeof google === 'object'; }, doTrigger ); + } else { + doTrigger(); + } + } + + /** + * Scan a subtree for visualizer-chart widgets and render each one. + */ + function scanAndRender( root ) { + var $root = $( root ); + + if ( $root.is( '[data-widget_type="visualizer-chart.default"]' ) ) { + renderWidget( root ); + } + + $root.find( '[data-widget_type="visualizer-chart.default"]' ).each( function () { + renderWidget( this ); + } ); + } + + $( document ).ready( function () { + var observer = new MutationObserver( function ( mutations ) { + // Collect widget elements to render, de-duplicating within the batch. + var toRender = []; + var seen = window.WeakSet ? new WeakSet() : null; + var seenList = []; + + // Enqueue a widget element for rendering, skipping duplicates. + function enqueue( el ) { + if ( ! el ) { + return; + } + if ( seen ) { + if ( seen.has( el ) ) { + return; + } + seen.add( el ); + toRender.push( el ); + return; + } + if ( seenList.indexOf( el ) !== -1 ) { + return; + } + seenList.push( el ); + toRender.push( el ); + } + + mutations.forEach( function ( mutation ) { + mutation.addedNodes.forEach( function ( node ) { + if ( node.nodeType !== 1 ) { + return; + } + + // Only react when chart data was injected, not when chart + // rendering (SVG / canvas) mutates the DOM — that would loop. + var hasData = ( node.matches && node.matches( 'script.visualizer-chart-data[type="application/json"]' ) ) || + ( node.querySelector && node.querySelector( 'script.visualizer-chart-data[type="application/json"]' ) ); + if ( ! hasData ) { + return; + } + + // Node is the widget wrapper or contains one (new widget added). + if ( $( node ).is( '[data-widget_type="visualizer-chart.default"]' ) ) { + enqueue( node ); + } + $( node ).find( '[data-widget_type="visualizer-chart.default"]' ).each( function () { + enqueue( this ); + } ); + + // Node is inner content of an existing widget (chart switched). + // Look upward for the widget wrapper. + enqueue( $( node ).closest( '[data-widget_type="visualizer-chart.default"]' )[ 0 ] ); + } ); + } ); + + toRender.forEach( function ( el ) { + renderWidget( el ); + } ); + } ); + + observer.observe( document.documentElement, { childList: true, subtree: true } ); + + // Handle widgets already present on initial load. + scanAndRender( document.body ); + } ); + + // Register Elementor's element_ready hook as a secondary trigger. + // Called both immediately (in case elementorFrontend is already initialised) + // and on the init event (in case it fires after this script loads). + // The guard prevents double-registration if both code paths fire. + var elementorHookRegistered = false; + function registerElementorHook() { + if ( elementorHookRegistered ) { + return; + } + if ( typeof elementorFrontend !== 'undefined' && elementorFrontend.hooks ) { + elementorFrontend.hooks.addAction( + 'frontend/element_ready/visualizer-chart.default', + function ( $scope ) { renderWidget( $scope[ 0 ] ); } + ); + elementorHookRegistered = true; + } + } + registerElementorHook(); + window.addEventListener( 'elementor/frontend/init', registerElementorHook ); +}( jQuery ) ); +} // end visualizerElementorPreview guard diff --git a/js/render-google.js b/js/render-google.js index a81377417..289323438 100644 --- a/js/render-google.js +++ b/js/render-google.js @@ -26,6 +26,10 @@ var isResizeRequest = false; return; } + if ( chart.library && chart.library !== 'google' && chart.library !== 'GoogleCharts' ) { + return; + } + // re-render the chart only if it doesn't have annotations and it is on the front-end // this is to prevent the chart from showing "All series on a given axis must be of the same data type" during resize. // remember, some charts do not support annotations so they should not be included in this. diff --git a/phpstan.neon b/phpstan.neon index 6840d0ec1..95556cd92 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,7 @@ parameters: bootstrapFiles: - %currentWorkingDirectory%/tests/php/static-analysis-stubs/symbols.php - %currentWorkingDirectory%/tests/php/static-analysis-stubs/visualizer-pro.php + - %currentWorkingDirectory%/tests/php/static-analysis-stubs/elementor.php scanDirectories: - %currentWorkingDirectory%/vendor/neitanod/forceutf8 - %currentWorkingDirectory%/vendor/openspout/openspout diff --git a/tests/e2e/specs/elementor-widget.spec.js b/tests/e2e/specs/elementor-widget.spec.js new file mode 100644 index 000000000..38879b28c --- /dev/null +++ b/tests/e2e/specs/elementor-widget.spec.js @@ -0,0 +1,297 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { createChartWithAdmin, deleteAllCharts } = require( '../utils/common' ); + +/** + * How long to wait for the Elementor editor chrome (panel + preview iframe) to be ready. + */ +const ELEMENTOR_LOAD_TIMEOUT = 30000; + +/** + * How long to wait for a chart to finish rendering inside the preview iframe. + */ +const CHART_RENDER_TIMEOUT = 15000; + +/** + * Navigate to the Elementor editor for a given page ID, wait until it is ready, + * then dismiss any first-run modals/panels Elementor shows. + * + * @param {import('@wordpress/e2e-test-utils-playwright').Admin} admin + * @param {import('playwright/test').Page} page + * @param {number} pageId + */ +async function openElementorEditor( admin, page, pageId ) { + await admin.visitAdminPage( `post.php?post=${ pageId }&action=elementor` ); + await page.waitForSelector( '#elementor-preview-iframe', { timeout: ELEMENTOR_LOAD_TIMEOUT } ); + await page.waitForSelector( '#elementor-panel', { timeout: ELEMENTOR_LOAD_TIMEOUT } ); + await page.waitForSelector( '#elementor-panel-state-loading', { state: 'hidden', timeout: ELEMENTOR_LOAD_TIMEOUT } ); + + // Dismiss any first-run modals/panels Elementor shows (notifications dialog, + // onboarding checklist, etc.) that would block panel interactions. + await dismissElementorModals( page ); +} + +/** + * Close any Elementor modal dialogs or floating panels that appear on first launch. + * + * @param {import('playwright/test').Page} page + */ +async function dismissElementorModals( page ) { + // Elementor's "What's New" / notifications lightbox — dismiss via "Skip" button. + const skipBtn = page.locator( 'button:has-text("Skip"), button:has-text("Maybe Later")' ).first(); + if ( await skipBtn.isVisible( { timeout: 1500 } ).catch( () => false ) ) { + await skipBtn.click(); + await page.waitForTimeout( 300 ); + } + + // Generic lightbox close button (same dialog, alternative close path). + const lightboxClose = page.locator( '.dialog-lightbox-close-button' ).first(); + if ( await lightboxClose.isVisible( { timeout: 1000 } ).catch( () => false ) ) { + await lightboxClose.click(); + await page.waitForTimeout( 300 ); + } + + // Onboarding / "productivity boost" checklist panel. + const onboardingClose = page.locator( '.e-onboarding__go-pro-close-btn, [data-action="close"]' ).first(); + if ( await onboardingClose.isVisible( { timeout: 1000 } ).catch( () => false ) ) { + await onboardingClose.click(); + await page.waitForTimeout( 300 ); + } + + // Navigator / Structure panel (opens automatically on some versions). + const navigatorClose = page.locator( '#elementor-navigator__close' ).first(); + if ( await navigatorClose.isVisible( { timeout: 500 } ).catch( () => false ) ) { + await navigatorClose.click(); + } +} + +/** + * Search for the Visualizer widget in the Elementor panel and drag it onto + * the preview canvas. Resolves once the widget wrapper is present in the iframe. + * + * @param {import('playwright/test').Page} page + * @returns {Promise} + */ +async function addVisualizerWidget( page ) { + // If the panel is in widget-edit mode, press Escape to deselect and return + // to the elements list. The search box is the definitive indicator. + const searchInput = page.locator( '#elementor-panel-elements-search-input' ); + if ( ! await searchInput.isVisible( { timeout: 1000 } ).catch( () => false ) ) { + await page.keyboard.press( 'Escape' ); + await searchInput.waitFor( { timeout: 5000 } ); + } + await searchInput.fill( '' ); + await searchInput.fill( 'visualizer' ); + + // The widget card — use text filter because data-element_type lives on the + // inner .elementor-element div, not on the .elementor-element-wrapper. + const widgetHandle = page.locator( '.elementor-element-wrapper' ) + .filter( { hasText: 'Visualizer Chart' } ) + .first(); + await widgetHandle.waitFor( { timeout: 5000 } ); + + // Clicking the widget card adds it to the page in Elementor. + await widgetHandle.click(); + + const previewFrame = page.frameLocator( '#elementor-preview-iframe' ); + + // Wait until the widget wrapper appears in the preview and the panel + // switches to the widget-settings view. + await previewFrame + .locator( '[data-widget_type="visualizer-chart.default"]' ) + .waitFor( { timeout: 10000 } ); +} + +/** + * Select the first real chart from the widget's dropdown in the Elementor panel. + * + * @param {import('playwright/test').Page} page + */ +async function selectFirstChart( page ) { + // data-setting="chart_id" is on the