From 4e4e9915204fe9bb737dda6c5938741230922d58 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 30 Mar 2026 15:23:26 -0600 Subject: [PATCH] fix(tours): use wp_add_inline_script on underscore to define wu_tours globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wu_tours was localized onto wu-admin which is only enqueued on WP Ultimo pages (since PR #433). The network dashboard (dashboard-network) is not a WP Ultimo page, so wu-admin was absent there and the localization silently did nothing, leaving wu_tours undefined when tours.js executed. Fix: replace wp_localize_script('wu-admin', ...) with wp_add_inline_script() on 'underscore' — a WordPress core script always present in the admin. This injects wu_tours and wu_tours_vars as globals immediately after underscore loads, making them available to the wu-tours module regardless of whether wu-admin is enqueued. Add Tours_Test with 7 unit tests including a regression test that verifies wu_tours is defined via inline script on underscore (not localized on wu-admin). Closes #707 --- inc/ui/class-tours.php | 46 +++++--- tests/WP_Ultimo/UI/Tours_Test.php | 167 ++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 15 deletions(-) create mode 100644 tests/WP_Ultimo/UI/Tours_Test.php diff --git a/inc/ui/class-tours.php b/inc/ui/class-tours.php index 1bb122289..55054262d 100644 --- a/inc/ui/class-tours.php +++ b/inc/ui/class-tours.php @@ -91,23 +91,39 @@ public function register_scripts(): void { public function enqueue_scripts(): void { if ($this->has_tours()) { - // It's not possible to localize a module so we'll just use wu-admin which will always be there. See https://core.trac.wordpress.org/ticket/60234 - wp_localize_script('wu-admin', 'wu_tours', $this->tours); - - wp_localize_script( - 'wu-admin', - 'wu_tours_vars', - [ - 'ajaxurl' => wu_ajax_url(), - 'nonce' => wp_create_nonce('wu_tour_finished'), - 'i18n' => [ - 'next' => __('Next', 'ultimate-multisite'), - 'finish' => __('Close', 'ultimate-multisite'), - ], - ] + /* + * We cannot use wp_localize_script() on a module script (wu-tours), and + * we cannot rely on wu-admin being enqueued on every admin page — since + * PR #433 it is only enqueued on WP Ultimo pages. The network dashboard + * (index.php, hook suffix dashboard-network) is not a WP Ultimo page, so + * wu-admin is absent there and localizing onto it silently does nothing, + * leaving wu_tours undefined when tours.js executes. + * + * Fix: use wp_add_inline_script() on 'underscore', which is a WordPress + * core script always present in the admin. This injects wu_tours and + * wu_tours_vars as globals immediately after underscore loads, making them + * available to the wu-tours module regardless of whether wu-admin is + * enqueued. See https://core.trac.wordpress.org/ticket/60234. + */ + wp_enqueue_script('underscore'); + + $inline_data = sprintf( + 'var wu_tours = %s; var wu_tours_vars = %s;', + wp_json_encode($this->tours), + wp_json_encode( + [ + 'ajaxurl' => wu_ajax_url(), + 'nonce' => wp_create_nonce('wu_tour_finished'), + 'i18n' => [ + 'next' => __('Next', 'ultimate-multisite'), + 'finish' => __('Close', 'ultimate-multisite'), + ], + ] + ) ); - wp_enqueue_script('underscore'); + wp_add_inline_script('underscore', $inline_data, 'after'); + wp_enqueue_script_module('wu-tours'); wp_enqueue_style('shepherd'); } diff --git a/tests/WP_Ultimo/UI/Tours_Test.php b/tests/WP_Ultimo/UI/Tours_Test.php new file mode 100644 index 000000000..f014a63fe --- /dev/null +++ b/tests/WP_Ultimo/UI/Tours_Test.php @@ -0,0 +1,167 @@ +get_instance(); + + $this->assertInstanceOf(Tours::class, $instance); + } + + /** + * Test singleton returns same instance. + */ + public function test_singleton_returns_same_instance(): void { + + $this->assertSame( + Tours::get_instance(), + Tours::get_instance() + ); + } + + /** + * Test init registers hooks. + */ + public function test_init_registers_hooks(): void { + + $instance = $this->get_instance(); + + $instance->init(); + + $this->assertIsInt(has_action('wp_ajax_wu_mark_tour_as_finished', [$instance, 'mark_as_finished'])); + $this->assertIsInt(has_action('admin_enqueue_scripts', [$instance, 'register_scripts'])); + $this->assertIsInt(has_action('in_admin_footer', [$instance, 'enqueue_scripts'])); + } + + /** + * Test has_tours returns false when no tours registered. + */ + public function test_has_tours_returns_false_when_empty(): void { + + $instance = $this->get_instance(); + + // Access protected property via reflection to reset tours. + $reflection = new \ReflectionClass($instance); + $prop = $reflection->getProperty('tours'); + $prop->setAccessible(true); + $prop->setValue($instance, []); + + $this->assertFalse($instance->has_tours()); + } + + /** + * Test has_tours returns true when tours are registered. + */ + public function test_has_tours_returns_true_when_tours_exist(): void { + + $instance = $this->get_instance(); + + $reflection = new \ReflectionClass($instance); + $prop = $reflection->getProperty('tours'); + $prop->setAccessible(true); + $prop->setValue($instance, ['test-tour' => [['id' => 'step1', 'text' => 'Hello']]]); + + $this->assertTrue($instance->has_tours()); + + // Reset. + $prop->setValue($instance, []); + } + + /** + * Test enqueue_scripts does nothing when no tours registered. + */ + public function test_enqueue_scripts_skips_when_no_tours(): void { + + global $wp_scripts; + + $instance = $this->get_instance(); + + // Ensure no tours. + $reflection = new \ReflectionClass($instance); + $prop = $reflection->getProperty('tours'); + $prop->setAccessible(true); + $prop->setValue($instance, []); + + $queue_before = isset($wp_scripts) ? $wp_scripts->queue : []; + + $instance->enqueue_scripts(); + + $queue_after = isset($wp_scripts) ? $wp_scripts->queue : []; + + // Queue should not have grown. + $this->assertSame($queue_before, $queue_after); + } + + /** + * Test enqueue_scripts uses wp_add_inline_script on 'underscore', not wu-admin. + * + * Regression test for GH#707: wu_tours was localized onto wu-admin which is + * not enqueued on the network dashboard, causing a ReferenceError. The fix + * uses wp_add_inline_script on 'underscore' (always present in WP admin). + */ + public function test_enqueue_scripts_inlines_data_on_underscore_not_wu_admin(): void { + + global $wp_scripts; + + $instance = $this->get_instance(); + + // Register 'underscore' if not already registered (test environment may not have it). + if ( ! wp_script_is('underscore', 'registered')) { + wp_register_script('underscore', false, [], false, false); + } + + // Inject a tour so enqueue_scripts() proceeds. + $reflection = new \ReflectionClass($instance); + $prop = $reflection->getProperty('tours'); + $prop->setAccessible(true); + $prop->setValue($instance, ['test-tour' => [['id' => 'step1', 'text' => 'Hello']]]); + + $instance->enqueue_scripts(); + + // 'underscore' must be enqueued. + $this->assertTrue(wp_script_is('underscore', 'enqueued'), 'underscore should be enqueued'); + + // Inline data must be attached to 'underscore', not 'wu-admin'. + $inline_data = $wp_scripts->get_data('underscore', 'after'); + $this->assertNotEmpty($inline_data, 'Inline script data should be attached to underscore'); + + $inline_str = is_array($inline_data) ? implode('', $inline_data) : (string) $inline_data; + $this->assertStringContainsString('wu_tours', $inline_str, 'wu_tours should be defined in inline script'); + $this->assertStringContainsString('wu_tours_vars', $inline_str, 'wu_tours_vars should be defined in inline script'); + + // wu-admin must NOT have wu_tours localized onto it. + $wu_admin_data = $wp_scripts->get_data('wu-admin', 'data'); + $this->assertStringNotContainsString('wu_tours', (string) $wu_admin_data, 'wu_tours must not be localized onto wu-admin'); + + // Reset. + $prop->setValue($instance, []); + } +}