@posthog/react 1.2.1 → 1.2.3
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/package.json +4 -4
- package/src/components/PostHogErrorBoundary.tsx +88 -0
- package/src/components/PostHogFeature.tsx +174 -0
- package/src/components/__tests__/PostHogErrorBoundary.test.jsx +109 -0
- package/src/components/__tests__/PostHogFeature.test.jsx +298 -0
- package/src/components/index.ts +6 -0
- package/src/context/PostHogContext.ts +6 -0
- package/src/context/PostHogProvider.tsx +128 -0
- package/src/context/__tests__/PostHogContext.test.jsx +34 -0
- package/src/context/__tests__/PostHogProvider.test.jsx +131 -0
- package/src/context/index.ts +2 -0
- package/src/helpers/error-helpers.ts +15 -0
- package/src/helpers/index.ts +1 -0
- package/src/hooks/__tests__/featureFlags.test.jsx +93 -0
- package/src/hooks/__tests__/usePostHog.test.jsx +23 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useActiveFeatureFlags.ts +16 -0
- package/src/hooks/useFeatureFlagEnabled.ts +16 -0
- package/src/hooks/useFeatureFlagPayload.ts +17 -0
- package/src/hooks/useFeatureFlagVariantKey.ts +18 -0
- package/src/hooks/usePostHog.ts +7 -0
- package/src/index.ts +4 -0
- package/src/utils/__tests__/object-utils.test.ts +42 -0
- package/src/utils/object-utils.ts +36 -0
- package/src/utils/type-utils.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/react",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Provides components and hooks for React integrations of PostHog.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"module": "dist/esm/index.js",
|
|
15
15
|
"types": "dist/types/index.d.ts",
|
|
16
16
|
"files": [
|
|
17
|
-
"dist
|
|
18
|
-
"
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
19
|
],
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@types/react": ">=16.8.0",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"tslib": "^2.5.0",
|
|
49
49
|
"typescript": "^5.5.4",
|
|
50
50
|
"@posthog-tooling/rollup-utils": "1.0.0",
|
|
51
|
-
"posthog-js": "1.
|
|
51
|
+
"posthog-js": "1.270.1"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"clean": "rimraf dist",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { FunctionComponent } from 'react'
|
|
2
|
+
import { PostHogContext } from '../context'
|
|
3
|
+
import { isFunction } from '../utils/type-utils'
|
|
4
|
+
|
|
5
|
+
export type Properties = Record<string, any>
|
|
6
|
+
|
|
7
|
+
export type PostHogErrorBoundaryFallbackProps = {
|
|
8
|
+
error: unknown
|
|
9
|
+
exceptionEvent: unknown
|
|
10
|
+
componentStack: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PostHogErrorBoundaryProps = {
|
|
14
|
+
children?: React.ReactNode | (() => React.ReactNode)
|
|
15
|
+
fallback?: React.ReactNode | FunctionComponent<PostHogErrorBoundaryFallbackProps>
|
|
16
|
+
additionalProperties?: Properties | ((error: unknown) => Properties)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type PostHogErrorBoundaryState = {
|
|
20
|
+
componentStack: string | null
|
|
21
|
+
exceptionEvent: unknown
|
|
22
|
+
error: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INITIAL_STATE: PostHogErrorBoundaryState = {
|
|
26
|
+
componentStack: null,
|
|
27
|
+
exceptionEvent: null,
|
|
28
|
+
error: null,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const __POSTHOG_ERROR_MESSAGES = {
|
|
32
|
+
INVALID_FALLBACK:
|
|
33
|
+
'[PostHog.js][PostHogErrorBoundary] Invalid fallback prop, provide a valid React element or a function that returns a valid React element.',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class PostHogErrorBoundary extends React.Component<PostHogErrorBoundaryProps, PostHogErrorBoundaryState> {
|
|
37
|
+
static contextType = PostHogContext
|
|
38
|
+
|
|
39
|
+
constructor(props: PostHogErrorBoundaryProps) {
|
|
40
|
+
super(props)
|
|
41
|
+
this.state = INITIAL_STATE
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) {
|
|
45
|
+
//eslint-disable-next-line react/prop-types
|
|
46
|
+
const { additionalProperties } = this.props
|
|
47
|
+
let currentProperties
|
|
48
|
+
if (isFunction(additionalProperties)) {
|
|
49
|
+
currentProperties = additionalProperties(error)
|
|
50
|
+
} else if (typeof additionalProperties === 'object') {
|
|
51
|
+
currentProperties = additionalProperties
|
|
52
|
+
}
|
|
53
|
+
const { client } = this.context
|
|
54
|
+
const exceptionEvent = client.captureException(error, currentProperties)
|
|
55
|
+
|
|
56
|
+
const { componentStack } = errorInfo
|
|
57
|
+
this.setState({
|
|
58
|
+
error,
|
|
59
|
+
componentStack,
|
|
60
|
+
exceptionEvent,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public render(): React.ReactNode {
|
|
65
|
+
//eslint-disable-next-line react/prop-types
|
|
66
|
+
const { children, fallback } = this.props
|
|
67
|
+
const state = this.state
|
|
68
|
+
|
|
69
|
+
if (state.componentStack == null) {
|
|
70
|
+
return isFunction(children) ? children() : children
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const element = isFunction(fallback)
|
|
74
|
+
? (React.createElement(fallback, {
|
|
75
|
+
error: state.error,
|
|
76
|
+
componentStack: state.componentStack,
|
|
77
|
+
exceptionEvent: state.exceptionEvent,
|
|
78
|
+
}) as React.ReactNode)
|
|
79
|
+
: fallback
|
|
80
|
+
|
|
81
|
+
if (React.isValidElement(element)) {
|
|
82
|
+
return element as React.ReactElement
|
|
83
|
+
}
|
|
84
|
+
//eslint-disable-next-line no-console
|
|
85
|
+
console.warn(__POSTHOG_ERROR_MESSAGES.INVALID_FALLBACK)
|
|
86
|
+
return <></>
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useFeatureFlagPayload, useFeatureFlagVariantKey, usePostHog } from '../hooks'
|
|
2
|
+
import React, { Children, ReactNode, useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { PostHog } from '../context'
|
|
4
|
+
import { isFunction, isNull, isUndefined } from '../utils/type-utils'
|
|
5
|
+
|
|
6
|
+
export type PostHogFeatureProps = React.HTMLProps<HTMLDivElement> & {
|
|
7
|
+
flag: string
|
|
8
|
+
children: React.ReactNode | ((payload: any) => React.ReactNode)
|
|
9
|
+
fallback?: React.ReactNode
|
|
10
|
+
match?: string | boolean
|
|
11
|
+
visibilityObserverOptions?: IntersectionObserverInit
|
|
12
|
+
trackInteraction?: boolean
|
|
13
|
+
trackView?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PostHogFeature({
|
|
17
|
+
flag,
|
|
18
|
+
match,
|
|
19
|
+
children,
|
|
20
|
+
fallback,
|
|
21
|
+
visibilityObserverOptions,
|
|
22
|
+
trackInteraction,
|
|
23
|
+
trackView,
|
|
24
|
+
...props
|
|
25
|
+
}: PostHogFeatureProps): JSX.Element | null {
|
|
26
|
+
const payload = useFeatureFlagPayload(flag)
|
|
27
|
+
const variant = useFeatureFlagVariantKey(flag)
|
|
28
|
+
|
|
29
|
+
const shouldTrackInteraction = trackInteraction ?? true
|
|
30
|
+
const shouldTrackView = trackView ?? true
|
|
31
|
+
|
|
32
|
+
if (isUndefined(match) || variant === match) {
|
|
33
|
+
const childNode: React.ReactNode = isFunction(children) ? children(payload) : children
|
|
34
|
+
return (
|
|
35
|
+
<VisibilityAndClickTrackers
|
|
36
|
+
flag={flag}
|
|
37
|
+
options={visibilityObserverOptions}
|
|
38
|
+
trackInteraction={shouldTrackInteraction}
|
|
39
|
+
trackView={shouldTrackView}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
{childNode}
|
|
43
|
+
</VisibilityAndClickTrackers>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return <>{fallback}</>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function captureFeatureInteraction({
|
|
50
|
+
flag,
|
|
51
|
+
posthog,
|
|
52
|
+
flagVariant,
|
|
53
|
+
}: {
|
|
54
|
+
flag: string
|
|
55
|
+
posthog: PostHog
|
|
56
|
+
flagVariant?: string | boolean
|
|
57
|
+
}) {
|
|
58
|
+
const properties: Record<string, any> = {
|
|
59
|
+
feature_flag: flag,
|
|
60
|
+
$set: { [`$feature_interaction/${flag}`]: flagVariant ?? true },
|
|
61
|
+
}
|
|
62
|
+
if (typeof flagVariant === 'string') {
|
|
63
|
+
properties.feature_flag_variant = flagVariant
|
|
64
|
+
}
|
|
65
|
+
posthog.capture('$feature_interaction', properties)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function captureFeatureView({
|
|
69
|
+
flag,
|
|
70
|
+
posthog,
|
|
71
|
+
flagVariant,
|
|
72
|
+
}: {
|
|
73
|
+
flag: string
|
|
74
|
+
posthog: PostHog
|
|
75
|
+
flagVariant?: string | boolean
|
|
76
|
+
}) {
|
|
77
|
+
const properties: Record<string, any> = {
|
|
78
|
+
feature_flag: flag,
|
|
79
|
+
$set: { [`$feature_view/${flag}`]: flagVariant ?? true },
|
|
80
|
+
}
|
|
81
|
+
if (typeof flagVariant === 'string') {
|
|
82
|
+
properties.feature_flag_variant = flagVariant
|
|
83
|
+
}
|
|
84
|
+
posthog.capture('$feature_view', properties)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function VisibilityAndClickTracker({
|
|
88
|
+
flag,
|
|
89
|
+
children,
|
|
90
|
+
onIntersect,
|
|
91
|
+
onClick,
|
|
92
|
+
trackView,
|
|
93
|
+
options,
|
|
94
|
+
...props
|
|
95
|
+
}: {
|
|
96
|
+
flag: string
|
|
97
|
+
children: React.ReactNode
|
|
98
|
+
onIntersect: (entry: IntersectionObserverEntry) => void
|
|
99
|
+
onClick: () => void
|
|
100
|
+
trackView: boolean
|
|
101
|
+
options?: IntersectionObserverInit
|
|
102
|
+
}): JSX.Element {
|
|
103
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
104
|
+
const posthog = usePostHog()
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (isNull(ref.current) || !trackView) return
|
|
108
|
+
|
|
109
|
+
// eslint-disable-next-line compat/compat
|
|
110
|
+
const observer = new IntersectionObserver(([entry]) => onIntersect(entry), {
|
|
111
|
+
threshold: 0.1,
|
|
112
|
+
...options,
|
|
113
|
+
})
|
|
114
|
+
observer.observe(ref.current)
|
|
115
|
+
return () => observer.disconnect()
|
|
116
|
+
}, [flag, options, posthog, ref, trackView, onIntersect])
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div ref={ref} {...props} onClick={onClick}>
|
|
120
|
+
{children}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function VisibilityAndClickTrackers({
|
|
126
|
+
flag,
|
|
127
|
+
children,
|
|
128
|
+
trackInteraction,
|
|
129
|
+
trackView,
|
|
130
|
+
options,
|
|
131
|
+
...props
|
|
132
|
+
}: {
|
|
133
|
+
flag: string
|
|
134
|
+
children: React.ReactNode
|
|
135
|
+
trackInteraction: boolean
|
|
136
|
+
trackView: boolean
|
|
137
|
+
options?: IntersectionObserverInit
|
|
138
|
+
}): JSX.Element {
|
|
139
|
+
const clickTrackedRef = useRef(false)
|
|
140
|
+
const visibilityTrackedRef = useRef(false)
|
|
141
|
+
const posthog = usePostHog()
|
|
142
|
+
const variant = useFeatureFlagVariantKey(flag)
|
|
143
|
+
|
|
144
|
+
const cachedOnClick = useCallback(() => {
|
|
145
|
+
if (!clickTrackedRef.current && trackInteraction) {
|
|
146
|
+
captureFeatureInteraction({ flag, posthog, flagVariant: variant })
|
|
147
|
+
clickTrackedRef.current = true
|
|
148
|
+
}
|
|
149
|
+
}, [flag, posthog, trackInteraction, variant])
|
|
150
|
+
|
|
151
|
+
const onIntersect = (entry: IntersectionObserverEntry) => {
|
|
152
|
+
if (!visibilityTrackedRef.current && entry.isIntersecting) {
|
|
153
|
+
captureFeatureView({ flag, posthog, flagVariant: variant })
|
|
154
|
+
visibilityTrackedRef.current = true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const trackedChildren = Children.map(children, (child: ReactNode) => {
|
|
159
|
+
return (
|
|
160
|
+
<VisibilityAndClickTracker
|
|
161
|
+
flag={flag}
|
|
162
|
+
onClick={cachedOnClick}
|
|
163
|
+
onIntersect={onIntersect}
|
|
164
|
+
trackView={trackView}
|
|
165
|
+
options={options}
|
|
166
|
+
{...props}
|
|
167
|
+
>
|
|
168
|
+
{child}
|
|
169
|
+
</VisibilityAndClickTracker>
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return <>{trackedChildren}</>
|
|
174
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { render } from '@testing-library/react'
|
|
5
|
+
import { __POSTHOG_ERROR_MESSAGES, PostHogErrorBoundary } from '../PostHogErrorBoundary'
|
|
6
|
+
import posthog from 'posthog-js'
|
|
7
|
+
|
|
8
|
+
describe('PostHogErrorBoundary component', () => {
|
|
9
|
+
mockFunction(console, 'error')
|
|
10
|
+
mockFunction(console, 'warn')
|
|
11
|
+
mockFunction(posthog, 'captureException')
|
|
12
|
+
|
|
13
|
+
given('render_with_error', () => (props) => render(<RenderWithError {...props} />))
|
|
14
|
+
given('render_without_error', () => (props) => render(<RenderWithoutError {...props} />))
|
|
15
|
+
|
|
16
|
+
it('should call captureException with error message', () => {
|
|
17
|
+
const { container } = given.render_with_error({ message: 'Test error', fallback: <div></div> })
|
|
18
|
+
expect(posthog.captureException).toHaveBeenCalledWith(new Error('Test error'), undefined)
|
|
19
|
+
expect(container.innerHTML).toBe('<div></div>')
|
|
20
|
+
expect(console.error).toHaveBeenCalledTimes(2)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should warn user when fallback is null', () => {
|
|
24
|
+
const { container } = given.render_with_error({ fallback: null })
|
|
25
|
+
expect(posthog.captureException).toHaveBeenCalledWith(new Error('Error'), undefined)
|
|
26
|
+
expect(container.innerHTML).toBe('')
|
|
27
|
+
expect(console.warn).toHaveBeenCalledWith(__POSTHOG_ERROR_MESSAGES.INVALID_FALLBACK)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should warn user when fallback is a string', () => {
|
|
31
|
+
const { container } = given.render_with_error({ fallback: 'hello' })
|
|
32
|
+
expect(posthog.captureException).toHaveBeenCalledWith(new Error('Error'), undefined)
|
|
33
|
+
expect(container.innerHTML).toBe('')
|
|
34
|
+
expect(console.warn).toHaveBeenCalledWith(__POSTHOG_ERROR_MESSAGES.INVALID_FALLBACK)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should add additional properties before sending event (as object)', () => {
|
|
38
|
+
const props = { team_id: '1234' }
|
|
39
|
+
given.render_with_error({ message: 'Kaboom', additionalProperties: props })
|
|
40
|
+
expect(posthog.captureException).toHaveBeenCalledWith(new Error('Kaboom'), props)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should add additional properties before sending event (as function)', () => {
|
|
44
|
+
const props = { team_id: '1234' }
|
|
45
|
+
given.render_with_error({
|
|
46
|
+
message: 'Kaboom',
|
|
47
|
+
additionalProperties: (err) => {
|
|
48
|
+
expect(err.message).toBe('Kaboom')
|
|
49
|
+
return props
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
expect(posthog.captureException).toHaveBeenCalledWith(new Error('Kaboom'), props)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should render children without errors', () => {
|
|
56
|
+
const { container } = given.render_without_error()
|
|
57
|
+
expect(container.innerHTML).toBe('<div>Amazing content</div>')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('captureException processing', () => {
|
|
62
|
+
mockFunction(console, 'error')
|
|
63
|
+
mockFunction(console, 'warn')
|
|
64
|
+
mockFunction(posthog, 'capture')
|
|
65
|
+
|
|
66
|
+
given('render_with_error', () => (props) => render(<RenderWithError {...props} />))
|
|
67
|
+
|
|
68
|
+
it('should call capture with a stacktrace', () => {
|
|
69
|
+
given.render_with_error({ message: 'Kaboom', fallback: <div></div>, additionalProperties: {} })
|
|
70
|
+
const captureCalls = posthog.capture.mock.calls
|
|
71
|
+
expect(captureCalls.length).toBe(1)
|
|
72
|
+
const exceptionList = captureCalls[0][1].$exception_list
|
|
73
|
+
expect(exceptionList.length).toBe(1)
|
|
74
|
+
const stacktrace = exceptionList[0].stacktrace
|
|
75
|
+
expect(stacktrace.frames.length).toBe(44)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
function mockFunction(object, funcName) {
|
|
80
|
+
const originalFunc = object[funcName]
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
object[funcName] = jest.fn()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
object[funcName] = originalFunc
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ComponentWithError({ message }) {
|
|
92
|
+
throw new Error(message)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function RenderWithError({ message = 'Error', fallback, additionalProperties }) {
|
|
96
|
+
return (
|
|
97
|
+
<PostHogErrorBoundary fallback={fallback} additionalProperties={additionalProperties}>
|
|
98
|
+
<ComponentWithError message={message} />
|
|
99
|
+
</PostHogErrorBoundary>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function RenderWithoutError({ additionalProperties }) {
|
|
104
|
+
return (
|
|
105
|
+
<PostHogErrorBoundary fallback={<div></div>} additionalProperties={additionalProperties}>
|
|
106
|
+
<div>Amazing content</div>
|
|
107
|
+
</PostHogErrorBoundary>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import { PostHogProvider } from '../../context'
|
|
5
|
+
import { PostHogFeature } from '../'
|
|
6
|
+
import '@testing-library/jest-dom'
|
|
7
|
+
|
|
8
|
+
const FEATURE_FLAG_STATUS = {
|
|
9
|
+
multivariate_feature: 'string-value',
|
|
10
|
+
example_feature_payload: 'test',
|
|
11
|
+
test: true,
|
|
12
|
+
test_false: false,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FEATURE_FLAG_PAYLOADS = {
|
|
16
|
+
example_feature_payload: {
|
|
17
|
+
id: 1,
|
|
18
|
+
name: 'example_feature_1_payload',
|
|
19
|
+
key: 'example_feature_1_payload',
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('PostHogFeature component', () => {
|
|
24
|
+
given('featureFlag', () => 'test')
|
|
25
|
+
given('matchValue', () => true)
|
|
26
|
+
given(
|
|
27
|
+
'render',
|
|
28
|
+
() => () =>
|
|
29
|
+
render(
|
|
30
|
+
<PostHogProvider client={given.posthog}>
|
|
31
|
+
<PostHogFeature flag={given.featureFlag} match={given.matchValue}>
|
|
32
|
+
<div data-testid="helloDiv">Hello</div>
|
|
33
|
+
</PostHogFeature>
|
|
34
|
+
</PostHogProvider>
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
given('posthog', () => ({
|
|
38
|
+
isFeatureEnabled: (flag) => !!FEATURE_FLAG_STATUS[flag],
|
|
39
|
+
getFeatureFlag: (flag) => FEATURE_FLAG_STATUS[flag],
|
|
40
|
+
getFeatureFlagPayload: (flag) => FEATURE_FLAG_PAYLOADS[flag],
|
|
41
|
+
onFeatureFlags: (callback) => {
|
|
42
|
+
const activeFlags = []
|
|
43
|
+
for (const flag in FEATURE_FLAG_STATUS) {
|
|
44
|
+
if (FEATURE_FLAG_STATUS[flag]) {
|
|
45
|
+
activeFlags.push(flag)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
callback(activeFlags)
|
|
49
|
+
return () => {}
|
|
50
|
+
},
|
|
51
|
+
capture: jest.fn(),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
// IntersectionObserver isn't available in test environment
|
|
56
|
+
const mockIntersectionObserver = jest.fn()
|
|
57
|
+
mockIntersectionObserver.mockReturnValue({
|
|
58
|
+
observe: () => null,
|
|
59
|
+
unobserve: () => null,
|
|
60
|
+
disconnect: () => null,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// eslint-disable-next-line compat/compat
|
|
64
|
+
window.IntersectionObserver = mockIntersectionObserver
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should track interactions with the feature component', () => {
|
|
68
|
+
given.render()
|
|
69
|
+
|
|
70
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
71
|
+
expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
72
|
+
feature_flag: 'test',
|
|
73
|
+
$set: { '$feature_interaction/test': true },
|
|
74
|
+
})
|
|
75
|
+
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should not fire for every interaction with the feature component', () => {
|
|
79
|
+
given.render()
|
|
80
|
+
|
|
81
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
82
|
+
expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
83
|
+
feature_flag: 'test',
|
|
84
|
+
$set: { '$feature_interaction/test': true },
|
|
85
|
+
})
|
|
86
|
+
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
|
|
87
|
+
|
|
88
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
89
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
90
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
91
|
+
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should track an interaction with each child node of the feature component', () => {
|
|
95
|
+
given(
|
|
96
|
+
'render',
|
|
97
|
+
() => () =>
|
|
98
|
+
render(
|
|
99
|
+
<PostHogProvider client={given.posthog}>
|
|
100
|
+
<PostHogFeature flag={given.featureFlag} match={given.matchValue}>
|
|
101
|
+
<div data-testid="helloDiv">Hello</div>
|
|
102
|
+
<div data-testid="worldDiv">World!</div>
|
|
103
|
+
</PostHogFeature>
|
|
104
|
+
</PostHogProvider>
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
given.render()
|
|
108
|
+
|
|
109
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
110
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
111
|
+
fireEvent.click(screen.getByTestId('worldDiv'))
|
|
112
|
+
fireEvent.click(screen.getByTestId('worldDiv'))
|
|
113
|
+
fireEvent.click(screen.getByTestId('worldDiv'))
|
|
114
|
+
expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
115
|
+
feature_flag: 'test',
|
|
116
|
+
$set: { '$feature_interaction/test': true },
|
|
117
|
+
})
|
|
118
|
+
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should not fire events when interaction is disabled', () => {
|
|
122
|
+
given(
|
|
123
|
+
'render',
|
|
124
|
+
() => () =>
|
|
125
|
+
render(
|
|
126
|
+
<PostHogProvider client={given.posthog}>
|
|
127
|
+
<PostHogFeature flag={given.featureFlag} match={given.matchValue} trackInteraction={false}>
|
|
128
|
+
<div data-testid="helloDiv">Hello</div>
|
|
129
|
+
</PostHogFeature>
|
|
130
|
+
</PostHogProvider>
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
given.render()
|
|
134
|
+
|
|
135
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
136
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
137
|
+
|
|
138
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
139
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
140
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
141
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should fire events when interaction is disabled but re-enabled after', () => {
|
|
145
|
+
const DynamicUpdateComponent = () => {
|
|
146
|
+
const [trackInteraction, setTrackInteraction] = useState(false)
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
<div
|
|
151
|
+
data-testid="clicker"
|
|
152
|
+
onClick={() => {
|
|
153
|
+
setTrackInteraction(true)
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
Click me
|
|
157
|
+
</div>
|
|
158
|
+
<PostHogFeature
|
|
159
|
+
flag={given.featureFlag}
|
|
160
|
+
match={given.matchValue}
|
|
161
|
+
trackInteraction={trackInteraction}
|
|
162
|
+
>
|
|
163
|
+
<div data-testid="helloDiv">Hello</div>
|
|
164
|
+
</PostHogFeature>
|
|
165
|
+
</>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
given(
|
|
170
|
+
'render',
|
|
171
|
+
() => () =>
|
|
172
|
+
render(
|
|
173
|
+
<PostHogProvider client={given.posthog}>
|
|
174
|
+
<DynamicUpdateComponent />
|
|
175
|
+
</PostHogProvider>
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
given.render()
|
|
179
|
+
|
|
180
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
181
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
182
|
+
|
|
183
|
+
fireEvent.click(screen.getByTestId('clicker'))
|
|
184
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
185
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
186
|
+
expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
187
|
+
feature_flag: 'test',
|
|
188
|
+
$set: { '$feature_interaction/test': true },
|
|
189
|
+
})
|
|
190
|
+
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('should not show the feature component if the flag is not enabled', () => {
|
|
194
|
+
given('featureFlag', () => 'test_false')
|
|
195
|
+
given.render()
|
|
196
|
+
|
|
197
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
198
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
199
|
+
|
|
200
|
+
// check if any elements are found
|
|
201
|
+
const allTags = screen.queryAllByText(/.*/)
|
|
202
|
+
|
|
203
|
+
// Assert that no random elements are found
|
|
204
|
+
expect(allTags.length).toEqual(2)
|
|
205
|
+
expect(allTags[0].tagName).toEqual('BODY')
|
|
206
|
+
expect(allTags[1].tagName).toEqual('DIV')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should fallback when provided', () => {
|
|
210
|
+
given('featureFlag', () => 'test_false')
|
|
211
|
+
given(
|
|
212
|
+
'render',
|
|
213
|
+
() => () =>
|
|
214
|
+
render(
|
|
215
|
+
<PostHogProvider client={given.posthog}>
|
|
216
|
+
<PostHogFeature
|
|
217
|
+
flag={given.featureFlag}
|
|
218
|
+
match={given.matchValue}
|
|
219
|
+
fallback={<div data-testid="nope">Nope</div>}
|
|
220
|
+
>
|
|
221
|
+
<div data-testid="helloDiv">Hello</div>
|
|
222
|
+
</PostHogFeature>
|
|
223
|
+
</PostHogProvider>
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
given.render()
|
|
227
|
+
|
|
228
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
229
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
230
|
+
|
|
231
|
+
fireEvent.click(screen.getByTestId('nope'))
|
|
232
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should handle showing multivariate flags with bool match', () => {
|
|
236
|
+
given('featureFlag', () => 'multivariate_feature')
|
|
237
|
+
given('matchValue', () => true)
|
|
238
|
+
|
|
239
|
+
given.render()
|
|
240
|
+
|
|
241
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
242
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should handle showing multivariate flags with incorrect match', () => {
|
|
246
|
+
given('featureFlag', () => 'multivariate_feature')
|
|
247
|
+
given('matchValue', () => 'string-valueCXCC')
|
|
248
|
+
|
|
249
|
+
given.render()
|
|
250
|
+
|
|
251
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
252
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('should handle showing multivariate flags', () => {
|
|
256
|
+
given('featureFlag', () => 'multivariate_feature')
|
|
257
|
+
given('matchValue', () => 'string-value')
|
|
258
|
+
|
|
259
|
+
given.render()
|
|
260
|
+
|
|
261
|
+
expect(screen.queryByTestId('helloDiv')).toBeInTheDocument()
|
|
262
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
263
|
+
|
|
264
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
265
|
+
expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
266
|
+
feature_flag: 'multivariate_feature',
|
|
267
|
+
feature_flag_variant: 'string-value',
|
|
268
|
+
$set: { '$feature_interaction/multivariate_feature': 'string-value' },
|
|
269
|
+
})
|
|
270
|
+
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('should handle payload flags', () => {
|
|
274
|
+
given('featureFlag', () => 'example_feature_payload')
|
|
275
|
+
given('matchValue', () => 'test')
|
|
276
|
+
given(
|
|
277
|
+
'render',
|
|
278
|
+
() => () =>
|
|
279
|
+
render(
|
|
280
|
+
<PostHogProvider client={given.posthog}>
|
|
281
|
+
<PostHogFeature flag={given.featureFlag} match={given.matchValue}>
|
|
282
|
+
{(payload) => {
|
|
283
|
+
return <div data-testid={`hi_${payload.name}`}>Hullo</div>
|
|
284
|
+
}}
|
|
285
|
+
</PostHogFeature>
|
|
286
|
+
</PostHogProvider>
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
given.render()
|
|
291
|
+
|
|
292
|
+
expect(screen.queryByTestId('hi_example_feature_1_payload')).toBeInTheDocument()
|
|
293
|
+
expect(given.posthog.capture).not.toHaveBeenCalled()
|
|
294
|
+
|
|
295
|
+
fireEvent.click(screen.getByTestId('hi_example_feature_1_payload'))
|
|
296
|
+
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
|
|
297
|
+
})
|
|
298
|
+
})
|