feat: Heatmaps toolbar code (#21630)
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
@ -209,6 +209,7 @@ export const FEATURE_FLAGS = {
|
||||
SESSION_REPLAY_MOBILE_ONBOARDING: 'session-replay-mobile-onboarding', // owner: #team-replay
|
||||
IP_ALLOWLIST_SETTING: 'ip-allowlist-setting', // owner: @benjackwhite
|
||||
EMAIL_VERIFICATION_TICKET_SUBMISSION: 'email-verification-ticket-submission', // owner: #team-growth
|
||||
TOOLBAR_HEATMAPS: 'toolbar-heatmaps', // owner: #team-replay
|
||||
THEME: 'theme', // owner: @aprilfools
|
||||
} as const
|
||||
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
|
||||
|
@ -43,6 +43,9 @@ export function loadPostHogJS(): void {
|
||||
capture_copied_text: true,
|
||||
},
|
||||
process_person: 'identified_only',
|
||||
|
||||
__preview_heatmaps: true,
|
||||
|
||||
// Helper to capture events for assertions in Cypress
|
||||
_onCapture: (window as any)._cypress_posthog_captures
|
||||
? (_, event) => (window as any)._cypress_posthog_captures.push(event)
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IconPencil, IconPlus, IconSearch, IconTrash, IconX } from '@posthog/icons'
|
||||
import { IconPencil, IconPlus, IconSearch, IconTrash } from '@posthog/icons'
|
||||
import { LemonDivider, LemonTag } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { Field, Form, Group } from 'kea-forms'
|
||||
@ -9,7 +9,7 @@ import { actionsTabLogic } from '~/toolbar/actions/actionsTabLogic'
|
||||
import { SelectorEditingModal } from '~/toolbar/actions/SelectorEditingModal'
|
||||
import { StepField } from '~/toolbar/actions/StepField'
|
||||
import { ToolbarMenu } from '~/toolbar/bar/ToolbarMenu'
|
||||
import { posthog } from '~/toolbar/posthog'
|
||||
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||
|
||||
export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
||||
const {
|
||||
@ -38,7 +38,7 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
||||
startingSelector={editingSelectorValue}
|
||||
onChange={(selector) => {
|
||||
if (selector && editingSelector !== null) {
|
||||
posthog.capture('toolbar_manual_selector_applied', {
|
||||
toolbarPosthogJS.capture('toolbar_manual_selector_applied', {
|
||||
chosenSelector: selector,
|
||||
})
|
||||
setElementSelector(selector, editingSelector)
|
||||
@ -52,7 +52,7 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
||||
enableFormOnSubmit
|
||||
className="flex flex-col overflow-hidden flex-1"
|
||||
>
|
||||
<ToolbarMenu.Header>
|
||||
<ToolbarMenu.Header className="border-b">
|
||||
<h1 className="p-1 font-bold text-sm mb-0">
|
||||
{selectedActionId === 'new' ? 'New ' : 'Edit '}
|
||||
action
|
||||
@ -124,9 +124,12 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
||||
icon={<IconPencil />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
posthog.capture('toolbar_manual_selector_modal_opened', {
|
||||
selector: step?.selector,
|
||||
})
|
||||
toolbarPosthogJS.capture(
|
||||
'toolbar_manual_selector_modal_opened',
|
||||
{
|
||||
selector: step?.selector,
|
||||
}
|
||||
)
|
||||
editSelectorWithIndex(index)
|
||||
}}
|
||||
>
|
||||
@ -198,22 +201,25 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
||||
</ToolbarMenu.Body>
|
||||
<ToolbarMenu.Footer>
|
||||
<span className="flex-1">
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => selectAction(null)}
|
||||
sideIcon={<IconX />}
|
||||
>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
{selectedActionId !== 'new' ? (
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
status="danger"
|
||||
onClick={deleteAction}
|
||||
icon={<IconTrash />}
|
||||
size="small"
|
||||
>
|
||||
Delete
|
||||
</LemonButton>
|
||||
) : null}
|
||||
</span>
|
||||
<LemonButton type="primary" htmlType="submit">
|
||||
<LemonButton type="secondary" size="small" onClick={() => selectAction(null)}>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton type="primary" htmlType="submit" size="small">
|
||||
{selectedActionId === 'new' ? 'Create ' : 'Save '}
|
||||
action
|
||||
</LemonButton>
|
||||
{selectedActionId !== 'new' ? (
|
||||
<LemonButton type="secondary" status="danger" onClick={deleteAction} icon={<IconTrash />} />
|
||||
) : null}
|
||||
</ToolbarMenu.Footer>
|
||||
</Form>
|
||||
</ToolbarMenu>
|
||||
|
@ -23,7 +23,7 @@ export function ActionsListView({ actions }: ActionsListViewProps): JSX.Element
|
||||
subtle
|
||||
key={action.id}
|
||||
onClick={() => selectAction(action.id || null)}
|
||||
className="font-medium my-1"
|
||||
className="font-medium my-1 w-full"
|
||||
>
|
||||
<span className="min-w-[2rem] inline-block text-left">{index + 1}.</span>
|
||||
<span className="flex-grow">
|
||||
|
@ -7,8 +7,8 @@ import { urls } from 'scenes/urls'
|
||||
|
||||
import { actionsLogic } from '~/toolbar/actions/actionsLogic'
|
||||
import { toolbarLogic } from '~/toolbar/bar/toolbarLogic'
|
||||
import { posthog } from '~/toolbar/posthog'
|
||||
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'
|
||||
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||
import { ActionDraftType, ActionForm } from '~/toolbar/types'
|
||||
import { actionStepToActionStepFormItem, elementToActionStep, stepToDatabaseFormat } from '~/toolbar/utils'
|
||||
import { ActionType, ElementType } from '~/types'
|
||||
@ -292,11 +292,11 @@ export const actionsTabLogic = kea<actionsTabLogicType>([
|
||||
}
|
||||
},
|
||||
showButtonActions: () => {
|
||||
posthog.capture('toolbar mode triggered', { mode: 'actions', enabled: true })
|
||||
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'actions', enabled: true })
|
||||
},
|
||||
hideButtonActions: () => {
|
||||
actions.setShowActionsTooltip(false)
|
||||
posthog.capture('toolbar mode triggered', { mode: 'actions', enabled: false })
|
||||
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'actions', enabled: false })
|
||||
},
|
||||
[actionsLogic.actionTypes.getActionsSuccess]: () => {
|
||||
const { userIntent, actionId } = values
|
||||
|
@ -1,15 +1,24 @@
|
||||
export function ToolbarMenu({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return <div className="w-full h-full flex flex-col overflow-hidden">{children}</div>
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type ToolbarMenuProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
ToolbarMenu.Header = function ToolbarMenuHeader({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return <div className="pt-1 px-1">{children}</div>
|
||||
export function ToolbarMenu({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||
return <div className={clsx('w-full h-full flex flex-col overflow-hidden', className)}>{children}</div>
|
||||
}
|
||||
|
||||
ToolbarMenu.Body = function ToolbarMenuBody({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return <div className="flex flex-col flex-1 h-full overflow-y-auto px-1 min-h-20">{children}</div>
|
||||
ToolbarMenu.Header = function ToolbarMenuHeader({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||
return <div className={clsx('pt-1 px-1', className)}>{children}</div>
|
||||
}
|
||||
|
||||
ToolbarMenu.Footer = function ToolbarMenufooter({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return <div className="flex flex-row items-center p-2 border-t">{children}</div>
|
||||
ToolbarMenu.Body = function ToolbarMenuBody({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||
return (
|
||||
<div className={clsx('flex flex-col flex-1 h-full overflow-y-auto px-1 min-h-20', className)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
ToolbarMenu.Footer = function ToolbarMenufooter({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||
return <div className={clsx('flex flex-row items-center p-2 border-t gap-2', className)}>{children}</div>
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ElementRect } from '~/toolbar/types'
|
||||
|
||||
interface HeatmapElementProps {
|
||||
interface AutocaptureElementProps {
|
||||
rect?: ElementRect
|
||||
style: Record<string, any>
|
||||
onClick: (event: React.MouseEvent) => void
|
||||
@ -8,13 +8,13 @@ interface HeatmapElementProps {
|
||||
onMouseOut: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function HeatmapElement({
|
||||
export function AutocaptureElement({
|
||||
rect,
|
||||
style = {},
|
||||
onClick,
|
||||
onMouseOver,
|
||||
onMouseOut,
|
||||
}: HeatmapElementProps): JSX.Element | null {
|
||||
}: AutocaptureElementProps): JSX.Element | null {
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
@ -12,18 +12,18 @@ const heatmapLabelStyle = {
|
||||
fontFamily: 'monospace',
|
||||
}
|
||||
|
||||
interface HeatmapLabelProps extends React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
|
||||
interface AutocaptureElementLabelProps extends React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
|
||||
rect?: ElementRect
|
||||
align?: 'left' | 'right'
|
||||
}
|
||||
|
||||
export function HeatmapLabel({
|
||||
export function AutocaptureElementLabel({
|
||||
rect,
|
||||
style = {},
|
||||
align = 'right',
|
||||
children,
|
||||
...props
|
||||
}: HeatmapLabelProps): JSX.Element | null {
|
||||
}: AutocaptureElementLabelProps): JSX.Element | null {
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
@ -81,7 +81,6 @@ export function ElementInfoWindow(): JSX.Element | null {
|
||||
transition: 'opacity 0.2s, box-shadow 0.2s',
|
||||
backgroundBlendMode: 'multiply',
|
||||
background: 'white',
|
||||
boxShadow: `hsla(4, 30%, 27%, 0.6) 0px 3px 10px 2px`,
|
||||
}}
|
||||
>
|
||||
{onClose ? (
|
||||
@ -111,8 +110,16 @@ export function ElementInfoWindow(): JSX.Element | null {
|
||||
<IconX />
|
||||
</div>
|
||||
) : null}
|
||||
{/* eslint-disable-next-line react/forbid-dom-props */}
|
||||
<div style={{ minHeight, maxHeight, overflow: 'auto' }}>
|
||||
<div
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{
|
||||
minHeight,
|
||||
maxHeight,
|
||||
overflow: 'auto',
|
||||
boxShadow: `hsla(4, 30%, 27%, 0.6) 0px 3px 10px 2px`,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<ElementInfo />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,14 +4,17 @@ import { useActions, useValues } from 'kea'
|
||||
import { compactNumber } from 'lib/utils'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { AutocaptureElement } from '~/toolbar/elements/AutocaptureElement'
|
||||
import { AutocaptureElementLabel } from '~/toolbar/elements/AutocaptureElementLabel'
|
||||
import { ElementInfoWindow } from '~/toolbar/elements/ElementInfoWindow'
|
||||
import { elementsLogic } from '~/toolbar/elements/elementsLogic'
|
||||
import { FocusRect } from '~/toolbar/elements/FocusRect'
|
||||
import { HeatmapElement } from '~/toolbar/elements/HeatmapElement'
|
||||
import { HeatmapLabel } from '~/toolbar/elements/HeatmapLabel'
|
||||
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||
import { getBoxColors, getHeatMapHue } from '~/toolbar/utils'
|
||||
|
||||
import { Heatmap } from './Heatmap'
|
||||
import { ScrollDepth } from './ScrollDepth'
|
||||
|
||||
export function Elements(): JSX.Element {
|
||||
const {
|
||||
heatmapElements,
|
||||
@ -48,16 +51,18 @@ export function Elements(): JSX.Element {
|
||||
zIndex: 2147483010,
|
||||
}}
|
||||
>
|
||||
<ScrollDepth />
|
||||
<Heatmap />
|
||||
{highlightElementMeta?.rect ? <FocusRect rect={highlightElementMeta.rect} /> : null}
|
||||
|
||||
{elementsToDisplay.map(({ rect, element }, index) => (
|
||||
<HeatmapElement
|
||||
<AutocaptureElement
|
||||
key={`inspect-${index}`}
|
||||
rect={rect}
|
||||
style={{
|
||||
pointerEvents: heatmapPointerEvents,
|
||||
cursor: 'pointer',
|
||||
zIndex: 0,
|
||||
zIndex: hoverElement === element ? 2 : 1,
|
||||
opacity:
|
||||
(!hoverElement && !selectedElement) ||
|
||||
selectedElement === element ||
|
||||
@ -65,6 +70,7 @@ export function Elements(): JSX.Element {
|
||||
? 1
|
||||
: 0.4,
|
||||
transition: 'opacity 0.2s, box-shadow 0.2s',
|
||||
borderRadius: 5,
|
||||
...getBoxColors('blue', hoverElement === element || selectedElement === element),
|
||||
}}
|
||||
onClick={() => selectElement(element)}
|
||||
@ -76,14 +82,15 @@ export function Elements(): JSX.Element {
|
||||
{heatmapElements.map(({ rect, count, clickCount, rageclickCount, element }, index) => {
|
||||
return (
|
||||
<Fragment key={`heatmap-${index}`}>
|
||||
<HeatmapElement
|
||||
<AutocaptureElement
|
||||
rect={rect}
|
||||
style={{
|
||||
pointerEvents: inspectEnabled ? 'none' : heatmapPointerEvents,
|
||||
zIndex: 1,
|
||||
zIndex: hoverElement === element ? 4 : 3,
|
||||
opacity: !hoverElement || hoverElement === element ? 1 : 0.4,
|
||||
transition: 'opacity 0.2s, box-shadow 0.2s',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 5,
|
||||
...getBoxColors(
|
||||
'red',
|
||||
hoverElement === element,
|
||||
@ -95,7 +102,7 @@ export function Elements(): JSX.Element {
|
||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||
/>
|
||||
{!!clickCount && (
|
||||
<HeatmapLabel
|
||||
<AutocaptureElementLabel
|
||||
rect={rect}
|
||||
style={{
|
||||
pointerEvents: heatmapPointerEvents,
|
||||
@ -122,10 +129,10 @@ export function Elements(): JSX.Element {
|
||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||
>
|
||||
{compactNumber(clickCount || 0)}
|
||||
</HeatmapLabel>
|
||||
</AutocaptureElementLabel>
|
||||
)}
|
||||
{!!rageclickCount && (
|
||||
<HeatmapLabel
|
||||
<AutocaptureElementLabel
|
||||
rect={rect}
|
||||
style={{
|
||||
pointerEvents: heatmapPointerEvents,
|
||||
@ -153,7 +160,7 @@ export function Elements(): JSX.Element {
|
||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||
>
|
||||
{compactNumber(rageclickCount)}😡
|
||||
</HeatmapLabel>
|
||||
</AutocaptureElementLabel>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
@ -162,7 +169,7 @@ export function Elements(): JSX.Element {
|
||||
{labelsToDisplay.map(({ element, rect, index }, loopIndex) => {
|
||||
if (rect) {
|
||||
return (
|
||||
<HeatmapLabel
|
||||
<AutocaptureElementLabel
|
||||
key={`label-${loopIndex}`}
|
||||
rect={rect}
|
||||
align="left"
|
||||
@ -182,7 +189,7 @@ export function Elements(): JSX.Element {
|
||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||
>
|
||||
{(index || loopIndex) + 1}
|
||||
</HeatmapLabel>
|
||||
</AutocaptureElementLabel>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
46
frontend/src/toolbar/elements/Heatmap.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import heatmapsJs, { Heatmap as HeatmapJS } from 'heatmap.js'
|
||||
import { useValues } from 'kea'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||
|
||||
export function Heatmap(): JSX.Element | null {
|
||||
const { heatmapJsData, heatmapEnabled, heatmapFilters } = useValues(heatmapLogic)
|
||||
const heatmapsJsRef = useRef<HeatmapJS<'value', 'x', 'y'>>()
|
||||
const heatmapsJsContainerRef = useRef<HTMLDivElement | null>()
|
||||
|
||||
const updateHeatmapData = useCallback((): void => {
|
||||
try {
|
||||
heatmapsJsRef.current?.setData(heatmapJsData)
|
||||
} catch (e) {
|
||||
console.error('error setting data', e)
|
||||
}
|
||||
}, [heatmapJsData])
|
||||
|
||||
const setHeatmapContainer = useCallback((container: HTMLDivElement | null): void => {
|
||||
heatmapsJsContainerRef.current = container
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
heatmapsJsRef.current = heatmapsJs.create({
|
||||
container,
|
||||
})
|
||||
|
||||
updateHeatmapData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
updateHeatmapData()
|
||||
}, [heatmapJsData])
|
||||
|
||||
if (!heatmapEnabled || !heatmapFilters.enabled || heatmapFilters.type === 'scrolldepth') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0" ref={setHeatmapContainer} />
|
||||
</div>
|
||||
)
|
||||
}
|
121
frontend/src/toolbar/elements/ScrollDepth.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useValues } from 'kea'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||
|
||||
import { toolbarConfigLogic } from '../toolbarConfigLogic'
|
||||
|
||||
function ScrollDepthMouseInfo(): JSX.Element | null {
|
||||
const { posthog } = useValues(toolbarConfigLogic)
|
||||
const { heatmapElements, rawHeatmapLoading } = useValues(heatmapLogic)
|
||||
|
||||
// Track the mouse position and render an indicator about how many people have scrolled to this point
|
||||
const [mouseY, setMouseY] = useState<null | number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent): void => {
|
||||
setMouseY(e.clientY)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!mouseY) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scrollOffset = (posthog as any).scrollManager.scrollY()
|
||||
const scrolledMouseY = mouseY + scrollOffset
|
||||
|
||||
const elementInMouseY = heatmapElements.find((x, i) => {
|
||||
const lastY = heatmapElements[i - 1]?.y ?? 0
|
||||
return scrolledMouseY >= lastY && scrolledMouseY < x.y
|
||||
})
|
||||
|
||||
const maxCount = heatmapElements[0]?.count ?? 0
|
||||
const percentage = ((elementInMouseY?.count ?? 0) / maxCount) * 100
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center z-10"
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{
|
||||
top: mouseY,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
<div className="border-b w-full" />
|
||||
<div className="bg-border whitespace-nowrap text-default rounded p-2 font-semibold">
|
||||
{rawHeatmapLoading ? (
|
||||
<>Loading...</>
|
||||
) : heatmapElements.length ? (
|
||||
<>{percentage.toPrecision(4)}% scrolled this far</>
|
||||
) : (
|
||||
<>No scroll data for the current dimension range</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-b w-10" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScrollDepth(): JSX.Element | null {
|
||||
const { posthog } = useValues(toolbarConfigLogic)
|
||||
|
||||
const { heatmapEnabled, heatmapFilters, heatmapElements, scrollDepthPosthogJsError } = useValues(heatmapLogic)
|
||||
|
||||
if (!heatmapEnabled || !heatmapFilters.enabled || heatmapFilters.type !== 'scrolldepth') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (scrollDepthPosthogJsError) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scrollOffset = (posthog as any).scrollManager.scrollY()
|
||||
|
||||
// We want to have a fading color from red to orange to green to blue to grey, fading from the highest count to the lowest
|
||||
const maxCount = heatmapElements[0]?.count ?? 0
|
||||
|
||||
function color(count: number): string {
|
||||
const value = 1 - count / maxCount
|
||||
const safeValue = Math.max(0, Math.min(1, value))
|
||||
const hue = Math.round(260 * safeValue)
|
||||
|
||||
// Return hsl color. You can adjust saturation and lightness to your liking
|
||||
return `hsl(${hue}, 100%, 50%)`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0"
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{
|
||||
transform: `translateY(${-scrollOffset}px)`,
|
||||
}}
|
||||
>
|
||||
{heatmapElements.map(({ y, count }, i) => (
|
||||
<div
|
||||
key={y}
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: heatmapElements[i - 1]?.y ?? 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: y - (heatmapElements[i - 1]?.y ?? 0),
|
||||
backgroundColor: color(count),
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollDepthMouseInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -3,9 +3,9 @@ import { collectAllElementsDeep } from 'query-selector-shadow-dom'
|
||||
|
||||
import { actionsLogic } from '~/toolbar/actions/actionsLogic'
|
||||
import { actionsTabLogic } from '~/toolbar/actions/actionsTabLogic'
|
||||
import { posthog } from '~/toolbar/posthog'
|
||||
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
||||
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'
|
||||
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||
import { ActionElementWithMetadata, ElementWithMetadata } from '~/toolbar/types'
|
||||
|
||||
import { elementToActionStep, getAllClickTargets, getElementForStep, getRectForElement } from '../utils'
|
||||
@ -371,11 +371,11 @@ export const elementsLogic = kea<elementsLogicType>([
|
||||
}),
|
||||
listeners(({ actions }) => ({
|
||||
enableInspect: () => {
|
||||
posthog.capture('toolbar mode triggered', { mode: 'inspect', enabled: true })
|
||||
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'inspect', enabled: true })
|
||||
actionsLogic.actions.getActions()
|
||||
},
|
||||
disableInspect: () => {
|
||||
posthog.capture('toolbar mode triggered', { mode: 'inspect', enabled: false })
|
||||
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'inspect', enabled: false })
|
||||
},
|
||||
selectElement: ({ element }) => {
|
||||
const inspectForAction =
|
||||
@ -401,7 +401,7 @@ export const elementsLogic = kea<elementsLogicType>([
|
||||
}
|
||||
}
|
||||
|
||||
posthog.capture('toolbar selected HTML element', {
|
||||
toolbarPosthogJS.capture('toolbar selected HTML element', {
|
||||
element_tag: element?.tagName.toLowerCase(),
|
||||
element_type: (element as HTMLInputElement)?.type,
|
||||
has_href: !!(element as HTMLAnchorElement)?.href,
|
||||
|
@ -1,30 +1,66 @@
|
||||
import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea'
|
||||
import { loaders } from 'kea-loaders'
|
||||
import { encodeParams } from 'kea-router'
|
||||
import { subscriptions } from 'kea-subscriptions'
|
||||
import { windowValues } from 'kea-window-values'
|
||||
import { elementToSelector, escapeRegex } from 'lib/actionUtils'
|
||||
import { PaginatedResponse } from 'lib/api'
|
||||
import { dateFilterToText } from 'lib/utils'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { collectAllElementsDeep, querySelectorAllDeep } from 'query-selector-shadow-dom'
|
||||
|
||||
import { posthog } from '~/toolbar/posthog'
|
||||
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
||||
import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic'
|
||||
import { CountedHTMLElement, ElementsEventType } from '~/toolbar/types'
|
||||
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||
import {
|
||||
CountedHTMLElement,
|
||||
ElementsEventType,
|
||||
HeatmapElement,
|
||||
HeatmapRequestType,
|
||||
HeatmapResponseType,
|
||||
} from '~/toolbar/types'
|
||||
import { elementToActionStep, trimElement } from '~/toolbar/utils'
|
||||
import { FilterType, PropertyFilterType, PropertyOperator } from '~/types'
|
||||
|
||||
import type { heatmapLogicType } from './heatmapLogicType'
|
||||
|
||||
export const SCROLL_DEPTH_JS_VERSION = [1, 99]
|
||||
|
||||
const emptyElementsStatsPages: PaginatedResponse<ElementsEventType> = {
|
||||
next: undefined,
|
||||
previous: undefined,
|
||||
results: [],
|
||||
}
|
||||
|
||||
export type CommonFilters = {
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}
|
||||
|
||||
export type HeatmapFilters = {
|
||||
enabled: boolean
|
||||
type?: string
|
||||
viewportAccuracy?: number
|
||||
aggregation?: HeatmapRequestType['aggregation']
|
||||
}
|
||||
|
||||
export type HeatmapJsDataPoint = {
|
||||
x: number
|
||||
y: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export type HeatmapJsData = {
|
||||
data: HeatmapJsDataPoint[]
|
||||
max: number
|
||||
min: number
|
||||
}
|
||||
export type HeatmapFixedPositionMode = 'fixed' | 'relative' | 'hidden'
|
||||
|
||||
export const heatmapLogic = kea<heatmapLogicType>([
|
||||
path(['toolbar', 'elements', 'heatmapLogic']),
|
||||
connect({
|
||||
values: [currentPageLogic, ['href', 'wildcardHref']],
|
||||
values: [currentPageLogic, ['href', 'wildcardHref'], toolbarConfigLogic, ['posthog']],
|
||||
actions: [currentPageLogic, ['setHref', 'setWildcardHref']],
|
||||
}),
|
||||
actions({
|
||||
@ -33,12 +69,27 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
||||
}),
|
||||
enableHeatmap: true,
|
||||
disableHeatmap: true,
|
||||
setShowHeatmapTooltip: (showHeatmapTooltip: boolean) => ({ showHeatmapTooltip }),
|
||||
setShiftPressed: (shiftPressed: boolean) => ({ shiftPressed }),
|
||||
setHeatmapFilter: (filter: Partial<FilterType>) => ({ filter }),
|
||||
setCommonFilters: (filters: CommonFilters) => ({ filters }),
|
||||
setHeatmapFilters: (filters: HeatmapFilters) => ({ filters }),
|
||||
patchHeatmapFilters: (filters: Partial<HeatmapFilters>) => ({ filters }),
|
||||
toggleClickmapsEnabled: (enabled?: boolean) => ({ enabled }),
|
||||
|
||||
loadMoreElementStats: true,
|
||||
setMatchLinksByHref: (matchLinksByHref: boolean) => ({ matchLinksByHref }),
|
||||
loadHeatmap: (type: string) => ({
|
||||
type,
|
||||
}),
|
||||
loadAllEnabled: (delayMs: number = 0) => ({ delayMs }),
|
||||
maybeLoadClickmap: (delayMs: number = 0) => ({ delayMs }),
|
||||
maybeLoadHeatmap: (delayMs: number = 0) => ({ delayMs }),
|
||||
fetchHeatmapApi: (params: HeatmapRequestType) => ({ params }),
|
||||
setHeatmapScrollY: (scrollY: number) => ({ scrollY }),
|
||||
setHeatmapFixedPositionMode: (mode: HeatmapFixedPositionMode) => ({ mode }),
|
||||
}),
|
||||
windowValues(() => ({
|
||||
windowWidth: (window: Window) => window.innerWidth,
|
||||
})),
|
||||
reducers({
|
||||
matchLinksByHref: [false, { setMatchLinksByHref: (_, { matchLinksByHref }) => matchLinksByHref }],
|
||||
canLoadMoreElementStats: [
|
||||
@ -56,31 +107,49 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
||||
getElementStatsFailure: () => false,
|
||||
},
|
||||
],
|
||||
heatmapLoading: [
|
||||
false,
|
||||
{
|
||||
getElementStats: () => true,
|
||||
getElementStatsSuccess: () => false,
|
||||
getElementStatsFailure: () => false,
|
||||
resetElementStats: () => false,
|
||||
},
|
||||
],
|
||||
showHeatmapTooltip: [
|
||||
false,
|
||||
{
|
||||
setShowHeatmapTooltip: (_, { showHeatmapTooltip }) => showHeatmapTooltip,
|
||||
},
|
||||
],
|
||||
shiftPressed: [
|
||||
false,
|
||||
{
|
||||
setShiftPressed: (_, { shiftPressed }) => shiftPressed,
|
||||
},
|
||||
],
|
||||
heatmapFilter: [
|
||||
{} as Partial<FilterType>,
|
||||
commonFilters: [
|
||||
{} as CommonFilters,
|
||||
{
|
||||
setHeatmapFilter: (_, { filter }) => filter,
|
||||
setCommonFilters: (_, { filters }) => filters,
|
||||
},
|
||||
],
|
||||
heatmapFilters: [
|
||||
{
|
||||
enabled: true,
|
||||
type: 'click',
|
||||
viewportAccuracy: 0.9,
|
||||
aggregation: 'total_count',
|
||||
} as HeatmapFilters,
|
||||
{ persist: true },
|
||||
{
|
||||
setHeatmapFilters: (_, { filters }) => filters,
|
||||
patchHeatmapFilters: (state, { filters }) => ({ ...state, ...filters }),
|
||||
},
|
||||
],
|
||||
clickmapsEnabled: [
|
||||
true,
|
||||
{ persist: true },
|
||||
{
|
||||
toggleClickmapsEnabled: (state, { enabled }) => (enabled === undefined ? !state : enabled),
|
||||
},
|
||||
],
|
||||
heatmapScrollY: [
|
||||
0,
|
||||
{
|
||||
setHeatmapScrollY: (_, { scrollY }) => scrollY,
|
||||
},
|
||||
],
|
||||
|
||||
heatmapFixedPositionMode: [
|
||||
'fixed' as HeatmapFixedPositionMode,
|
||||
{
|
||||
setHeatmapFixedPositionMode: (_, { mode }) => mode,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@ -110,7 +179,8 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
||||
type: PropertyFilterType.Event,
|
||||
},
|
||||
],
|
||||
...values.heatmapFilter,
|
||||
date_from: values.commonFilters.date_from,
|
||||
date_to: values.commonFilters.date_to,
|
||||
}
|
||||
|
||||
defaultUrl = `/api/element/stats/${encodeParams({ ...params, paginate_response: true }, '?')}`
|
||||
@ -144,22 +214,58 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
rawHeatmap: [
|
||||
null as HeatmapResponseType | null,
|
||||
{
|
||||
loadHeatmap: async () => {
|
||||
const { href, wildcardHref } = values
|
||||
const { date_from, date_to } = values.commonFilters
|
||||
const { type, aggregation } = values.heatmapFilters
|
||||
const urlExact = wildcardHref === href ? href : undefined
|
||||
const urlRegex = wildcardHref !== href ? wildcardHref : undefined
|
||||
|
||||
// toolbar fetch collapses queryparams but this URL has multiple with the same name
|
||||
const response = await toolbarFetch(
|
||||
`/api/heatmap/${encodeParams(
|
||||
{
|
||||
type,
|
||||
date_from,
|
||||
date_to,
|
||||
url_exact: urlExact,
|
||||
url_pattern: urlRegex,
|
||||
viewport_width_min: values.viewportRange.min,
|
||||
viewport_width_max: values.viewportRange.max,
|
||||
aggregation,
|
||||
},
|
||||
'?'
|
||||
)}`,
|
||||
'GET'
|
||||
)
|
||||
|
||||
if (response.status === 403) {
|
||||
toolbarConfigLogic.actions.authenticate()
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('API error')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
selectors(({ cache }) => ({
|
||||
dateRange: [
|
||||
(s) => [s.heatmapFilter],
|
||||
(heatmapFilter: Partial<FilterType>) => {
|
||||
return dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days')
|
||||
(s) => [s.commonFilters],
|
||||
(commonFilters: Partial<FilterType>) => {
|
||||
return dateFilterToText(commonFilters.date_from, commonFilters.date_to, 'Last 7 days')
|
||||
},
|
||||
],
|
||||
elements: [
|
||||
(selectors) => [
|
||||
selectors.elementStats,
|
||||
toolbarConfigLogic.selectors.dataAttributes,
|
||||
selectors.href,
|
||||
selectors.matchLinksByHref,
|
||||
],
|
||||
(s) => [s.elementStats, toolbarConfigLogic.selectors.dataAttributes, s.href, s.matchLinksByHref],
|
||||
(elementStats, dataAttributes, href, matchLinksByHref) => {
|
||||
cache.pageElements = cache.lastHref == href ? cache.pageElements : collectAllElementsDeep('*', document)
|
||||
cache.selectorToElements = cache.lastHref == href ? cache.selectorToElements : {}
|
||||
@ -240,8 +346,11 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
||||
},
|
||||
],
|
||||
countedElements: [
|
||||
(selectors) => [selectors.elements, toolbarConfigLogic.selectors.dataAttributes],
|
||||
(elements, dataAttributes) => {
|
||||
(s) => [s.elements, toolbarConfigLogic.selectors.dataAttributes, s.clickmapsEnabled],
|
||||
(elements, dataAttributes, clickmapsEnabled) => {
|
||||
if (!clickmapsEnabled) {
|
||||
return []
|
||||
}
|
||||
const normalisedElements = new Map<HTMLElement, CountedHTMLElement>()
|
||||
;(elements || []).forEach((countedElement) => {
|
||||
const trimmedElement = trimElement(countedElement.element)
|
||||
@ -273,22 +382,225 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
||||
return countedElements.map((e, i) => ({ ...e, position: i + 1 }))
|
||||
},
|
||||
],
|
||||
elementCount: [(selectors) => [selectors.countedElements], (countedElements) => countedElements.length],
|
||||
elementCount: [(s) => [s.countedElements], (countedElements) => countedElements.length],
|
||||
clickCount: [
|
||||
(selectors) => [selectors.countedElements],
|
||||
(s) => [s.countedElements],
|
||||
(countedElements) => (countedElements ? countedElements.map((e) => e.count).reduce((a, b) => a + b, 0) : 0),
|
||||
],
|
||||
highestClickCount: [
|
||||
(selectors) => [selectors.countedElements],
|
||||
(s) => [s.countedElements],
|
||||
(countedElements) =>
|
||||
countedElements ? countedElements.map((e) => e.count).reduce((a, b) => (b > a ? b : a), 0) : 0,
|
||||
],
|
||||
|
||||
heatmapElements: [
|
||||
(s) => [s.rawHeatmap],
|
||||
(rawHeatmap): HeatmapElement[] => {
|
||||
if (!rawHeatmap) {
|
||||
return []
|
||||
}
|
||||
|
||||
const elements: HeatmapElement[] = []
|
||||
|
||||
rawHeatmap?.results.forEach((element) => {
|
||||
if ('scroll_depth_bucket' in element) {
|
||||
elements.push({
|
||||
count: element.cumulative_count,
|
||||
xPercentage: 0,
|
||||
targetFixed: false,
|
||||
y: element.scroll_depth_bucket,
|
||||
})
|
||||
} else {
|
||||
elements.push({
|
||||
count: element.count,
|
||||
xPercentage: element.pointer_relative_x,
|
||||
targetFixed: element.pointer_target_fixed,
|
||||
y: element.pointer_y,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return elements
|
||||
},
|
||||
],
|
||||
|
||||
viewportRange: [
|
||||
(s) => [s.heatmapFilters, s.windowWidth],
|
||||
(heatmapFilters, windowWidth): { max: number; min: number } => {
|
||||
const viewportAccuracy = heatmapFilters.viewportAccuracy ?? 0.2
|
||||
const extraPixels = windowWidth - windowWidth * viewportAccuracy
|
||||
|
||||
const minWidth = Math.max(0, windowWidth - extraPixels)
|
||||
const maxWidth = windowWidth + extraPixels
|
||||
|
||||
return {
|
||||
min: Math.round(minWidth),
|
||||
max: Math.round(maxWidth),
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
scrollDepthPosthogJsError: [
|
||||
(s) => [s.posthog],
|
||||
(posthog: PostHog): 'version' | 'disabled' | null => {
|
||||
const posthogVersion = posthog?._calculate_event_properties('test', {})?.['$lib_version'] ?? '0.0.0'
|
||||
const majorMinorVersion = posthogVersion.split('.')
|
||||
const majorVersion = parseInt(majorMinorVersion[0], 10)
|
||||
const minorVersion = parseInt(majorMinorVersion[1], 10)
|
||||
|
||||
if (!(posthog as any)?.scrollManager?.scrollY) {
|
||||
return 'version'
|
||||
}
|
||||
|
||||
const isSupported =
|
||||
majorVersion > SCROLL_DEPTH_JS_VERSION[0] ||
|
||||
(majorVersion === SCROLL_DEPTH_JS_VERSION[0] && minorVersion >= SCROLL_DEPTH_JS_VERSION[1])
|
||||
const isDisabled = posthog?.config.disable_scroll_properties
|
||||
|
||||
return !isSupported ? 'version' : isDisabled ? 'disabled' : null
|
||||
},
|
||||
],
|
||||
|
||||
heatmapJsData: [
|
||||
(s) => [s.heatmapElements, s.heatmapScrollY, s.windowWidth, s.heatmapFixedPositionMode],
|
||||
(heatmapElements, heatmapScrollY, windowWidth, heatmapFixedPositionMode): HeatmapJsData => {
|
||||
// We want to account for all the fixed position elements, the scroll of the context and the browser width
|
||||
const data = heatmapElements.reduce((acc, element) => {
|
||||
if (heatmapFixedPositionMode === 'hidden' && element.targetFixed) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const y = Math.round(
|
||||
element.targetFixed && heatmapFixedPositionMode === 'fixed'
|
||||
? element.y
|
||||
: element.y - heatmapScrollY
|
||||
)
|
||||
const x = Math.round(element.xPercentage * windowWidth)
|
||||
|
||||
return [...acc, { x, y, value: element.count }]
|
||||
}, [] as HeatmapJsDataPoint[])
|
||||
|
||||
// Max is the highest value in the data set we have
|
||||
const max = data.reduce((max, { value }) => Math.max(max, value), 0)
|
||||
|
||||
// TODO: Group based on some sensible resolutions (we can then use this for a hover state to show more detail)
|
||||
|
||||
return {
|
||||
min: 0,
|
||||
max,
|
||||
data,
|
||||
}
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
subscriptions(({ actions }) => ({
|
||||
viewportRange: () => {
|
||||
actions.maybeLoadHeatmap(500)
|
||||
},
|
||||
})),
|
||||
|
||||
listeners(({ actions, values }) => ({
|
||||
fetchHeatmapApi: async () => {
|
||||
const { href, wildcardHref } = values
|
||||
const { date_from, date_to } = values.commonFilters
|
||||
const { type, aggregation } = values.heatmapFilters
|
||||
const urlExact = wildcardHref === href ? href : undefined
|
||||
const urlRegex = wildcardHref !== href ? wildcardHref : undefined
|
||||
|
||||
// toolbar fetch collapses queryparams but this URL has multiple with the same name
|
||||
const response = await toolbarFetch(
|
||||
`/api/heatmap/${encodeParams(
|
||||
{
|
||||
type,
|
||||
date_from,
|
||||
date_to,
|
||||
url_exact: urlExact,
|
||||
url_pattern: urlRegex,
|
||||
viewport_width_min: values.viewportRange.min,
|
||||
viewport_width_max: values.viewportRange.max,
|
||||
aggregation,
|
||||
},
|
||||
'?'
|
||||
)}`,
|
||||
'GET'
|
||||
)
|
||||
|
||||
if (response.status === 403) {
|
||||
toolbarConfigLogic.actions.authenticate()
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('API error')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
},
|
||||
enableHeatmap: () => {
|
||||
actions.loadAllEnabled()
|
||||
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'heatmap', enabled: true })
|
||||
},
|
||||
disableHeatmap: () => {
|
||||
actions.resetElementStats()
|
||||
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'heatmap', enabled: false })
|
||||
},
|
||||
|
||||
loadAllEnabled: async ({ delayMs }, breakpoint) => {
|
||||
await breakpoint(delayMs)
|
||||
|
||||
actions.maybeLoadHeatmap()
|
||||
actions.maybeLoadClickmap()
|
||||
},
|
||||
maybeLoadClickmap: async ({ delayMs }, breakpoint) => {
|
||||
await breakpoint(delayMs)
|
||||
if (values.heatmapEnabled && values.clickmapsEnabled) {
|
||||
actions.getElementStats()
|
||||
}
|
||||
},
|
||||
|
||||
maybeLoadHeatmap: async ({ delayMs }, breakpoint) => {
|
||||
await breakpoint(delayMs)
|
||||
if (values.heatmapEnabled) {
|
||||
if (values.heatmapFilters.enabled && values.heatmapFilters.type) {
|
||||
actions.loadHeatmap(values.heatmapFilters.type)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setHref: () => {
|
||||
actions.loadAllEnabled()
|
||||
},
|
||||
setWildcardHref: () => {
|
||||
actions.loadAllEnabled(1000)
|
||||
},
|
||||
setCommonFilters: () => {
|
||||
actions.loadAllEnabled(200)
|
||||
},
|
||||
|
||||
// Only trigger element stats loading if clickmaps are enabled
|
||||
toggleClickmapsEnabled: () => {
|
||||
if (values.clickmapsEnabled) {
|
||||
actions.getElementStats()
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreElementStats: () => {
|
||||
if (values.elementStats?.next) {
|
||||
actions.getElementStats(values.elementStats.next)
|
||||
}
|
||||
},
|
||||
|
||||
patchHeatmapFilters: ({ filters }) => {
|
||||
if (filters.type) {
|
||||
// Clear the heatmap if the type changes
|
||||
actions.loadHeatmapSuccess({ results: [] })
|
||||
}
|
||||
actions.maybeLoadHeatmap(200)
|
||||
},
|
||||
})),
|
||||
|
||||
afterMount(({ actions, values, cache }) => {
|
||||
if (values.heatmapEnabled) {
|
||||
actions.getElementStats()
|
||||
}
|
||||
actions.loadAllEnabled()
|
||||
cache.keyDownListener = (event: KeyboardEvent) => {
|
||||
if (event.shiftKey && !values.shiftPressed) {
|
||||
actions.setShiftPressed(true)
|
||||
@ -301,53 +613,18 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
||||
}
|
||||
window.addEventListener('keydown', cache.keyDownListener)
|
||||
window.addEventListener('keyup', cache.keyUpListener)
|
||||
|
||||
cache.scrollCheckTimer = setInterval(() => {
|
||||
const scrollY = (values.posthog as any)?.scrollManager?.scrollY() ?? 0
|
||||
if (values.heatmapScrollY !== scrollY) {
|
||||
actions.setHeatmapScrollY(scrollY)
|
||||
}
|
||||
}, 100)
|
||||
}),
|
||||
|
||||
beforeUnmount(({ cache }) => {
|
||||
window.removeEventListener('keydown', cache.keyDownListener)
|
||||
window.removeEventListener('keyup', cache.keyUpListener)
|
||||
clearInterval(cache.scrollCheckTimer)
|
||||
}),
|
||||
|
||||
listeners(({ actions, values }) => ({
|
||||
loadMoreElementStats: () => {
|
||||
if (values.elementStats?.next) {
|
||||
actions.getElementStats(values.elementStats.next)
|
||||
}
|
||||
},
|
||||
setHref: () => {
|
||||
if (values.heatmapEnabled) {
|
||||
actions.resetElementStats()
|
||||
actions.getElementStats()
|
||||
}
|
||||
},
|
||||
setWildcardHref: async (_, breakpoint) => {
|
||||
await breakpoint(100)
|
||||
if (values.heatmapEnabled) {
|
||||
actions.resetElementStats()
|
||||
actions.getElementStats()
|
||||
}
|
||||
},
|
||||
enableHeatmap: () => {
|
||||
actions.getElementStats()
|
||||
posthog.capture('toolbar mode triggered', { mode: 'heatmap', enabled: true })
|
||||
},
|
||||
disableHeatmap: () => {
|
||||
actions.resetElementStats()
|
||||
actions.setShowHeatmapTooltip(false)
|
||||
posthog.capture('toolbar mode triggered', { mode: 'heatmap', enabled: false })
|
||||
},
|
||||
getElementStatsSuccess: () => {
|
||||
actions.setShowHeatmapTooltip(true)
|
||||
},
|
||||
setShowHeatmapTooltip: async ({ showHeatmapTooltip }, breakpoint) => {
|
||||
if (showHeatmapTooltip) {
|
||||
await breakpoint(1000)
|
||||
actions.setShowHeatmapTooltip(false)
|
||||
}
|
||||
},
|
||||
setHeatmapFilter: () => {
|
||||
actions.resetElementStats()
|
||||
actions.getElementStats()
|
||||
},
|
||||
})),
|
||||
])
|
||||
|
@ -5,8 +5,8 @@ import { encodeParams } from 'kea-router'
|
||||
import { permanentlyMount } from 'lib/utils/kea-logic-builders'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
|
||||
import { posthog as posthogJS } from '~/toolbar/posthog'
|
||||
import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic'
|
||||
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||
import { CombinedFeatureFlagAndValueType } from '~/types'
|
||||
|
||||
import type { flagsToolbarLogicType } from './flagsToolbarLogicType'
|
||||
@ -119,7 +119,7 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
|
||||
const clientPostHog = values.posthog
|
||||
if (clientPostHog) {
|
||||
clientPostHog.featureFlags.override({ ...values.localOverrides, [flagKey]: overrideValue })
|
||||
posthogJS.capture('toolbar feature flag overridden')
|
||||
toolbarPosthogJS.capture('toolbar feature flag overridden')
|
||||
actions.checkLocalOverrides()
|
||||
clientPostHog.featureFlags.reloadFeatureFlags()
|
||||
}
|
||||
@ -134,7 +134,7 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
|
||||
} else {
|
||||
clientPostHog.featureFlags.override(false)
|
||||
}
|
||||
posthogJS.capture('toolbar feature flag override removed')
|
||||
toolbarPosthogJS.capture('toolbar feature flag override removed')
|
||||
actions.checkLocalOverrides()
|
||||
clientPostHog.featureFlags.reloadFeatureFlags()
|
||||
}
|
||||
|
@ -1,18 +0,0 @@
|
||||
import PostHog from 'posthog-js-lite'
|
||||
|
||||
const DEFAULT_API_KEY = 'sTMFPsFhdP1Ssg'
|
||||
|
||||
const runningOnPosthog = !!window.POSTHOG_APP_CONTEXT
|
||||
const apiKey = runningOnPosthog ? window.JS_POSTHOG_API_KEY : DEFAULT_API_KEY
|
||||
const apiHost = runningOnPosthog ? window.JS_POSTHOG_HOST : 'https://internal-e.posthog.com'
|
||||
|
||||
export const posthog = new PostHog(apiKey || DEFAULT_API_KEY, {
|
||||
host: apiHost,
|
||||
enable: false, // must call .optIn() before any events are sent
|
||||
persistence: 'memory', // We don't want to persist anything, all events are in-memory
|
||||
persistence_name: apiKey + '_toolbar', // We don't need this but it ensures we don't accidentally mess with the standard persistence
|
||||
})
|
||||
|
||||
if (runningOnPosthog && window.JS_POSTHOG_SELF_CAPTURE) {
|
||||
posthog.debug()
|
||||
}
|
@ -1,9 +1,12 @@
|
||||
import { IconMagicWand } from '@posthog/icons'
|
||||
import { LemonLabel, LemonSegmentedButton } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { CUSTOM_OPTION_KEY } from 'lib/components/DateFilter/types'
|
||||
import { IconSync } from 'lib/lemon-ui/icons'
|
||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||
import { LemonInput } from 'lib/lemon-ui/LemonInput'
|
||||
import { LemonMenu } from 'lib/lemon-ui/LemonMenu'
|
||||
import { LemonSlider } from 'lib/lemon-ui/LemonSlider'
|
||||
import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch'
|
||||
import { Spinner } from 'lib/lemon-ui/Spinner'
|
||||
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||
@ -14,98 +17,326 @@ import { elementsLogic } from '~/toolbar/elements/elementsLogic'
|
||||
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
||||
|
||||
import { useToolbarFeatureFlag } from '../toolbarPosthogJS'
|
||||
|
||||
const ScrollDepthJSWarning = (): JSX.Element | null => {
|
||||
const { scrollDepthPosthogJsError } = useValues(heatmapLogic)
|
||||
|
||||
if (!scrollDepthPosthogJsError) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="my-2 bg-danger-highlight border border-danger rounded p-2">
|
||||
{scrollDepthPosthogJsError === 'version' ? (
|
||||
<>This feature requires a newer version of posthog-js</>
|
||||
) : scrollDepthPosthogJsError === 'disabled' ? (
|
||||
<>
|
||||
Your posthog-js config has <i>disable_scroll_properties</i> set - these properties are required for
|
||||
scroll depth calculations to work.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export const HeatmapToolbarMenu = (): JSX.Element => {
|
||||
const { wildcardHref } = useValues(currentPageLogic)
|
||||
const { setWildcardHref } = useActions(currentPageLogic)
|
||||
const { setWildcardHref, autoWildcardHref } = useActions(currentPageLogic)
|
||||
|
||||
const { matchLinksByHref, countedElements, clickCount, heatmapLoading, heatmapFilter, canLoadMoreElementStats } =
|
||||
useValues(heatmapLogic)
|
||||
const { setHeatmapFilter, loadMoreElementStats, setMatchLinksByHref } = useActions(heatmapLogic)
|
||||
const {
|
||||
matchLinksByHref,
|
||||
countedElements,
|
||||
clickCount,
|
||||
commonFilters,
|
||||
heatmapFilters,
|
||||
canLoadMoreElementStats,
|
||||
viewportRange,
|
||||
rawHeatmapLoading,
|
||||
elementStatsLoading,
|
||||
clickmapsEnabled,
|
||||
heatmapFixedPositionMode,
|
||||
} = useValues(heatmapLogic)
|
||||
const {
|
||||
setCommonFilters,
|
||||
patchHeatmapFilters,
|
||||
loadMoreElementStats,
|
||||
setMatchLinksByHref,
|
||||
toggleClickmapsEnabled,
|
||||
setHeatmapFixedPositionMode,
|
||||
} = useActions(heatmapLogic)
|
||||
const { setHighlightElement, setSelectedElement } = useActions(elementsLogic)
|
||||
|
||||
const dateItems = dateMapping
|
||||
.filter((dm) => dm.key !== CUSTOM_OPTION_KEY)
|
||||
.map((dateOption) => ({
|
||||
label: dateOption.key,
|
||||
onClick: () => setHeatmapFilter({ date_from: dateOption.values[0], date_to: dateOption.values[1] }),
|
||||
onClick: () => setCommonFilters({ date_from: dateOption.values[0], date_to: dateOption.values[1] }),
|
||||
}))
|
||||
|
||||
const showNewHeatmaps = useToolbarFeatureFlag('toolbar-heatmaps')
|
||||
|
||||
return (
|
||||
<ToolbarMenu>
|
||||
<ToolbarMenu.Header>
|
||||
<LemonInput value={wildcardHref} onChange={setWildcardHref} />
|
||||
<div className="space-y-1 border-b px-1 pb-2">
|
||||
<div className="text-muted p-1">Use * as a wildcard</div>
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<LemonMenu items={dateItems}>
|
||||
<LemonButton size="small" type="secondary">
|
||||
{dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days')}
|
||||
</LemonButton>
|
||||
</LemonMenu>
|
||||
<div className="flex gap-1">
|
||||
<LemonInput className="flex-1" value={wildcardHref} onChange={setWildcardHref} />
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
icon={<IconMagicWand />}
|
||||
size="small"
|
||||
onClick={() => autoWildcardHref()}
|
||||
tooltip={
|
||||
<>
|
||||
You can use the wildcard character <code>*</code> to match any character in the URL. For
|
||||
example, <code>https://example.com/*</code> will match{' '}
|
||||
<code>https://example.com/page</code> and <code>https://example.com/page/1</code>.
|
||||
<br />
|
||||
Click this button to automatically wildcards where we believe it would make sense
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LemonButton
|
||||
icon={<IconSync />}
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={loadMoreElementStats}
|
||||
disabledReason={
|
||||
canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.'
|
||||
}
|
||||
>
|
||||
Load more
|
||||
<div className="flex flex-row items-center gap-2 py-2 border-b">
|
||||
<LemonMenu items={dateItems}>
|
||||
<LemonButton size="small" type="secondary">
|
||||
{dateFilterToText(commonFilters.date_from, commonFilters.date_to, 'Last 7 days')}
|
||||
</LemonButton>
|
||||
|
||||
{heatmapLoading ? <Spinner /> : null}
|
||||
</div>
|
||||
<div>
|
||||
Found: {countedElements.length} elements / {clickCount} clicks!
|
||||
</div>
|
||||
|
||||
<Tooltip title="Matching links by their target URL can exclude clicks from the heatmap if the URL is too unique.">
|
||||
<LemonSwitch
|
||||
checked={matchLinksByHref}
|
||||
label="Match links by their target URL"
|
||||
onChange={(checked) => setMatchLinksByHref(checked)}
|
||||
fullWidth={true}
|
||||
bordered={true}
|
||||
/>
|
||||
</Tooltip>
|
||||
</LemonMenu>
|
||||
</div>
|
||||
</ToolbarMenu.Header>
|
||||
<ToolbarMenu.Body>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
{heatmapLoading ? (
|
||||
<span className="flex-1 flex justify-center items-center p-4">
|
||||
<Spinner className="text-2xl" />
|
||||
</span>
|
||||
) : countedElements.length ? (
|
||||
countedElements.map(({ element, count, actionStep }, index) => {
|
||||
return (
|
||||
<div
|
||||
className="p-2 flex flex-row justify-between cursor-pointer hover:bg-primary-highlight"
|
||||
key={index}
|
||||
onClick={() => setSelectedElement(element)}
|
||||
onMouseEnter={() => setHighlightElement(element)}
|
||||
onMouseLeave={() => setHighlightElement(null)}
|
||||
>
|
||||
<div>
|
||||
{index + 1}.
|
||||
{actionStep?.text ||
|
||||
(actionStep?.tag_name ? (
|
||||
<code><{actionStep.tag_name}></code>
|
||||
) : (
|
||||
<em>Element</em>
|
||||
))}
|
||||
</div>
|
||||
<div>{count} clicks</div>
|
||||
{showNewHeatmaps ? (
|
||||
<div className="border-b p-2">
|
||||
<LemonSwitch
|
||||
className="w-full"
|
||||
checked={!!heatmapFilters.enabled}
|
||||
label={<>Heatmaps {rawHeatmapLoading ? <Spinner /> : null}</>}
|
||||
onChange={(e) =>
|
||||
patchHeatmapFilters({
|
||||
enabled: e,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{heatmapFilters.enabled && (
|
||||
<>
|
||||
<p>
|
||||
Heatmaps are calculated using additional data sent along with standard events. They
|
||||
are based off of general pointer interactions and might not be 100% accurate to the
|
||||
page you are viewing.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<LemonLabel>Heatmap type</LemonLabel>
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<LemonSegmentedButton
|
||||
onChange={(e) => patchHeatmapFilters({ type: e })}
|
||||
value={heatmapFilters.type ?? undefined}
|
||||
options={[
|
||||
{
|
||||
value: 'click',
|
||||
label: 'Clicks',
|
||||
},
|
||||
{
|
||||
value: 'rageclick',
|
||||
label: 'Rageclicks',
|
||||
},
|
||||
{
|
||||
value: 'mousemove',
|
||||
label: 'Mouse moves',
|
||||
},
|
||||
{
|
||||
value: 'scrolldepth',
|
||||
label: 'Scroll depth',
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-2">No elements found.</div>
|
||||
|
||||
{heatmapFilters.type === 'scrolldepth' && (
|
||||
<>
|
||||
<p>
|
||||
Scroll depth uses additional information from Pageview and Pageleave
|
||||
events to indicate how far down the page users have scrolled.
|
||||
</p>
|
||||
<ScrollDepthJSWarning />
|
||||
</>
|
||||
)}
|
||||
|
||||
<LemonLabel>Aggregation</LemonLabel>
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<LemonSegmentedButton
|
||||
onChange={(e) => patchHeatmapFilters({ aggregation: e })}
|
||||
value={heatmapFilters.aggregation ?? 'total_count'}
|
||||
options={[
|
||||
{
|
||||
value: 'total_count',
|
||||
label: 'Total count',
|
||||
},
|
||||
{
|
||||
value: 'unique_visitors',
|
||||
label: 'Unique visitors',
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LemonLabel>Viewport accuracy</LemonLabel>
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<LemonSlider
|
||||
className="flex-1"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={heatmapFilters.viewportAccuracy ?? 0}
|
||||
onChange={(value) => patchHeatmapFilters({ viewportAccuracy: value })}
|
||||
/>
|
||||
<Tooltip
|
||||
title={`
|
||||
The range of values
|
||||
Heatmap will be loaded for all viewports where the width is above
|
||||
|
||||
`}
|
||||
>
|
||||
<code className="w-[12rem] text-right text-xs whitsepace-nowrap">
|
||||
{`${Math.round((heatmapFilters.viewportAccuracy ?? 1) * 100)}% (${
|
||||
viewportRange.min
|
||||
}px - ${viewportRange.max}px)`}
|
||||
</code>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{heatmapFilters.type !== 'scrolldepth' ? (
|
||||
<>
|
||||
<LemonLabel>Fixed positioning calculation</LemonLabel>
|
||||
<p>
|
||||
PostHog JS will attempt to detect fixed elements such as headers or
|
||||
modals and will therefore show those heatmap areas, ignoring the scroll
|
||||
value.
|
||||
<br />
|
||||
You can choose to show these areas as fixed, include them with scrolled
|
||||
data or hide them altogether.
|
||||
</p>
|
||||
|
||||
<LemonSegmentedButton
|
||||
onChange={setHeatmapFixedPositionMode}
|
||||
value={heatmapFixedPositionMode}
|
||||
options={[
|
||||
{
|
||||
value: 'fixed',
|
||||
label: 'Show fixed',
|
||||
},
|
||||
{
|
||||
value: 'relative',
|
||||
label: 'Show scrolled',
|
||||
},
|
||||
{
|
||||
value: 'hidden',
|
||||
label: 'Hide',
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="p-2">
|
||||
{showNewHeatmaps ? (
|
||||
<LemonSwitch
|
||||
className="w-full"
|
||||
checked={!!clickmapsEnabled}
|
||||
label={<>Clickmaps (autocapture) {elementStatsLoading ? <Spinner /> : null}</>}
|
||||
onChange={(e) => toggleClickmapsEnabled(e)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(clickmapsEnabled || !showNewHeatmaps) && (
|
||||
<>
|
||||
{showNewHeatmaps ? (
|
||||
<p>
|
||||
Clickmaps are built using Autocapture events. They are more accurate than heatmaps
|
||||
if the event can be mapped to a specific element found on the page you are viewing
|
||||
but less data is usually captured.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<LemonButton
|
||||
icon={<IconSync />}
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={loadMoreElementStats}
|
||||
disabledReason={
|
||||
canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.'
|
||||
}
|
||||
>
|
||||
Load more
|
||||
</LemonButton>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Matching links by their target URL can exclude clicks from the heatmap if
|
||||
the URL is too unique.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<LemonSwitch
|
||||
className="flex-1"
|
||||
checked={matchLinksByHref}
|
||||
label="Match links by their target URL"
|
||||
onChange={(checked) => setMatchLinksByHref(checked)}
|
||||
fullWidth={true}
|
||||
bordered={true}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="my-2">
|
||||
Found: {countedElements.length} elements / {clickCount} clicks!
|
||||
</div>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
{countedElements.length ? (
|
||||
countedElements.map(({ element, count, actionStep }, index) => {
|
||||
return (
|
||||
<LemonButton
|
||||
key={index}
|
||||
size="small"
|
||||
fullWidth
|
||||
onClick={() => setSelectedElement(element)}
|
||||
>
|
||||
<div
|
||||
className="flex flex-1 justify-between"
|
||||
key={index}
|
||||
onMouseEnter={() => setHighlightElement(element)}
|
||||
onMouseLeave={() => setHighlightElement(null)}
|
||||
>
|
||||
<div>
|
||||
{index + 1}.
|
||||
{actionStep?.text ||
|
||||
(actionStep?.tag_name ? (
|
||||
<code><{actionStep.tag_name}></code>
|
||||
) : (
|
||||
<em>Element</em>
|
||||
))}
|
||||
</div>
|
||||
<div>{count} clicks</div>
|
||||
</div>
|
||||
</LemonButton>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-2">No elements found.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ToolbarMenu.Body>
|
||||
</ToolbarMenu>
|
||||
|
@ -1,12 +1,32 @@
|
||||
import { actions, afterMount, beforeUnmount, kea, path, reducers } from 'kea'
|
||||
import { actions, afterMount, beforeUnmount, kea, listeners, path, reducers } from 'kea'
|
||||
|
||||
import type { currentPageLogicType } from './currentPageLogicType'
|
||||
|
||||
const replaceWithWildcard = (part: string): string => {
|
||||
// replace uuids
|
||||
if (part.match(/^([a-f]|[0-9]){8}-([a-f]|[0-9]){4}-([a-f]|[0-9]){4}-([a-f]|[0-9]){4}-([a-f]|[0-9]){12}$/)) {
|
||||
return '*'
|
||||
}
|
||||
|
||||
// replace digits
|
||||
if (part.match(/^[0-9]+$/)) {
|
||||
return '*'
|
||||
}
|
||||
|
||||
// Replace long values
|
||||
if (part.length > 24) {
|
||||
return '*'
|
||||
}
|
||||
|
||||
return part
|
||||
}
|
||||
|
||||
export const currentPageLogic = kea<currentPageLogicType>([
|
||||
path(['toolbar', 'stats', 'currentPageLogic']),
|
||||
actions(() => ({
|
||||
setHref: (href: string) => ({ href }),
|
||||
setWildcardHref: (href: string) => ({ href }),
|
||||
autoWildcardHref: true,
|
||||
})),
|
||||
reducers(() => ({
|
||||
href: [window.location.href, { setHref: (_, { href }) => href }],
|
||||
@ -16,6 +36,31 @@ export const currentPageLogic = kea<currentPageLogicType>([
|
||||
],
|
||||
})),
|
||||
|
||||
listeners(({ actions, values }) => ({
|
||||
autoWildcardHref: () => {
|
||||
let url = values.wildcardHref
|
||||
|
||||
const urlParts = url.split('?')
|
||||
|
||||
url = urlParts[0]
|
||||
.split('/')
|
||||
.map((part) => replaceWithWildcard(part))
|
||||
.join('/')
|
||||
|
||||
// Iterate over query params and do the same for their values
|
||||
if (urlParts.length > 1) {
|
||||
const queryParams = urlParts[1].split('&')
|
||||
for (let i = 0; i < queryParams.length; i++) {
|
||||
const [key, value] = queryParams[i].split('=')
|
||||
queryParams[i] = `${key}=${replaceWithWildcard(value)}`
|
||||
}
|
||||
url = `${url}?${queryParams.join('&')}`
|
||||
}
|
||||
|
||||
actions.setWildcardHref(url)
|
||||
},
|
||||
})),
|
||||
|
||||
afterMount(({ actions, values, cache }) => {
|
||||
cache.interval = window.setInterval(() => {
|
||||
if (window.location.href !== values.href) {
|
||||
|
@ -2,7 +2,7 @@ import { actions, afterMount, kea, listeners, path, props, reducers, selectors }
|
||||
import { combineUrl, encodeParams } from 'kea-router'
|
||||
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
|
||||
|
||||
import { posthog } from '~/toolbar/posthog'
|
||||
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||
import { ToolbarProps } from '~/types'
|
||||
|
||||
import type { toolbarConfigLogicType } from './toolbarConfigLogicType'
|
||||
@ -51,17 +51,17 @@ export const toolbarConfigLogic = kea<toolbarConfigLogicType>([
|
||||
|
||||
listeners(({ values, actions }) => ({
|
||||
authenticate: () => {
|
||||
posthog.capture('toolbar authenticate', { is_authenticated: values.isAuthenticated })
|
||||
toolbarPosthogJS.capture('toolbar authenticate', { is_authenticated: values.isAuthenticated })
|
||||
const encodedUrl = encodeURIComponent(window.location.href)
|
||||
actions.persistConfig()
|
||||
window.location.href = `${values.apiURL}/authorize_and_redirect/?redirect=${encodedUrl}`
|
||||
},
|
||||
logout: () => {
|
||||
posthog.capture('toolbar logout')
|
||||
toolbarPosthogJS.capture('toolbar logout')
|
||||
localStorage.removeItem(LOCALSTORAGE_KEY)
|
||||
},
|
||||
tokenExpired: () => {
|
||||
posthog.capture('toolbar token expired')
|
||||
toolbarPosthogJS.capture('toolbar token expired')
|
||||
console.warn('PostHog Toolbar API token expired. Clearing session.')
|
||||
if (values.props.source !== 'localstorage') {
|
||||
lemonToast.error('PostHog Toolbar API token expired.')
|
||||
@ -87,12 +87,14 @@ export const toolbarConfigLogic = kea<toolbarConfigLogicType>([
|
||||
afterMount(({ props, values }) => {
|
||||
if (props.instrument) {
|
||||
const distinctId = props.distinctId
|
||||
|
||||
void toolbarPosthogJS.optIn()
|
||||
|
||||
if (distinctId) {
|
||||
posthog.identify(distinctId, props.userEmail ? { email: props.userEmail } : {})
|
||||
toolbarPosthogJS.identify(distinctId, props.userEmail ? { email: props.userEmail } : {})
|
||||
}
|
||||
posthog.optIn()
|
||||
}
|
||||
posthog.capture('toolbar loaded', { is_authenticated: values.isAuthenticated })
|
||||
toolbarPosthogJS.capture('toolbar loaded', { is_authenticated: values.isAuthenticated })
|
||||
}),
|
||||
])
|
||||
|
||||
|
35
frontend/src/toolbar/toolbarPosthogJS.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { FeatureFlagKey } from 'lib/constants'
|
||||
import PostHog from 'posthog-js-lite'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const DEFAULT_API_KEY = 'sTMFPsFhdP1Ssg'
|
||||
|
||||
const runningOnPosthog = !!window.POSTHOG_APP_CONTEXT
|
||||
const apiKey = runningOnPosthog ? window.JS_POSTHOG_API_KEY : DEFAULT_API_KEY
|
||||
const apiHost = runningOnPosthog ? window.JS_POSTHOG_HOST : 'https://internal-e.posthog.com'
|
||||
|
||||
export const toolbarPosthogJS = new PostHog(apiKey || DEFAULT_API_KEY, {
|
||||
host: apiHost,
|
||||
defaultOptIn: false, // must call .optIn() before any events are sent
|
||||
persistence: 'memory', // We don't want to persist anything, all events are in-memory
|
||||
persistence_name: apiKey + '_toolbar', // We don't need this but it ensures we don't accidentally mess with the standard persistence
|
||||
preloadFeatureFlags: false,
|
||||
})
|
||||
|
||||
if (runningOnPosthog && window.JS_POSTHOG_SELF_CAPTURE) {
|
||||
toolbarPosthogJS.debug()
|
||||
}
|
||||
|
||||
export const useToolbarFeatureFlag = (flag: FeatureFlagKey, match?: string): boolean => {
|
||||
const [flagValue, setFlagValue] = useState<boolean | string | undefined>(toolbarPosthogJS.getFeatureFlag(flag))
|
||||
|
||||
useEffect(() => {
|
||||
return toolbarPosthogJS.onFeatureFlag(flag, (value) => setFlagValue(value))
|
||||
}, [flag, match])
|
||||
|
||||
if (match) {
|
||||
return flagValue === match
|
||||
}
|
||||
|
||||
return !!flagValue
|
||||
}
|
@ -7,6 +7,42 @@ export type ElementsEventType = {
|
||||
type: '$autocapture' | '$rageclick'
|
||||
}
|
||||
|
||||
export type HeatmapKind = 'click' | 'rageclick' | 'mousemove' | 'scrolldepth'
|
||||
|
||||
export type HeatmapRequestType = {
|
||||
type: HeatmapKind
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
url_exact?: string
|
||||
url_pattern?: string
|
||||
viewport_width_min?: number
|
||||
viewport_width_max?: number
|
||||
aggregation: 'total_count' | 'unique_visitors'
|
||||
}
|
||||
|
||||
export type HeatmapResponseType = {
|
||||
results: (
|
||||
| {
|
||||
count: number
|
||||
pointer_relative_x: number
|
||||
pointer_target_fixed: boolean
|
||||
pointer_y: number
|
||||
}
|
||||
| {
|
||||
scroll_depth_bucket: number
|
||||
bucket_count: number
|
||||
cumulative_count: number
|
||||
}
|
||||
)[]
|
||||
}
|
||||
|
||||
export type HeatmapElement = {
|
||||
count: number
|
||||
xPercentage: number
|
||||
targetFixed: boolean
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface CountedHTMLElement {
|
||||
count: number // total of types of clicks
|
||||
clickCount: number // autocapture clicks
|
||||
@ -44,12 +80,6 @@ export interface ActionElementWithMetadata extends ElementWithMetadata {
|
||||
step?: ActionStepType
|
||||
}
|
||||
|
||||
export type BoxColor = {
|
||||
backgroundBlendMode: string
|
||||
background: string
|
||||
boxShadow: string
|
||||
}
|
||||
|
||||
export type ActionDraftType = Omit<ActionType, 'id' | 'created_at' | 'created_by'>
|
||||
|
||||
export interface ActionStepForm extends ActionStepType {
|
||||
|
@ -2,8 +2,9 @@ import { finder } from '@medv/finder'
|
||||
import { CLICK_TARGET_SELECTOR, CLICK_TARGETS, escapeRegex, TAGS_TO_IGNORE } from 'lib/actionUtils'
|
||||
import { cssEscape } from 'lib/utils/cssEscape'
|
||||
import { querySelectorAllDeep } from 'query-selector-shadow-dom'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
import { ActionStepForm, BoxColor, ElementRect } from '~/toolbar/types'
|
||||
import { ActionStepForm, ElementRect } from '~/toolbar/types'
|
||||
import { ActionStepType, StringMatching } from '~/types'
|
||||
|
||||
export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__'
|
||||
@ -246,26 +247,21 @@ export function getElementForStep(step: ActionStepForm, allElements?: HTMLElemen
|
||||
return null
|
||||
}
|
||||
|
||||
export function getBoxColors(color: 'blue' | 'red' | 'green', hover = false, opacity = 0.2): BoxColor | undefined {
|
||||
export function getBoxColors(color: 'blue' | 'red' | 'green', hover = false, opacity = 0.2): CSSProperties | undefined {
|
||||
if (color === 'blue') {
|
||||
return {
|
||||
backgroundBlendMode: 'multiply',
|
||||
background: `hsla(240, 90%, 58%, ${opacity})`,
|
||||
boxShadow: `hsla(240, 90%, 27%, 0.5) 0px 3px 10px ${hover ? 4 : 2}px`,
|
||||
boxShadow: `hsla(240, 90%, 27%, 0.2) 0px 3px 10px ${hover ? 4 : 0}px`,
|
||||
outline: `hsla(240, 90%, 58%, 0.5) solid 1px`,
|
||||
}
|
||||
}
|
||||
if (color === 'red') {
|
||||
return {
|
||||
backgroundBlendMode: 'multiply',
|
||||
background: `hsla(4, 90%, 58%, ${opacity})`,
|
||||
boxShadow: `hsla(4, 90%, 27%, 0.8) 0px 3px 10px ${hover ? 4 : 2}px`,
|
||||
}
|
||||
}
|
||||
if (color === 'green') {
|
||||
return {
|
||||
backgroundBlendMode: 'multiply',
|
||||
background: `hsla(97, 90%, 58%, ${opacity})`,
|
||||
boxShadow: `hsla(97, 90%, 27%, 0.8) 0px 3px 10px ${hover ? 4 : 2}px`,
|
||||
boxShadow: `hsla(4, 90%, 27%, 0.2) 0px 3px 10px ${hover ? 5 : 0}px`,
|
||||
outline: `hsla(4, 90%, 58%, 0.5) solid 1px`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -126,6 +126,7 @@
|
||||
"fflate": "^0.7.4",
|
||||
"fs-extra": "^10.0.0",
|
||||
"fuse.js": "^6.6.2",
|
||||
"heatmap.js": "^2.0.5",
|
||||
"husky": "^7.0.4",
|
||||
"image-blob-reduce": "^4.1.0",
|
||||
"kea": "^3.1.5",
|
||||
@ -146,7 +147,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-preset-env": "^9.3.0",
|
||||
"posthog-js": "1.128.3",
|
||||
"posthog-js-lite": "2.5.0",
|
||||
"posthog-js-lite": "3.0.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prop-types": "^15.7.2",
|
||||
"protomaps-themes-base": "2.0.0-alpha.1",
|
||||
@ -217,6 +218,7 @@
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-sankey": "^0.12.1",
|
||||
"@types/dompurify": "^3.0.3",
|
||||
"@types/heatmap.js": "^2.0.41",
|
||||
"@types/image-blob-reduce": "^4.1.1",
|
||||
"@types/jest": "^29.2.3",
|
||||
"@types/jest-image-snapshot": "^6.1.0",
|
||||
@ -307,7 +309,8 @@
|
||||
"playwright": "1.41.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"rrweb@2.0.0-alpha.12": "patches/rrweb@2.0.0-alpha.12.patch"
|
||||
"rrweb@2.0.0-alpha.12": "patches/rrweb@2.0.0-alpha.12.patch",
|
||||
"heatmap.js@2.0.5": "patches/heatmap.js@2.0.5.patch"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
|
13
patches/heatmap.js@2.0.5.patch
Normal file
@ -0,0 +1,13 @@
|
||||
diff --git a/build/heatmap.js b/build/heatmap.js
|
||||
index 3eee39ea8c127b065fb4df763ab76af152e7d368..a37c950b937d04805b62832c661890931d0f3ff1 100644
|
||||
--- a/build/heatmap.js
|
||||
+++ b/build/heatmap.js
|
||||
@@ -524,7 +524,7 @@ var Canvas2dRenderer = (function Canvas2dRendererClosure() {
|
||||
|
||||
}
|
||||
|
||||
- img.data = imgData;
|
||||
+ //img.data = imgData;
|
||||
this.ctx.putImageData(img, x, y);
|
||||
|
||||
this._renderBoundaries = [1000, 1000, 0, 0];
|
@ -1,4 +1,4 @@
|
||||
lockfileVersion: '6.0'
|
||||
lockfileVersion: '6.1'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
@ -8,6 +8,9 @@ overrides:
|
||||
playwright: 1.41.2
|
||||
|
||||
patchedDependencies:
|
||||
heatmap.js@2.0.5:
|
||||
hash: gydrxrztd4ruyhouu6tu7zh43e
|
||||
path: patches/heatmap.js@2.0.5.patch
|
||||
rrweb@2.0.0-alpha.12:
|
||||
hash: t3xxecww6aodjl4qopwv6jdxmq
|
||||
path: patches/rrweb@2.0.0-alpha.12.patch
|
||||
@ -196,6 +199,9 @@ dependencies:
|
||||
fuse.js:
|
||||
specifier: ^6.6.2
|
||||
version: 6.6.2
|
||||
heatmap.js:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5(patch_hash=gydrxrztd4ruyhouu6tu7zh43e)
|
||||
husky:
|
||||
specifier: ^7.0.4
|
||||
version: 7.0.4
|
||||
@ -257,8 +263,8 @@ dependencies:
|
||||
specifier: 1.128.3
|
||||
version: 1.128.3
|
||||
posthog-js-lite:
|
||||
specifier: 2.5.0
|
||||
version: 2.5.0
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
prettier:
|
||||
specifier: ^2.8.8
|
||||
version: 2.8.8
|
||||
@ -350,7 +356,7 @@ dependencies:
|
||||
optionalDependencies:
|
||||
fsevents:
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.3
|
||||
version: 2.3.2
|
||||
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
@ -467,6 +473,9 @@ devDependencies:
|
||||
'@types/dompurify':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@types/heatmap.js':
|
||||
specifier: ^2.0.41
|
||||
version: 2.0.41
|
||||
'@types/image-blob-reduce':
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
@ -7734,7 +7743,7 @@ packages:
|
||||
resolution: {integrity: sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==}
|
||||
dependencies:
|
||||
'@types/d3-array': 3.0.3
|
||||
'@types/geojson': 7946.0.10
|
||||
'@types/geojson': 7946.0.12
|
||||
dev: true
|
||||
|
||||
/@types/d3-delaunay@6.0.1:
|
||||
@ -7776,7 +7785,7 @@ packages:
|
||||
/@types/d3-geo@3.0.2:
|
||||
resolution: {integrity: sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==}
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.10
|
||||
'@types/geojson': 7946.0.12
|
||||
dev: true
|
||||
|
||||
/@types/d3-hierarchy@3.1.0:
|
||||
@ -7985,13 +7994,8 @@ packages:
|
||||
resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==}
|
||||
dev: true
|
||||
|
||||
/@types/geojson@7946.0.10:
|
||||
resolution: {integrity: sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==}
|
||||
dev: true
|
||||
|
||||
/@types/geojson@7946.0.12:
|
||||
resolution: {integrity: sha512-uK2z1ZHJyC0nQRbuovXFt4mzXDwf27vQeUWNhfKGwRcWW429GOhP8HxUHlM6TLH4bzmlv/HlEjpvJh3JfmGsAA==}
|
||||
dev: false
|
||||
|
||||
/@types/google.maps@3.55.4:
|
||||
resolution: {integrity: sha512-Ip3IfRs3RZjeC88V8FGnWQTQXeS5gkJedPSosN6DMi9Xs8buGTpsPq6UhREoZsGH+62VoQ6jiRBUR8R77If69w==}
|
||||
@ -8009,6 +8013,12 @@ packages:
|
||||
'@types/unist': 2.0.6
|
||||
dev: false
|
||||
|
||||
/@types/heatmap.js@2.0.41:
|
||||
resolution: {integrity: sha512-3oHffxC+N+1EKXjeC65klk1kHnLJ5i6tEKFNb/04J+qSfQuCliacsNBWDpt59JfG2vBXRRn+ICbzRZj48j6HfQ==}
|
||||
dependencies:
|
||||
'@types/leaflet': 0.7.40
|
||||
dev: true
|
||||
|
||||
/@types/hogan.js@3.0.5:
|
||||
resolution: {integrity: sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==}
|
||||
dev: false
|
||||
@ -8078,6 +8088,12 @@ packages:
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
dev: true
|
||||
|
||||
/@types/leaflet@0.7.40:
|
||||
resolution: {integrity: sha512-R2UwXOKwnKZi9zNm37WbPTAVuqHmysE6NVihkc5DUrovTirUxFSbZzvXrlwv0n5sibe0w8VF1bWu0ta4kZlAaA==}
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.12
|
||||
dev: true
|
||||
|
||||
/@types/less@3.0.6:
|
||||
resolution: {integrity: sha512-PecSzorDGdabF57OBeQO/xFbAkYWo88g4Xvnsx7LRwqLC17I7OoKtA3bQB9uXkY6UkMWCOsA8HSVpaoitscdXw==}
|
||||
dev: false
|
||||
@ -12825,7 +12841,6 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/fsevents@2.3.3:
|
||||
@ -13273,6 +13288,11 @@ packages:
|
||||
resolution: {integrity: sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==}
|
||||
dev: true
|
||||
|
||||
/heatmap.js@2.0.5(patch_hash=gydrxrztd4ruyhouu6tu7zh43e):
|
||||
resolution: {integrity: sha512-CG2gYFP5Cv9IQCXEg3ZRxnJDyAilhWnQlAuHYGuWVzv6mFtQelS1bR9iN80IyDmFECbFPbg6I0LR5uAFHgCthw==}
|
||||
dev: false
|
||||
patched: true
|
||||
|
||||
/helpertypes@0.0.19:
|
||||
resolution: {integrity: sha512-J00e55zffgi3yVnUp0UdbMztNkr2PnizEkOe9URNohnrNhW5X0QpegkuLpOmFQInpi93Nb8MCjQRHAiCDF42NQ==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@ -17453,8 +17473,8 @@ packages:
|
||||
picocolors: 1.0.0
|
||||
source-map-js: 1.0.2
|
||||
|
||||
/posthog-js-lite@2.5.0:
|
||||
resolution: {integrity: sha512-Urvlp0Vu9h3td0BVFWt0QXFJDoOZcaAD83XM9d91NKMKTVPZtfU0ysoxstIf5mw/ce9ZfuMgpWPaagrZI4rmSg==}
|
||||
/posthog-js-lite@3.0.0:
|
||||
resolution: {integrity: sha512-dyajjnfzZD1tht4N7p7iwf7nBnR1MjVaVu+MKr+7gBgA39bn28wizCIJZztZPtHy4PY0YwtSGgwfBCuG/hnHgA==}
|
||||
dev: false
|
||||
|
||||
/posthog-js@1.128.3:
|
||||
|