@guardian/ophan-tracker-js 2.2.9 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,10 +39,38 @@ const getDataLinkNames = (el, dataLinkNames = []) => {
39
39
  dataLinkNames.push(dataLinkName);
40
40
  return getDataLinkNames(el.parentNode, dataLinkNames);
41
41
  };
42
+ /**
43
+ * Manually track a click event on an element.
44
+ * Useful for elements that stop propagation or need explicit tracking.
45
+ *
46
+ * @param {Element} element - The element that was clicked.
47
+ */
48
+ export const trackClickComponentEvent = (element) => {
49
+ if (!element)
50
+ return;
51
+ const info = {
52
+ from: [location.protocol, '//', location.host, location.pathname].join(''),
53
+ to: element.href || undefined,
54
+ referringComponent: getContainingComponent(element),
55
+ referringDataLinkNames: getDataLinkNames(element),
56
+ refPlatform: ophan.servingPlatform(),
57
+ refViewId: ophan.viewId,
58
+ };
59
+ if (info.referringDataLinkNames && info.referringDataLinkNames.length > 0) {
60
+ transmit.sendMore({
61
+ clickComponent: info.referringComponent,
62
+ clickLinkNames: info.referringDataLinkNames,
63
+ });
64
+ }
65
+ // If it's an anchor, store data for next page
66
+ const anchorTarget = validAncestorAnchorElement(element);
67
+ if (anchorTarget) {
68
+ ophan.storeDataToSendOnNextEvent(info);
69
+ }
70
+ };
42
71
  if (typeof document.addEventListener === 'function') {
43
72
  document.addEventListener('click', function (e) {
44
73
  let target = e.target;
45
- let info;
46
74
  // For anchors, use validAncestorAnchorElement
47
75
  const anchorTarget = validAncestorAnchorElement(target);
48
76
  const nonTrackableElements = [
@@ -62,26 +90,10 @@ if (typeof document.addEventListener === 'function') {
62
90
  }
63
91
  // If it's an anchor, use anchorTarget, otherwise use the original target
64
92
  const trackingTarget = anchorTarget || target;
65
- info = {
66
- from: [location.protocol, '//', location.host, location.pathname].join(''),
67
- to: trackingTarget.href || undefined,
68
- referringComponent: getContainingComponent(trackingTarget),
69
- referringDataLinkNames: getDataLinkNames(trackingTarget),
70
- refPlatform: ophan.servingPlatform(),
71
- refViewId: ophan.viewId,
72
- };
73
- if (info.referringDataLinkNames && info.referringDataLinkNames.length > 0) {
74
- transmit.sendMore({
75
- clickComponent: info.referringComponent,
76
- clickLinkNames: info.referringDataLinkNames,
77
- });
78
- }
79
- if (anchorTarget) {
80
- // If it's an anchor, we may be about to navigate away from this page, so store to send on next request
81
- return ophan.storeDataToSendOnNextEvent(info);
82
- }
93
+ trackClickComponentEvent(trackingTarget);
83
94
  }, false);
84
95
  }
85
96
  export default {
86
97
  getDataLinkNames: getDataLinkNames,
98
+ trackClickComponentEvent: trackClickComponentEvent,
87
99
  };
package/NPM-dist/ng.js CHANGED
@@ -20,6 +20,7 @@ export * from './types/consent.js';
20
20
  export * from './types/component-type.js';
21
21
  export * from './types/component-event.js';
22
22
  export * from './types/event.js';
23
+ import { trackClickComponentEvent } from "./click-path-capture.js";
23
24
  let ng;
24
25
  if (window.guardian && window.guardian.ophan) {
25
26
  ng = window.guardian.ophan;
@@ -40,6 +41,7 @@ else {
40
41
  record: transmit.sendMore,
41
42
  viewId: transmit.viewId,
42
43
  pageViewId: transmit.viewId,
44
+ trackClickComponentEvent: trackClickComponentEvent,
43
45
  };
44
46
  ng = window.guardian.ophan;
45
47
  }
@@ -1,4 +1,4 @@
1
- import clickPathCapture, { getContainingComponent, validAncestorAnchorElement, } from '../click-path-capture.js';
1
+ import clickPathCapture, { getContainingComponent, validAncestorAnchorElement, trackClickComponentEvent, } from '../click-path-capture.js';
2
2
  import ophan from '../core.js';
3
3
  import transmit from '../transmit.js';
4
4
  const { getDataLinkNames } = clickPathCapture;
@@ -115,4 +115,213 @@ describe('click-path-capture.js', () => {
115
115
  expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
116
116
  });
117
117
  });
118
+ describe('trackClickComponentEvent', () => {
119
+ let transmitSendMoreSpy, ophanStoreDataToSendOnNextEventSpy;
120
+ beforeEach(() => {
121
+ jest.clearAllMocks();
122
+ transmitSendMoreSpy = jest
123
+ .spyOn(transmit, 'sendMore')
124
+ .mockImplementation(() => { });
125
+ ophanStoreDataToSendOnNextEventSpy = jest
126
+ .spyOn(ophan, 'storeDataToSendOnNextEvent')
127
+ .mockImplementation(() => { });
128
+ jest.spyOn(ophan, 'servingPlatform').mockReturnValue('web');
129
+ ophan.viewId = 'test-view-id';
130
+ });
131
+ afterEach(() => {
132
+ jest.clearAllMocks();
133
+ });
134
+ test('should handle null or undefined element gracefully', () => {
135
+ trackClickComponentEvent(null);
136
+ trackClickComponentEvent(undefined);
137
+ expect(transmitSendMoreSpy).not.toHaveBeenCalled();
138
+ expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
139
+ });
140
+ test('should track clicks on buttons with nested elements (play button scenario)', () => {
141
+ document.body.innerHTML = `
142
+ <div data-component="looping-video">
143
+ <button id="playButton" data-link-name="video-play-pause">
144
+ <div class="play-icon">
145
+ <svg id="playIcon">...</svg>
146
+ </div>
147
+ </button>
148
+ </div>
149
+ `;
150
+ const button = document.getElementById('playButton');
151
+ trackClickComponentEvent(button);
152
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
153
+ clickComponent: 'looping-video',
154
+ clickLinkNames: ['video-play-pause'],
155
+ });
156
+ expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
157
+ });
158
+ test('should track clicks with stopPropagation (mute button scenario)', () => {
159
+ document.body.innerHTML = `
160
+ <div data-component="looping-video">
161
+ <button id="muteButton" data-link-name="video-mute-toggle">
162
+ <div class="audio-icon-container">
163
+ <span>Mute</span>
164
+ </div>
165
+ </button>
166
+ </div>
167
+ `;
168
+ const button = document.getElementById('muteButton');
169
+ // Simulate a click handler that stops propagation
170
+ button.addEventListener('click', (e) => {
171
+ e.stopPropagation();
172
+ // Manually track the click since propagation is stopped
173
+ trackClickComponentEvent(e.currentTarget);
174
+ });
175
+ button.click();
176
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
177
+ clickComponent: 'looping-video',
178
+ clickLinkNames: ['video-mute-toggle'],
179
+ });
180
+ expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
181
+ });
182
+ test('should collect multiple data-link-names from nested components', () => {
183
+ document.body.innerHTML = `
184
+ <div data-component="outer-component" data-link-name="outer-link">
185
+ <div data-link-name="middle-link">
186
+ <button id="nestedButton" data-link-name="button-link">
187
+ Click Me
188
+ </button>
189
+ </div>
190
+ </div>
191
+ `;
192
+ const button = document.getElementById('nestedButton');
193
+ trackClickComponentEvent(button);
194
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
195
+ clickComponent: 'outer-component',
196
+ clickLinkNames: ['button-link', 'middle-link', 'outer-link'],
197
+ });
198
+ });
199
+ test('should handle anchor elements and store data for next event', () => {
200
+ document.body.innerHTML = `
201
+ <div data-component="navigation">
202
+ <a id="testAnchor" href="/next-page" data-link-name="nav-link">
203
+ Next Page
204
+ </a>
205
+ </div>
206
+ `;
207
+ const anchor = document.getElementById('testAnchor');
208
+ trackClickComponentEvent(anchor);
209
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
210
+ clickComponent: 'navigation',
211
+ clickLinkNames: ['nav-link'],
212
+ });
213
+ expect(ophanStoreDataToSendOnNextEventSpy).toHaveBeenCalledWith({
214
+ from: 'http://localhost/',
215
+ to: 'http://localhost/next-page',
216
+ referringComponent: 'navigation',
217
+ referringDataLinkNames: ['nav-link'],
218
+ refPlatform: 'web',
219
+ refViewId: 'test-view-id',
220
+ });
221
+ });
222
+ test('should handle nested anchors correctly', () => {
223
+ document.body.innerHTML = `
224
+ <div data-component="content">
225
+ <a id="outerAnchor" href="/outer" data-link-name="outer-anchor">
226
+ <span id="innerSpan" data-link-name="inner-span">Click me</span>
227
+ </a>
228
+ </div>
229
+ `;
230
+ const span = document.getElementById('innerSpan');
231
+ trackClickComponentEvent(span);
232
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
233
+ clickComponent: 'content',
234
+ clickLinkNames: ['inner-span', 'outer-anchor'],
235
+ });
236
+ expect(ophanStoreDataToSendOnNextEventSpy).toHaveBeenCalledWith({
237
+ from: 'http://localhost/',
238
+ to: undefined, // span elements don't have href
239
+ referringComponent: 'content',
240
+ referringDataLinkNames: ['inner-span', 'outer-anchor'],
241
+ refPlatform: 'web',
242
+ refViewId: 'test-view-id',
243
+ });
244
+ });
245
+ test('should not send data if no data-link-names are present', () => {
246
+ document.body.innerHTML = `
247
+ <div data-component="empty-component">
248
+ <button id="emptyButton">No tracking data</button>
249
+ </div>
250
+ `;
251
+ const button = document.getElementById('emptyButton');
252
+ trackClickComponentEvent(button);
253
+ expect(transmitSendMoreSpy).not.toHaveBeenCalled();
254
+ expect(ophanStoreDataToSendOnNextEventSpy).not.toHaveBeenCalled();
255
+ });
256
+ test('should handle elements without component correctly', () => {
257
+ document.body.innerHTML = `
258
+ <button id="orphanButton" data-link-name="orphan-link">
259
+ Orphan Button
260
+ </button>
261
+ `;
262
+ const button = document.getElementById('orphanButton');
263
+ trackClickComponentEvent(button);
264
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
265
+ clickComponent: null,
266
+ clickLinkNames: ['orphan-link'],
267
+ });
268
+ });
269
+ test('should handle real-world scenario with video controls (auto-looping videos in dotcom)', () => {
270
+ document.body.innerHTML = `
271
+ <div data-component="video-player" data-link-name="main-video">
272
+ <div class="video-container" data-link-name="video-container">
273
+ <video id="video" src="video.mp4"></video>
274
+ <div class="controls">
275
+ <button id="playPauseBtn" data-link-name="play-pause">
276
+ <div class="icon-wrapper">
277
+ <svg class="play-icon">...</svg>
278
+ </div>
279
+ </button>
280
+ <button id="muteBtn" data-link-name="mute-toggle">
281
+ <div class="icon-wrapper">
282
+ <svg class="mute-icon">...</svg>
283
+ </div>
284
+ </button>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ `;
289
+ // Test play button tracking
290
+ const playButton = document.getElementById('playPauseBtn');
291
+ trackClickComponentEvent(playButton);
292
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
293
+ clickComponent: 'video-player',
294
+ clickLinkNames: ['play-pause', 'video-container', 'main-video'],
295
+ });
296
+ transmitSendMoreSpy.mockClear();
297
+ // Test mute button tracking
298
+ const muteButton = document.getElementById('muteBtn');
299
+ trackClickComponentEvent(muteButton);
300
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
301
+ clickComponent: 'video-player',
302
+ clickLinkNames: ['mute-toggle', 'video-container', 'main-video'],
303
+ });
304
+ });
305
+ test('should handle currentTarget from event objects', () => {
306
+ document.body.innerHTML = `
307
+ <div data-component="interactive-element">
308
+ <button id="eventButton" data-link-name="event-button">
309
+ <span>Click Target</span>
310
+ </button>
311
+ </div>
312
+ `;
313
+ const button = document.getElementById('eventButton');
314
+ const span = button.querySelector('span');
315
+ // Simulate clicking the span but tracking the button (currentTarget)
316
+ const mockEvent = new MouseEvent('click', { bubbles: true });
317
+ Object.defineProperty(mockEvent, 'target', { value: span });
318
+ Object.defineProperty(mockEvent, 'currentTarget', { value: button });
319
+ // This mimics: trackClickComponentEvent(event.currentTarget)
320
+ trackClickComponentEvent(button);
321
+ expect(transmitSendMoreSpy).toHaveBeenCalledWith({
322
+ clickComponent: 'interactive-element',
323
+ clickLinkNames: ['event-button'],
324
+ });
325
+ });
326
+ });
118
327
  });
@@ -1,7 +1,9 @@
1
1
  export function validAncestorAnchorElement(el: Element): Element | null;
2
2
  export function getContainingComponent(el: Element): string | null;
3
+ export function trackClickComponentEvent(element: Element): void;
3
4
  declare namespace _default {
4
5
  export { getDataLinkNames };
6
+ export { trackClickComponentEvent };
5
7
  }
6
8
  export default _default;
7
9
  /**
@@ -21,6 +21,7 @@ interface NG {
21
21
  viewId: string;
22
22
  trackComponentAttention: (name: string, el: HTMLElement, visibilityThreshold: number) => void;
23
23
  setEventEmitter: (event: Object) => void;
24
+ trackClickComponentEvent: (element: Element) => void;
24
25
  }
25
26
  declare let ng: NG;
26
27
  export default ng;
@@ -51,7 +51,7 @@ export type TAction =
51
51
  /**
52
52
  * User Consent
53
53
  **/
54
- | 'ACCEPT_DEFAULT_CONSENT' | 'MANAGE_CONSENT' | 'CONSENT_ACCEPT_ALL' | 'CONSENT_REJECT_ALL'
54
+ | 'ACCEPT_DEFAULT_CONSENT' | 'MANAGE_CONSENT' | 'CONSENT_ACCEPT_ALL' | 'CONSENT_REJECT_ALL' | 'CONSENT_GEOLOCATION_MISMATCH'
55
55
  /**
56
56
  * The component sticks in the screen
57
57
  **/
@@ -1 +1 @@
1
- export type TComponentType = "READERS_QUESTIONS_ATOM" | "QANDA_ATOM" | "PROFILE_ATOM" | "GUIDE_ATOM" | "TIMELINE_ATOM" | "NEWSLETTER_SUBSCRIPTION" | "SURVEYS_QUESTIONS" | "ACQUISITIONS_EPIC" | "ACQUISITIONS_ENGAGEMENT_BANNER" | "ACQUISITIONS_THANK_YOU_EPIC" | "ACQUISITIONS_HEADER" | "ACQUISITIONS_FOOTER" | "ACQUISITIONS_INTERACTIVE_SLICE" | "ACQUISITIONS_NUGGET" | "ACQUISITIONS_STANDFIRST" | "ACQUISITIONS_THRASHER" | "ACQUISITIONS_EDITORIAL_LINK" | "ACQUISITIONS_MANAGE_MY_ACCOUNT" | "ACQUISITIONS_BUTTON" | "ACQUISITIONS_OTHER" | "APP_ADVERT" | "APP_AUDIO" | "APP_BUTTON" | "APP_CARD" | "APP_CROSSWORDS" | "APP_ENGAGEMENT_BANNER" | "APP_EPIC" | "APP_GALLERY" | "APP_LINK" | "APP_NAVIGATION_ITEM" | "APP_SCREEN" | "APP_THRASHER" | "APP_VIDEO" | "AUDIO_ATOM" | "CHART_ATOM" | "ACQUISITIONS_MERCHANDISING" | "ACQUISITIONS_HOUSE_ADS" | "SIGN_IN_GATE" | "ACQUISITIONS_SUBSCRIPTIONS_BANNER" | "MOBILE_STICKY_AD" | "IDENTITY_AUTHENTICATION" | "RETENTION_ENGAGEMENT_BANNER" | "ACQUISITION_SUPPORT_SITE" | "RETENTION_EPIC" | "CONSENT" | "LIVE_BLOG_PINNED_POST" | "STICKY_VIDEO" | "KEY_EVENT_CARD" | "RETENTION_HEADER" | "SLIDESHOW" | "APP_FEATURE" | "CARD" | "CAROUSEL" | "CONTAINER" | "MENU" | "ACQUISITIONS_GUTTER";
1
+ export type TComponentType = "READERS_QUESTIONS_ATOM" | "QANDA_ATOM" | "PROFILE_ATOM" | "GUIDE_ATOM" | "TIMELINE_ATOM" | "NEWSLETTER_SUBSCRIPTION" | "SURVEYS_QUESTIONS" | "ACQUISITIONS_EPIC" | "ACQUISITIONS_ENGAGEMENT_BANNER" | "ACQUISITIONS_THANK_YOU_EPIC" | "ACQUISITIONS_HEADER" | "ACQUISITIONS_FOOTER" | "ACQUISITIONS_INTERACTIVE_SLICE" | "ACQUISITIONS_NUGGET" | "ACQUISITIONS_STANDFIRST" | "ACQUISITIONS_THRASHER" | "ACQUISITIONS_EDITORIAL_LINK" | "ACQUISITIONS_MANAGE_MY_ACCOUNT" | "ACQUISITIONS_BUTTON" | "ACQUISITIONS_OTHER" | "APP_ADVERT" | "APP_AUDIO" | "APP_BUTTON" | "APP_CARD" | "APP_CROSSWORDS" | "APP_ENGAGEMENT_BANNER" | "APP_EPIC" | "APP_GALLERY" | "APP_LINK" | "APP_NAVIGATION_ITEM" | "APP_SCREEN" | "APP_THRASHER" | "APP_VIDEO" | "AUDIO_ATOM" | "CHART_ATOM" | "ACQUISITIONS_MERCHANDISING" | "ACQUISITIONS_HOUSE_ADS" | "SIGN_IN_GATE" | "ACQUISITIONS_SUBSCRIPTIONS_BANNER" | "MOBILE_STICKY_AD" | "IDENTITY_AUTHENTICATION" | "RETENTION_ENGAGEMENT_BANNER" | "ACQUISITION_SUPPORT_SITE" | "RETENTION_EPIC" | "CONSENT" | "LIVE_BLOG_PINNED_POST" | "STICKY_VIDEO" | "KEY_EVENT_CARD" | "RETENTION_HEADER" | "SLIDESHOW" | "APP_FEATURE" | "CARD" | "CAROUSEL" | "CONTAINER" | "MENU" | "ACQUISITIONS_GUTTER" | "INTERACTIVE_ATOM" | 'LOOP_VIDEO';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guardian/ophan-tracker-js",
3
- "version": "2.2.9",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=16"
package/readme.md CHANGED
@@ -20,7 +20,7 @@ Tracker JS contains multiple entry point files tailored to specific platforms. T
20
20
  **Default Behaviour**
21
21
 
22
22
  The [default entry point](https://github.com/guardian/ophan/blob/main/tracker-js/package.json#L12) for the library is `ng.js`. With this entry point, the library automatically initializes itself on the global window object and provides a suite of functionalities to monitor user interactions, visibility, and more.
23
- The library will send an initial [page view event](./types/event.ts#L15) on page load. You are then in control of sending other events to Ophan.
23
+ The library will send an initial [page view event](/tracker-js/src/types/event.ts#L15) on page load. You are then in control of sending other events to Ophan.
24
24
 
25
25
  You can see example usage of the library on theguardian.com [here](https://github.com/guardian/dotcom-rendering/blob/main/dotcom-rendering/src/client/ophan/ophan.ts)
26
26
 
@@ -42,7 +42,7 @@ You can see example usage of the library on theguardian.com [here](https://githu
42
42
 
43
43
  ### Usage with Typescript
44
44
 
45
- Refer to the type definitions within the library for the structure of valid tracking events. The `ophan.record()` function accepts a type of `EventPayload` found [here]("https://github.com/guardian/ophan/blob/main/tracker-js/src/types/event.ts#L90").
45
+ Refer to the type definitions within the library for the structure of valid tracking events. The `ophan.record()` function accepts a type of `EventPayload` found [here](/tracker-js/src/types/event.ts#L90).
46
46
 
47
47
  #### Example Usage
48
48