react-fathom 0.1.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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/cjs/index.cjs +410 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/next/index.cjs +910 -0
- package/dist/cjs/next/index.cjs.map +1 -0
- package/dist/es/index.js +381 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/next/index.js +885 -0
- package/dist/es/next/index.js.map +1 -0
- package/dist/react-fathom.js +413 -0
- package/dist/react-fathom.js.map +1 -0
- package/dist/react-fathom.min.js +3 -0
- package/dist/react-fathom.min.js.map +1 -0
- package/package.json +127 -0
- package/src/FathomContext.tsx +5 -0
- package/src/FathomProvider.test.tsx +532 -0
- package/src/FathomProvider.tsx +122 -0
- package/src/components/TrackClick.test.tsx +191 -0
- package/src/components/TrackClick.tsx +62 -0
- package/src/components/TrackPageview.test.tsx +111 -0
- package/src/components/TrackPageview.tsx +36 -0
- package/src/components/TrackVisible.test.tsx +311 -0
- package/src/components/TrackVisible.tsx +105 -0
- package/src/components/index.ts +3 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useFathom.test.tsx +51 -0
- package/src/hooks/useFathom.ts +11 -0
- package/src/hooks/useTrackOnClick.test.tsx +197 -0
- package/src/hooks/useTrackOnClick.ts +65 -0
- package/src/hooks/useTrackOnMount.test.tsx +79 -0
- package/src/hooks/useTrackOnMount.ts +24 -0
- package/src/hooks/useTrackOnVisible.test.tsx +313 -0
- package/src/hooks/useTrackOnVisible.ts +99 -0
- package/src/index.ts +4 -0
- package/src/next/NextFathomProvider.test.tsx +131 -0
- package/src/next/NextFathomProvider.tsx +62 -0
- package/src/next/NextFathomProviderApp.test.tsx +308 -0
- package/src/next/NextFathomProviderApp.tsx +106 -0
- package/src/next/NextFathomProviderPages.test.tsx +330 -0
- package/src/next/NextFathomProviderPages.tsx +112 -0
- package/src/next/compositions/withAppRouter.test.tsx +113 -0
- package/src/next/compositions/withAppRouter.tsx +48 -0
- package/src/next/compositions/withPagesRouter.test.tsx +113 -0
- package/src/next/compositions/withPagesRouter.tsx +44 -0
- package/src/next/index.ts +7 -0
- package/src/next/types.ts +19 -0
- package/src/types.ts +37 -0
- package/types/FathomContext.d.ts +3 -0
- package/types/FathomContext.d.ts.map +1 -0
- package/types/FathomProvider.d.ts +5 -0
- package/types/FathomProvider.d.ts.map +1 -0
- package/types/components/TrackClick.d.ts +39 -0
- package/types/components/TrackClick.d.ts.map +1 -0
- package/types/components/TrackPageview.d.ts +21 -0
- package/types/components/TrackPageview.d.ts.map +1 -0
- package/types/components/TrackVisible.d.ts +39 -0
- package/types/components/TrackVisible.d.ts.map +1 -0
- package/types/components/index.d.ts +4 -0
- package/types/components/index.d.ts.map +1 -0
- package/types/hooks/index.d.ts +5 -0
- package/types/hooks/index.d.ts.map +1 -0
- package/types/hooks/useFathom.d.ts +6 -0
- package/types/hooks/useFathom.d.ts.map +1 -0
- package/types/hooks/useTrackOnClick.d.ts +39 -0
- package/types/hooks/useTrackOnClick.d.ts.map +1 -0
- package/types/hooks/useTrackOnMount.d.ts +14 -0
- package/types/hooks/useTrackOnMount.d.ts.map +1 -0
- package/types/hooks/useTrackOnVisible.d.ts +43 -0
- package/types/hooks/useTrackOnVisible.d.ts.map +1 -0
- package/types/index.d.ts +5 -0
- package/types/index.d.ts.map +1 -0
- package/types/next/AppRouterProvider.d.ts +7 -0
- package/types/next/AppRouterProvider.d.ts.map +1 -0
- package/types/next/NextFathomProvider.d.ts +34 -0
- package/types/next/NextFathomProvider.d.ts.map +1 -0
- package/types/next/NextFathomProviderApp.d.ts +6 -0
- package/types/next/NextFathomProviderApp.d.ts.map +1 -0
- package/types/next/NextFathomProviderPages.d.ts +6 -0
- package/types/next/NextFathomProviderPages.d.ts.map +1 -0
- package/types/next/PagesRouterProvider.d.ts +7 -0
- package/types/next/PagesRouterProvider.d.ts.map +1 -0
- package/types/next/compositions/withAppRouter.d.ts +29 -0
- package/types/next/compositions/withAppRouter.d.ts.map +1 -0
- package/types/next/compositions/withPagesRouter.d.ts +25 -0
- package/types/next/compositions/withPagesRouter.d.ts.map +1 -0
- package/types/next/index.d.ts +6 -0
- package/types/next/index.d.ts.map +1 -0
- package/types/next/types.d.ts +16 -0
- package/types/next/types.d.ts.map +1 -0
- package/types/test-setup.d.ts +2 -0
- package/types/test-setup.d.ts.map +1 -0
- package/types/types.d.ts +34 -0
- package/types/types.d.ts.map +1 -0
- package/types/useFathom.d.ts +7 -0
- package/types/useFathom.d.ts.map +1 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
6
|
+
|
|
7
|
+
import { FathomProvider } from '../FathomProvider'
|
|
8
|
+
import { TrackClick } from './TrackClick'
|
|
9
|
+
|
|
10
|
+
describe('TrackClick', () => {
|
|
11
|
+
const mockTrackEvent = vi.fn()
|
|
12
|
+
const mockClient = {
|
|
13
|
+
trackEvent: mockTrackEvent,
|
|
14
|
+
trackPageview: vi.fn(),
|
|
15
|
+
trackGoal: vi.fn(),
|
|
16
|
+
load: vi.fn(),
|
|
17
|
+
setSite: vi.fn(),
|
|
18
|
+
blockTrackingForMe: vi.fn(),
|
|
19
|
+
enableTrackingForMe: vi.fn(),
|
|
20
|
+
isTrackingEnabled: vi.fn(() => true),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
24
|
+
<FathomProvider client={mockClient}>{children}</FathomProvider>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should render children', () => {
|
|
32
|
+
render(
|
|
33
|
+
<TrackClick eventName="test-event">
|
|
34
|
+
<button>Click me</button>
|
|
35
|
+
</TrackClick>,
|
|
36
|
+
{ wrapper },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(screen.getByText('Click me')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should track event on click', () => {
|
|
43
|
+
render(
|
|
44
|
+
<TrackClick eventName="test-event">
|
|
45
|
+
<button>Click me</button>
|
|
46
|
+
</TrackClick>,
|
|
47
|
+
{ wrapper },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
fireEvent.click(screen.getByText('Click me'))
|
|
51
|
+
|
|
52
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
|
53
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should track event with options', () => {
|
|
57
|
+
render(
|
|
58
|
+
<TrackClick eventName="test-event" id="test-id" value={100}>
|
|
59
|
+
<button>Click me</button>
|
|
60
|
+
</TrackClick>,
|
|
61
|
+
{ wrapper },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
fireEvent.click(screen.getByText('Click me'))
|
|
65
|
+
|
|
66
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
|
|
67
|
+
id: 'test-id',
|
|
68
|
+
value: 100,
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should prevent default when preventDefault is true', () => {
|
|
73
|
+
render(
|
|
74
|
+
<TrackClick eventName="test-event" preventDefault>
|
|
75
|
+
<a href="/test">Link</a>
|
|
76
|
+
</TrackClick>,
|
|
77
|
+
{ wrapper },
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const link = screen.getByText('Link')
|
|
81
|
+
const clickEvent = new MouseEvent('click', {
|
|
82
|
+
bubbles: true,
|
|
83
|
+
cancelable: true,
|
|
84
|
+
})
|
|
85
|
+
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
|
|
86
|
+
|
|
87
|
+
fireEvent(link, clickEvent)
|
|
88
|
+
|
|
89
|
+
expect(preventDefaultSpy).toHaveBeenCalled()
|
|
90
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should not prevent default when preventDefault is false', () => {
|
|
94
|
+
render(
|
|
95
|
+
<TrackClick eventName="test-event" preventDefault={false}>
|
|
96
|
+
<a href="/test">Link</a>
|
|
97
|
+
</TrackClick>,
|
|
98
|
+
{ wrapper },
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const link = screen.getByText('Link')
|
|
102
|
+
const clickEvent = new MouseEvent('click', {
|
|
103
|
+
bubbles: true,
|
|
104
|
+
cancelable: true,
|
|
105
|
+
})
|
|
106
|
+
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
|
|
107
|
+
|
|
108
|
+
fireEvent(link, clickEvent)
|
|
109
|
+
|
|
110
|
+
expect(preventDefaultSpy).not.toHaveBeenCalled()
|
|
111
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should call custom onClick handler', () => {
|
|
115
|
+
const onClick = vi.fn()
|
|
116
|
+
|
|
117
|
+
render(
|
|
118
|
+
<TrackClick eventName="test-event" onClick={onClick}>
|
|
119
|
+
<button>Click me</button>
|
|
120
|
+
</TrackClick>,
|
|
121
|
+
{ wrapper },
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
fireEvent.click(screen.getByText('Click me'))
|
|
125
|
+
|
|
126
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
127
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should render with custom element type', () => {
|
|
131
|
+
render(
|
|
132
|
+
<TrackClick eventName="test-event" as="span">
|
|
133
|
+
<button>Click me</button>
|
|
134
|
+
</TrackClick>,
|
|
135
|
+
{ wrapper },
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const wrapperElement = screen.getByText('Click me').parentElement
|
|
139
|
+
expect(wrapperElement?.tagName).toBe('SPAN')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should render with default div wrapper', () => {
|
|
143
|
+
render(
|
|
144
|
+
<TrackClick eventName="test-event">
|
|
145
|
+
<button>Click me</button>
|
|
146
|
+
</TrackClick>,
|
|
147
|
+
{ wrapper },
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const wrapperElement = screen.getByText('Click me').parentElement
|
|
151
|
+
expect(wrapperElement?.tagName).toBe('DIV')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should handle multiple children', () => {
|
|
155
|
+
render(
|
|
156
|
+
<TrackClick eventName="test-event">
|
|
157
|
+
<button>Button 1</button>
|
|
158
|
+
<button>Button 2</button>
|
|
159
|
+
</TrackClick>,
|
|
160
|
+
{ wrapper },
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(screen.getByText('Button 1')).toBeInTheDocument()
|
|
164
|
+
expect(screen.getByText('Button 2')).toBeInTheDocument()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should merge defaultEventOptions from provider', () => {
|
|
168
|
+
const customWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
169
|
+
<FathomProvider
|
|
170
|
+
client={mockClient}
|
|
171
|
+
defaultEventOptions={{ id: 'default-id' }}
|
|
172
|
+
>
|
|
173
|
+
{children}
|
|
174
|
+
</FathomProvider>
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
render(
|
|
178
|
+
<TrackClick eventName="test-event" value={100}>
|
|
179
|
+
<button>Click me</button>
|
|
180
|
+
</TrackClick>,
|
|
181
|
+
{ wrapper: customWrapper },
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
fireEvent.click(screen.getByText('Click me'))
|
|
185
|
+
|
|
186
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
|
|
187
|
+
id: 'default-id',
|
|
188
|
+
value: 100,
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { ElementType, MouseEvent, ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { EventOptions } from 'fathom-client'
|
|
5
|
+
|
|
6
|
+
import { useFathom } from '../hooks/useFathom'
|
|
7
|
+
|
|
8
|
+
export interface TrackClickProps extends EventOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Event name to track
|
|
11
|
+
*/
|
|
12
|
+
eventName: string
|
|
13
|
+
/**
|
|
14
|
+
* Child element(s) to wrap
|
|
15
|
+
*/
|
|
16
|
+
children: ReactNode
|
|
17
|
+
/**
|
|
18
|
+
* Whether to prevent default behavior
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
preventDefault?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Custom onClick handler (will be called before tracking)
|
|
24
|
+
*/
|
|
25
|
+
onClick?: (e: MouseEvent) => void
|
|
26
|
+
/**
|
|
27
|
+
* HTML element to render as wrapper
|
|
28
|
+
* @default 'div'
|
|
29
|
+
*/
|
|
30
|
+
as?: ElementType
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Component wrapper that automatically tracks clicks on its children
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* <TrackClick eventName="cta-clicked" id="hero-cta">
|
|
39
|
+
* <button>Get Started</button>
|
|
40
|
+
* </TrackClick>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export const TrackClick: React.FC<TrackClickProps> = ({
|
|
44
|
+
eventName,
|
|
45
|
+
children,
|
|
46
|
+
preventDefault = false,
|
|
47
|
+
onClick,
|
|
48
|
+
as: Component = 'div',
|
|
49
|
+
...eventOptions
|
|
50
|
+
}) => {
|
|
51
|
+
const { trackEvent } = useFathom()
|
|
52
|
+
|
|
53
|
+
const handleClick = (e: MouseEvent) => {
|
|
54
|
+
if (preventDefault) {
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
}
|
|
57
|
+
onClick?.(e)
|
|
58
|
+
trackEvent?.(eventName, eventOptions)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return <Component onClick={handleClick}>{children}</Component>
|
|
62
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
|
|
7
|
+
import { FathomProvider } from '../FathomProvider'
|
|
8
|
+
import { TrackPageview } from './TrackPageview'
|
|
9
|
+
|
|
10
|
+
describe('TrackPageview', () => {
|
|
11
|
+
const mockTrackPageview = vi.fn()
|
|
12
|
+
const mockClient = {
|
|
13
|
+
trackEvent: vi.fn(),
|
|
14
|
+
trackPageview: mockTrackPageview,
|
|
15
|
+
trackGoal: vi.fn(),
|
|
16
|
+
load: vi.fn(),
|
|
17
|
+
setSite: vi.fn(),
|
|
18
|
+
blockTrackingForMe: vi.fn(),
|
|
19
|
+
enableTrackingForMe: vi.fn(),
|
|
20
|
+
isTrackingEnabled: vi.fn(() => true),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
24
|
+
<FathomProvider client={mockClient}>{children}</FathomProvider>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should track pageview on mount', () => {
|
|
32
|
+
render(<TrackPageview />, { wrapper })
|
|
33
|
+
|
|
34
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
35
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should track pageview with options', () => {
|
|
39
|
+
render(<TrackPageview url="/test-page" />, { wrapper })
|
|
40
|
+
|
|
41
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
42
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({ url: '/test-page' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should render children', () => {
|
|
46
|
+
render(
|
|
47
|
+
<TrackPageview>
|
|
48
|
+
<div>Page content</div>
|
|
49
|
+
</TrackPageview>,
|
|
50
|
+
{ wrapper },
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(screen.getByText('Page content')).toBeInTheDocument()
|
|
54
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should render without children', () => {
|
|
58
|
+
const { container } = render(<TrackPageview />, { wrapper })
|
|
59
|
+
|
|
60
|
+
expect(container.firstChild).toBeNull()
|
|
61
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should only track once on mount', () => {
|
|
65
|
+
const { rerender } = render(<TrackPageview />, { wrapper })
|
|
66
|
+
|
|
67
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
68
|
+
|
|
69
|
+
rerender(<TrackPageview />)
|
|
70
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should merge defaultPageviewOptions from provider', () => {
|
|
74
|
+
const customWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
75
|
+
<FathomProvider
|
|
76
|
+
client={mockClient}
|
|
77
|
+
defaultPageviewOptions={{ url: '/default' }}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</FathomProvider>
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
render(<TrackPageview />, { wrapper: customWrapper })
|
|
84
|
+
|
|
85
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
86
|
+
// The component passes empty object, provider merges defaults internally
|
|
87
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({ url: '/default' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should override defaultPageviewOptions with props', () => {
|
|
91
|
+
const customWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
92
|
+
<FathomProvider
|
|
93
|
+
client={mockClient}
|
|
94
|
+
defaultPageviewOptions={{ url: '/default' }}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</FathomProvider>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
render(<TrackPageview url="/custom" />, { wrapper: customWrapper })
|
|
101
|
+
|
|
102
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
103
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({ url: '/custom' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should not track when used outside FathomProvider', () => {
|
|
107
|
+
render(<TrackPageview />)
|
|
108
|
+
|
|
109
|
+
expect(mockTrackPageview).not.toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { PageViewOptions } from 'fathom-client'
|
|
5
|
+
|
|
6
|
+
import { useFathom } from '../hooks/useFathom'
|
|
7
|
+
|
|
8
|
+
export interface TrackPageviewProps extends PageViewOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Child element(s) to render
|
|
11
|
+
*/
|
|
12
|
+
children?: ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Component that tracks a pageview when it mounts
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <TrackPageview url="/custom-page">
|
|
21
|
+
* <div>Page content</div>
|
|
22
|
+
* </TrackPageview>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export const TrackPageview: React.FC<TrackPageviewProps> = ({
|
|
26
|
+
children,
|
|
27
|
+
...pageviewOptions
|
|
28
|
+
}) => {
|
|
29
|
+
const { trackPageview } = useFathom()
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
trackPageview?.(pageviewOptions)
|
|
33
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
34
|
+
|
|
35
|
+
return <>{children}</>
|
|
36
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
6
|
+
|
|
7
|
+
import { FathomProvider } from '../FathomProvider'
|
|
8
|
+
import { TrackVisible } from './TrackVisible'
|
|
9
|
+
|
|
10
|
+
describe('TrackVisible', () => {
|
|
11
|
+
const mockTrackEvent = vi.fn()
|
|
12
|
+
const mockClient = {
|
|
13
|
+
trackEvent: mockTrackEvent,
|
|
14
|
+
trackPageview: vi.fn(),
|
|
15
|
+
trackGoal: vi.fn(),
|
|
16
|
+
load: vi.fn(),
|
|
17
|
+
setSite: vi.fn(),
|
|
18
|
+
blockTrackingForMe: vi.fn(),
|
|
19
|
+
enableTrackingForMe: vi.fn(),
|
|
20
|
+
isTrackingEnabled: vi.fn(() => true),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
24
|
+
<FathomProvider client={mockClient}>{children}</FathomProvider>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
let mockObserve: ReturnType<typeof vi.fn>
|
|
28
|
+
let mockDisconnect: ReturnType<typeof vi.fn>
|
|
29
|
+
let observerCallback: (entries: IntersectionObserverEntry[]) => void
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks()
|
|
33
|
+
mockObserve = vi.fn()
|
|
34
|
+
mockDisconnect = vi.fn()
|
|
35
|
+
|
|
36
|
+
global.IntersectionObserver = class MockIntersectionObserver {
|
|
37
|
+
observe = mockObserve
|
|
38
|
+
disconnect = mockDisconnect
|
|
39
|
+
unobserve = vi.fn()
|
|
40
|
+
|
|
41
|
+
constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
|
|
42
|
+
observerCallback = callback
|
|
43
|
+
}
|
|
44
|
+
} as any
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
delete (global as any).IntersectionObserver
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should render children', () => {
|
|
52
|
+
render(
|
|
53
|
+
<TrackVisible eventName="test-event">
|
|
54
|
+
<div>Visible content</div>
|
|
55
|
+
</TrackVisible>,
|
|
56
|
+
{ wrapper },
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(screen.getByText('Visible content')).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should create IntersectionObserver on mount', () => {
|
|
63
|
+
render(
|
|
64
|
+
<TrackVisible eventName="test-event">
|
|
65
|
+
<div>Content</div>
|
|
66
|
+
</TrackVisible>,
|
|
67
|
+
{ wrapper },
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(mockObserve).toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should track event when element becomes visible', async () => {
|
|
74
|
+
render(
|
|
75
|
+
<TrackVisible eventName="test-event">
|
|
76
|
+
<div>Content</div>
|
|
77
|
+
</TrackVisible>,
|
|
78
|
+
{ wrapper },
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// Wait for IntersectionObserver to be created
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(mockObserve).toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const element = screen.getByText('Content').parentElement
|
|
87
|
+
const mockEntry = {
|
|
88
|
+
isIntersecting: true,
|
|
89
|
+
target: element!,
|
|
90
|
+
} as IntersectionObserverEntry
|
|
91
|
+
|
|
92
|
+
observerCallback([mockEntry])
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
|
96
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should track event with options', async () => {
|
|
101
|
+
render(
|
|
102
|
+
<TrackVisible eventName="test-event" id="test-id" value={100}>
|
|
103
|
+
<div>Content</div>
|
|
104
|
+
</TrackVisible>,
|
|
105
|
+
{ wrapper },
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// Wait for IntersectionObserver to be created
|
|
109
|
+
await waitFor(() => {
|
|
110
|
+
expect(mockObserve).toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const element = screen.getByText('Content').parentElement
|
|
114
|
+
const mockEntry = {
|
|
115
|
+
isIntersecting: true,
|
|
116
|
+
target: element!,
|
|
117
|
+
} as IntersectionObserverEntry
|
|
118
|
+
|
|
119
|
+
observerCallback([mockEntry])
|
|
120
|
+
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
|
|
123
|
+
id: 'test-id',
|
|
124
|
+
value: 100,
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should track only once when trackOnce is true', async () => {
|
|
130
|
+
render(
|
|
131
|
+
<TrackVisible eventName="test-event" trackOnce>
|
|
132
|
+
<div>Content</div>
|
|
133
|
+
</TrackVisible>,
|
|
134
|
+
{ wrapper },
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Wait for IntersectionObserver to be created
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(mockObserve).toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const element = screen.getByText('Content').parentElement
|
|
143
|
+
const mockEntry = {
|
|
144
|
+
isIntersecting: true,
|
|
145
|
+
target: element!,
|
|
146
|
+
} as IntersectionObserverEntry
|
|
147
|
+
|
|
148
|
+
observerCallback([mockEntry])
|
|
149
|
+
observerCallback([mockEntry])
|
|
150
|
+
observerCallback([mockEntry])
|
|
151
|
+
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should track multiple times when trackOnce is false', async () => {
|
|
158
|
+
render(
|
|
159
|
+
<TrackVisible eventName="test-event" trackOnce={false}>
|
|
160
|
+
<div>Content</div>
|
|
161
|
+
</TrackVisible>,
|
|
162
|
+
{ wrapper },
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// Wait for IntersectionObserver to be created
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(mockObserve).toHaveBeenCalled()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const element = screen.getByText('Content').parentElement
|
|
171
|
+
const mockEntry = {
|
|
172
|
+
isIntersecting: true,
|
|
173
|
+
target: element!,
|
|
174
|
+
} as IntersectionObserverEntry
|
|
175
|
+
|
|
176
|
+
observerCallback([mockEntry])
|
|
177
|
+
observerCallback([mockEntry])
|
|
178
|
+
observerCallback([mockEntry])
|
|
179
|
+
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
expect(mockTrackEvent).toHaveBeenCalledTimes(3)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('should use custom observer options', async () => {
|
|
186
|
+
const observerOptions = { threshold: 0.5 }
|
|
187
|
+
let capturedOptions: IntersectionObserverInit | undefined
|
|
188
|
+
|
|
189
|
+
const OriginalObserver = global.IntersectionObserver
|
|
190
|
+
global.IntersectionObserver = class MockIntersectionObserver {
|
|
191
|
+
observe = mockObserve
|
|
192
|
+
disconnect = mockDisconnect
|
|
193
|
+
unobserve = vi.fn()
|
|
194
|
+
|
|
195
|
+
constructor(
|
|
196
|
+
callback: (entries: IntersectionObserverEntry[]) => void,
|
|
197
|
+
options?: IntersectionObserverInit,
|
|
198
|
+
) {
|
|
199
|
+
capturedOptions = options
|
|
200
|
+
observerCallback = callback
|
|
201
|
+
}
|
|
202
|
+
} as any
|
|
203
|
+
|
|
204
|
+
render(
|
|
205
|
+
<TrackVisible eventName="test-event" observerOptions={observerOptions}>
|
|
206
|
+
<div>Content</div>
|
|
207
|
+
</TrackVisible>,
|
|
208
|
+
{ wrapper },
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
await waitFor(() => {
|
|
212
|
+
expect(capturedOptions?.threshold).toBe(0.5)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
global.IntersectionObserver = OriginalObserver
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('should render with custom element type', () => {
|
|
219
|
+
render(
|
|
220
|
+
<TrackVisible eventName="test-event" as="section">
|
|
221
|
+
<div>Content</div>
|
|
222
|
+
</TrackVisible>,
|
|
223
|
+
{ wrapper },
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const wrapperElement = screen.getByText('Content').parentElement
|
|
227
|
+
expect(wrapperElement?.tagName).toBe('SECTION')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should render with default div wrapper', () => {
|
|
231
|
+
render(
|
|
232
|
+
<TrackVisible eventName="test-event">
|
|
233
|
+
<div>Content</div>
|
|
234
|
+
</TrackVisible>,
|
|
235
|
+
{ wrapper },
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const wrapperElement = screen.getByText('Content').parentElement
|
|
239
|
+
expect(wrapperElement?.tagName).toBe('DIV')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('should forward ref', () => {
|
|
243
|
+
const ref = { current: null }
|
|
244
|
+
|
|
245
|
+
render(
|
|
246
|
+
<TrackVisible eventName="test-event" ref={ref}>
|
|
247
|
+
<div>Content</div>
|
|
248
|
+
</TrackVisible>,
|
|
249
|
+
{ wrapper },
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
expect(ref.current).not.toBeNull()
|
|
253
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should disconnect observer on unmount', async () => {
|
|
257
|
+
const { unmount } = render(
|
|
258
|
+
<TrackVisible eventName="test-event">
|
|
259
|
+
<div>Content</div>
|
|
260
|
+
</TrackVisible>,
|
|
261
|
+
{ wrapper },
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
// Wait for IntersectionObserver to be created
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(mockObserve).toHaveBeenCalled()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
unmount()
|
|
270
|
+
|
|
271
|
+
expect(mockDisconnect).toHaveBeenCalledTimes(1)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should merge defaultEventOptions from provider', async () => {
|
|
275
|
+
const customWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
276
|
+
<FathomProvider
|
|
277
|
+
client={mockClient}
|
|
278
|
+
defaultEventOptions={{ id: 'default-id' }}
|
|
279
|
+
>
|
|
280
|
+
{children}
|
|
281
|
+
</FathomProvider>
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
render(
|
|
285
|
+
<TrackVisible eventName="test-event" value={100}>
|
|
286
|
+
<div>Content</div>
|
|
287
|
+
</TrackVisible>,
|
|
288
|
+
{ wrapper: customWrapper },
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
// Wait for IntersectionObserver to be created
|
|
292
|
+
await waitFor(() => {
|
|
293
|
+
expect(mockObserve).toHaveBeenCalled()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const element = screen.getByText('Content').parentElement
|
|
297
|
+
const mockEntry = {
|
|
298
|
+
isIntersecting: true,
|
|
299
|
+
target: element!,
|
|
300
|
+
} as IntersectionObserverEntry
|
|
301
|
+
|
|
302
|
+
observerCallback([mockEntry])
|
|
303
|
+
|
|
304
|
+
await waitFor(() => {
|
|
305
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
|
|
306
|
+
id: 'default-id',
|
|
307
|
+
value: 100,
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
})
|