@financial-times/cp-content-pipeline-ui 6.13.0 → 6.14.1

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.
@@ -0,0 +1,132 @@
1
+ import { TopperTracker } from './index'
2
+
3
+ describe('TopperTracker', () => {
4
+ let mockIntersectionObserver: jest.Mock
5
+ let mockObserve: jest.Mock
6
+ let mockUnobserve: jest.Mock
7
+ let mockDisconnect: jest.Mock
8
+ let mockComponent: HTMLElement
9
+ let dispatchEventSpy: jest.SpyInstance
10
+
11
+ beforeEach(() => {
12
+ mockObserve = jest.fn()
13
+ mockUnobserve = jest.fn()
14
+ mockDisconnect = jest.fn()
15
+
16
+ mockIntersectionObserver = jest.fn(() => {
17
+ return {
18
+ observe: mockObserve,
19
+ unobserve: mockUnobserve,
20
+ disconnect: mockDisconnect,
21
+ }
22
+ })
23
+
24
+ window.IntersectionObserver = mockIntersectionObserver
25
+
26
+ mockComponent = document.createElement('div')
27
+ mockComponent.setAttribute('data-component-type', 'flourish-topper')
28
+ mockComponent.setAttribute('data-component-id', 'test-id')
29
+ document.body.appendChild(mockComponent)
30
+
31
+ jest.spyOn(document, 'querySelector').mockReturnValue(mockComponent)
32
+ jest
33
+ .spyOn(performance, 'now')
34
+ .mockImplementationOnce(() => 1)
35
+ .mockImplementationOnce(() => 12344)
36
+ dispatchEventSpy = jest.spyOn(document.body, 'dispatchEvent')
37
+ })
38
+
39
+ afterEach(() => {
40
+ jest.clearAllMocks()
41
+ document.body.removeChild(mockComponent)
42
+ })
43
+
44
+ it('should initialise and observe the component', () => {
45
+ const tracker = new TopperTracker()
46
+ tracker.init()
47
+
48
+ expect(mockIntersectionObserver).toHaveBeenCalled()
49
+ expect(mockObserve).toHaveBeenCalledWith(mockComponent)
50
+ })
51
+
52
+ it('should dispatch a mount event on intialisation', () => {
53
+ const tracker = new TopperTracker()
54
+ tracker.init()
55
+
56
+ const mountEvent = dispatchEventSpy.mock.calls[0][0] as CustomEvent
57
+ expect(mountEvent.type).toBe('oTracking.event')
58
+ expect(mountEvent.detail).toEqual({
59
+ category: 'component',
60
+ action: 'mount',
61
+ component: {
62
+ name: 'flourish-topper',
63
+ id: 'test-id',
64
+ },
65
+ })
66
+ })
67
+
68
+ it('should handle component visibility changes', () => {
69
+ const tracker = new TopperTracker()
70
+ tracker.init()
71
+
72
+ const mockChanges: IntersectionObserverEntry[] = [
73
+ {
74
+ target: mockComponent,
75
+ isIntersecting: true,
76
+ intersectionRatio: 1.0,
77
+ boundingClientRect: {} as DOMRectReadOnly,
78
+ intersectionRect: {} as DOMRectReadOnly,
79
+ rootBounds: null,
80
+ time: 1,
81
+ },
82
+ ]
83
+
84
+ tracker['onChange'](mockChanges)
85
+
86
+ const viewEvent = dispatchEventSpy.mock.calls[1][0] as CustomEvent
87
+ expect(viewEvent.type).toBe('oTracking.event')
88
+ expect(viewEvent.detail).toEqual({
89
+ category: 'component',
90
+ action: 'view',
91
+ component: {
92
+ name: 'flourish-topper',
93
+ id: 'test-id',
94
+ },
95
+ })
96
+
97
+ const mockChangesAfter: IntersectionObserverEntry[] = [
98
+ {
99
+ target: mockComponent,
100
+ isIntersecting: false,
101
+ intersectionRatio: 0.0,
102
+ boundingClientRect: {} as DOMRectReadOnly,
103
+ intersectionRect: {} as DOMRectReadOnly,
104
+ rootBounds: null,
105
+ time: 12.3444,
106
+ },
107
+ ]
108
+
109
+ tracker['onChange'](mockChangesAfter)
110
+
111
+ const stopViewEvent = dispatchEventSpy.mock.calls[2][0] as CustomEvent
112
+ expect(stopViewEvent.type).toBe('oTracking.event')
113
+ expect(stopViewEvent.detail).toEqual({
114
+ category: 'component',
115
+ action: 'stop-view',
116
+ component: {
117
+ name: 'flourish-topper',
118
+ id: 'test-id',
119
+ timeElapsedSeconds: 12.34,
120
+ },
121
+ })
122
+ })
123
+
124
+ it('should disconnect the observer', () => {
125
+ const tracker = new TopperTracker()
126
+ tracker.init()
127
+ tracker.disconnect()
128
+
129
+ expect(mockUnobserve).toHaveBeenCalledWith(mockComponent)
130
+ expect(mockDisconnect).toHaveBeenCalled()
131
+ })
132
+ })
@@ -0,0 +1,88 @@
1
+ interface Component {
2
+ name: string
3
+ id: string | null
4
+ timeElapsedSeconds?: number
5
+ }
6
+ class TopperTracker {
7
+ private startTime: number
8
+ private totalVisibleTime: number
9
+ private timeElapsedSeconds: number | null
10
+ private type: string
11
+ private component: Element | null
12
+ private id: string | null
13
+ private observer: IntersectionObserver | null
14
+
15
+ constructor({ type = 'flourish-topper' } = {}) {
16
+ this.startTime = 0
17
+ this.totalVisibleTime = 0
18
+ this.timeElapsedSeconds = null
19
+ this.type = type
20
+ this.component = document.querySelector(
21
+ `[data-component-type="${this.type}"]`
22
+ )
23
+ this.id = this.component?.getAttribute('data-component-id') || null
24
+ this.observer = null
25
+ }
26
+
27
+ init(): void {
28
+ if (!window.IntersectionObserver || !this.component) {
29
+ return
30
+ }
31
+
32
+ this.dispatchEvent('mount')
33
+
34
+ this.observer = new IntersectionObserver(this.onChange.bind(this), {
35
+ threshold: [0.75],
36
+ })
37
+ this.observer.observe(this.component)
38
+ }
39
+
40
+ private dispatchEvent(action: string): void {
41
+ const component: Component = {
42
+ name: this.type,
43
+ id: this.id,
44
+ }
45
+ if (this.timeElapsedSeconds) {
46
+ component.timeElapsedSeconds = this.timeElapsedSeconds
47
+ }
48
+ const event = new CustomEvent('oTracking.event', {
49
+ detail: {
50
+ category: 'component',
51
+ action: action,
52
+ component,
53
+ },
54
+ bubbles: true,
55
+ })
56
+ document.body.dispatchEvent(event)
57
+ }
58
+
59
+ private onChange(changes: IntersectionObserverEntry[]): void {
60
+ changes.forEach((change) => {
61
+ if (change.target !== this.component) {
62
+ return
63
+ }
64
+ if (change.isIntersecting || change.intersectionRatio >= 1) {
65
+ this.dispatchEvent('view')
66
+ this.startTime = performance.now()
67
+ }
68
+ if (!change.isIntersecting || change.intersectionRatio === 0) {
69
+ this.totalVisibleTime = performance.now() - this.startTime
70
+ this.timeElapsedSeconds = parseFloat(
71
+ (this.totalVisibleTime / 1000).toFixed(2)
72
+ )
73
+ this.dispatchEvent('stop-view')
74
+ this.totalVisibleTime = 0
75
+ this.timeElapsedSeconds = null
76
+ }
77
+ })
78
+ }
79
+
80
+ disconnect(): void {
81
+ if (this.observer && this.component) {
82
+ this.observer.unobserve(this.component)
83
+ this.observer.disconnect()
84
+ }
85
+ }
86
+ }
87
+
88
+ export { TopperTracker }