Skip to content

Commit fa89e6d

Browse files
Merge pull request #12122 from google/enhancement/11932-pointer-tracking
Enhancement/11932 pointer tracking
2 parents e25b7e2 + 858b8d8 commit fa89e6d

9 files changed

Lines changed: 251 additions & 10 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Admin pointer tracking helpers.
3+
*
4+
* Site Kit by Google, Copyright 2026 Google LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
/**
20+
* Internal dependencies.
21+
*/
22+
import { trackEvent } from './util';
23+
24+
const TRACKING_KEYS = [ 'view', 'click', 'dismiss' ];
25+
26+
function fireTrackingEvent( eventConfig ) {
27+
if ( ! eventConfig || ! eventConfig.category || ! eventConfig.action ) {
28+
return null;
29+
}
30+
31+
const { category, action, label } = eventConfig;
32+
if ( undefined !== label ) {
33+
return trackEvent( category, action, label );
34+
}
35+
36+
return trackEvent( category, action );
37+
}
38+
39+
function registerPointerTracking( slug, tracking ) {
40+
if ( ! tracking || ! Object.keys( tracking ).length ) {
41+
return { onDismiss: null };
42+
}
43+
44+
const fired = TRACKING_KEYS.reduce(
45+
( acc, key ) => ( { ...acc, [ key ]: false } ),
46+
{}
47+
);
48+
49+
function fireOnce( key ) {
50+
if ( fired[ key ] || ! tracking[ key ] ) {
51+
return null;
52+
}
53+
54+
fired[ key ] = true;
55+
return fireTrackingEvent( tracking[ key ] );
56+
}
57+
58+
// Fire view event immediately.
59+
fireOnce( 'view' );
60+
61+
const pointerElement = document.querySelector( `.${ slug }` );
62+
const ownerDocument =
63+
( pointerElement && pointerElement.ownerDocument ) ||
64+
document.documentElement.ownerDocument;
65+
const ctaSelector = `.${ slug } .googlesitekit-pointer-cta`;
66+
67+
function handleClick( event ) {
68+
const target = event.target instanceof Element ? event.target : null;
69+
if ( ! target || ! target.closest( ctaSelector ) ) {
70+
return;
71+
}
72+
73+
const cta = target.closest( 'a' );
74+
const href = cta && cta.getAttribute( 'href' );
75+
const shouldDeferNavigation = href && ! cta.getAttribute( 'target' );
76+
77+
if ( shouldDeferNavigation ) {
78+
event.preventDefault();
79+
}
80+
81+
const track = Promise.resolve( fireOnce( 'click' ) );
82+
83+
if ( shouldDeferNavigation ) {
84+
track.finally( () => {
85+
ownerDocument.defaultView.location.assign( href );
86+
} );
87+
}
88+
}
89+
90+
ownerDocument.addEventListener( 'click', handleClick, true );
91+
92+
return {
93+
onDismiss: () => {
94+
if ( fired.click ) {
95+
// Prevent firing dismiss if click was already fired.
96+
ownerDocument.removeEventListener( 'click', handleClick, true );
97+
return;
98+
}
99+
100+
fireOnce( 'dismiss' );
101+
ownerDocument.removeEventListener( 'click', handleClick, true );
102+
},
103+
};
104+
}
105+
106+
window.googlesitekitAdminPointersTracking =
107+
window.googlesitekitAdminPointersTracking || {};
108+
window.googlesitekitAdminPointersTracking.register = registerPointerTracking;

assets/webpack/modules.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ module.exports = function ( mode, rules ) {
9494
// Old Modules
9595
'googlesitekit-activation': './js/googlesitekit-activation.js',
9696
'googlesitekit-adminbar': './js/googlesitekit-adminbar.js',
97+
'googlesitekit-admin-pointers-tracking':
98+
'./js/googlesitekit-admin-pointers-tracking.js',
9799
'googlesitekit-settings': './js/googlesitekit-settings.js',
98100
'googlesitekit-main-dashboard':
99101
'./js/googlesitekit-main-dashboard.js',

includes/Core/Admin/Pointer.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ final class Pointer {
5252
* @type callable $active_callback Optional. Callback function to determine whether the pointer is active in
5353
* the current context. The current admin screen's hook suffix is passed to
5454
* the callback. Default is that the pointer is active unconditionally.
55+
* @type array $tracking Optional. Tracking config for view, dismiss, and click events.
5556
* }
5657
*/
5758
public function __construct( $slug, array $args ) {
@@ -66,6 +67,7 @@ public function __construct( $slug, array $args ) {
6667
'active_callback' => null,
6768
'buttons' => null,
6869
'class' => '',
70+
'tracking' => array(),
6971
)
7072
);
7173
}
@@ -114,6 +116,17 @@ public function get_class() {
114116
return $this->args['class'];
115117
}
116118

119+
/**
120+
* Gets the pointer tracking data.
121+
*
122+
* @since n.e.x.t
123+
*
124+
* @return array Pointer tracking config.
125+
*/
126+
public function get_tracking() {
127+
return $this->args['tracking'];
128+
}
129+
117130
/**
118131
* Gets the pointer content.
119132
*

includes/Core/Admin/Pointers.php

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ function ( Pointer $pointer ) use ( $hook_suffix ) {
6565
// Dashboard styles are required where pointers are used to ensure proper styling.
6666
wp_enqueue_style( 'googlesitekit-wp-dashboard-css' );
6767
wp_enqueue_script( 'wp-pointer' );
68+
wp_enqueue_script( 'googlesitekit-admin-pointers-tracking' );
6869

6970
add_action(
7071
'admin_print_footer_scripts',
@@ -121,7 +122,10 @@ private function print_pointer_script( $pointer ) {
121122
$content .= '<div class="googlesitekit-pointer-buttons">' . $buttons . '</div>';
122123
}
123124

124-
$class = array( 'wp-pointer' );
125+
$class = array( 'wp-pointer' );
126+
$slug_class = sanitize_html_class( $pointer->get_slug() );
127+
$class[] = $slug_class;
128+
125129
if ( $pointer->get_class() ) {
126130
$class[] = $pointer->get_class();
127131
}
@@ -156,10 +160,28 @@ private function print_pointer_script( $pointer ) {
156160
'div' => array( 'class' => array() ),
157161
);
158162

163+
$tracking = $pointer->get_tracking();
164+
165+
$data = array(
166+
'data-slug' => $pointer->get_slug(),
167+
'data-class' => implode( ' ', $class ),
168+
'data-target-id' => $pointer->get_target_id(),
169+
'data-title' => wp_kses( $pointer->get_title(), $kses_title ),
170+
'data-content' => wp_kses( $content, $kses_content ),
171+
'data-position' => wp_json_encode( $pointer->get_position() ),
172+
);
173+
174+
if ( ! empty( $tracking ) ) {
175+
$data['data-tracking'] = wp_json_encode( $tracking );
176+
}
177+
159178
BC_Functions::wp_print_inline_script_tag(
160179
<<<'JS'
161180
(
162181
function ( $, wp, config ) {
182+
const tracking = config.tracking ? JSON.parse( config.tracking ) : null;
183+
let trackingHandlers = null;
184+
163185
function initPointer() {
164186
const options = {
165187
content: '<h3>' + config.title + '</h3>' + config.content,
@@ -168,6 +190,9 @@ function initPointer() {
168190
pointerClass: config.class,
169191
close: function() {
170192
wp.ajax.post( 'dismiss-wp-pointer', { pointer: config.slug } );
193+
if ( trackingHandlers && trackingHandlers.onDismiss ) {
194+
trackingHandlers.onDismiss();
195+
}
171196
},
172197
buttons: function( event, container ) {
173198
container.pointer.on( 'click', '[data-action="dismiss"]', function() {
@@ -176,22 +201,27 @@ function initPointer() {
176201
}
177202
};
178203
179-
$( '#' + config.targetId ).pointer( options ).pointer( 'open' );
204+
const target = $( '#' + config.targetId );
205+
if ( ! target.length ) {
206+
return;
207+
}
208+
209+
target.pointer( options ).pointer( 'open' );
210+
211+
if ( tracking && window.googlesitekitAdminPointersTracking ) {
212+
trackingHandlers = window.googlesitekitAdminPointersTracking.register(
213+
config.slug,
214+
tracking
215+
);
216+
}
180217
}
181218
182219
$( initPointer );
183220
}
184221
)( window.jQuery, window.wp, { ...document.currentScript.dataset } );
185222
JS
186223
,
187-
array(
188-
'data-slug' => $pointer->get_slug(),
189-
'data-class' => implode( ' ', $class ),
190-
'data-target-id' => $pointer->get_target_id(),
191-
'data-title' => wp_kses( $pointer->get_title(), $kses_title ),
192-
'data-content' => wp_kses( $content, $kses_content ),
193-
'data-position' => wp_json_encode( $pointer->get_position() ),
194-
)
224+
$data
195225
);
196226
}
197227
}

includes/Core/Assets/Assets.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,13 @@ private function get_assets() {
671671
),
672672
)
673673
),
674+
new Script(
675+
'googlesitekit-admin-pointers-tracking',
676+
array(
677+
'src' => $base_url . 'js/googlesitekit-admin-pointers-tracking.js',
678+
'dependencies' => $this->get_asset_dependencies(),
679+
)
680+
),
674681
// WP Dashboard assets.
675682
new Script(
676683
'googlesitekit-wp-dashboard',

includes/Core/Dashboard_Sharing/View_Only_Pointer.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ private function get_view_only_pointer() {
106106
return true;
107107
},
108108
'class' => 'googlesitekit-view-only-pointer',
109+
'tracking' => array(
110+
'view' => array(
111+
'category' => 'wpDashboard_pointer_view_only_dashboard',
112+
'action' => 'view_notification',
113+
),
114+
'dismiss' => array(
115+
'category' => 'wpDashboard_pointer_view_only_dashboard',
116+
'action' => 'dismiss_notification',
117+
),
118+
'click' => array(
119+
'category' => 'wpDashboard_pointer_view_only_dashboard',
120+
'action' => 'confirm_notification',
121+
),
122+
),
109123
'buttons' =>
110124
sprintf(
111125
'<a class="googlesitekit-pointer-cta button-primary" href="%s" data-action="dismiss">%s</a>',

includes/Core/Email_Reporting/Email_Reporting_Pointer.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,20 @@ private function get_email_reporting_pointer() {
158158
return true;
159159
},
160160
'class' => 'googlesitekit-email-pointer',
161+
'tracking' => array(
162+
'view' => array(
163+
'category' => 'wpDashboard_pointer_email_reports_setup_cta',
164+
'action' => 'view_notification',
165+
),
166+
'dismiss' => array(
167+
'category' => 'wpDashboard_pointer_email_reports_setup_cta',
168+
'action' => 'dismiss_notification',
169+
),
170+
'click' => array(
171+
'category' => 'wpDashboard_pointer_email_reports_setup_cta',
172+
'action' => 'confirm_notification',
173+
),
174+
),
161175
// Inline JS function to render CTA button and add delegated handlers for CTA and dismiss.
162176
'buttons' => sprintf(
163177
'<a class="googlesitekit-pointer-cta button-primary" data-action="dismiss" href="%s">%s</a>',

tests/phpunit/integration/Core/Admin/PointerTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,28 @@ public function test_get_buttons() {
149149
$this->assertEquals( $buttons, $pointer->get_buttons(), 'Pointer buttons should match the provided value.' );
150150
}
151151

152+
public function test_get_tracking() {
153+
$tracking = array(
154+
'view' => array(
155+
'category' => 'test-category',
156+
'action' => 'view',
157+
),
158+
'dismiss' => array(
159+
'category' => 'test-category',
160+
'action' => 'dismiss',
161+
'label' => 'test-label',
162+
),
163+
);
164+
$pointer = new Pointer(
165+
'test-slug',
166+
array(
167+
'tracking' => $tracking,
168+
)
169+
);
170+
171+
$this->assertEquals( $tracking, $pointer->get_tracking(), 'Pointer tracking should match the provided value.' );
172+
}
173+
152174
public function data_is_active() {
153175
return array(
154176
'no args' => array(

tests/phpunit/integration/Core/Admin/PointersTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,35 @@ function ( $pointers ) {
183183
$this->assertStringContainsString( 'Test pointer content.', $output, 'Pointer output should contain the content.' );
184184
$this->assertStringContainsString( 'test-target', $output, 'Pointer output should contain the target ID.' );
185185
}
186+
187+
public function test_print_pointer_script_with_tracking() {
188+
add_filter(
189+
'googlesitekit_admin_pointers',
190+
function ( $pointers ) {
191+
$pointers[] = new Pointer(
192+
'test-slug-tracking',
193+
array(
194+
'title' => 'Test pointer title',
195+
'content' => 'Test pointer content.',
196+
'target_id' => 'test-target',
197+
'active_callback' => '__return_true',
198+
'tracking' => array(
199+
'view' => array(
200+
'category' => 'test-category',
201+
'action' => 'view_pointer',
202+
),
203+
),
204+
)
205+
);
206+
return $pointers;
207+
}
208+
);
209+
210+
do_action( 'admin_enqueue_scripts', self::TEST_HOOK_SUFFIX );
211+
212+
$output = $this->capture_action( 'admin_print_footer_scripts' );
213+
214+
$this->assertStringContainsString( 'data-tracking', $output, 'Pointer output should include tracking data.' );
215+
$this->assertStringContainsString( 'test-category', $output, 'Pointer output should include tracking category.' );
216+
}
186217
}

0 commit comments

Comments
 (0)