Skip to content

Commit

Permalink
feat(onboarding-dashboard-templates): get the iframe loading (#24692)
Browse files Browse the repository at this point in the history
  • Loading branch information
raquelmsmith authored Sep 3, 2024
1 parent 842f11e commit 53e0c30
Show file tree
Hide file tree
Showing 11 changed files with 493 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { LemonBanner, Spinner } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { useEffect } from 'react'
import useResizeObserver from 'use-resize-observer'

import { ToolbarUserIntent } from '~/types'

import { appEditorUrl } from '../AuthorizedUrlList/authorizedUrlListLogic'
import { iframedToolbarBrowserLogic } from './iframedToolbarBrowserLogic'

function IframeErrorOverlay(): JSX.Element | null {
const logic = iframedToolbarBrowserLogic()
const { iframeBanner } = useValues(logic)
return iframeBanner ? (
<div className="absolute flex flex-col w-full h-full bg-blend-overlay items-start py-4 px-8 pointer-events-none">
<LemonBanner className="w-full" type={iframeBanner.level}>
{iframeBanner.message}. Your site might not allow being embedded in an iframe. You can click "Open in
toolbar" above to visit your site and view the heatmap there.
</LemonBanner>
</div>
) : null
}

function LoadingOverlay(): JSX.Element | null {
const logic = iframedToolbarBrowserLogic()
const { loading } = useValues(logic)
return loading ? (
<div className="absolute flex flex-col w-full h-full items-center justify-center pointer-events-none">
<Spinner className="text-5xl" textColored={true} />
</div>
) : null
}

export function IframedToolbarBrowser({
iframeRef,
userIntent,
}: {
iframeRef?: React.MutableRefObject<HTMLIFrameElement | null>
userIntent: ToolbarUserIntent
}): JSX.Element | null {
const logic = iframedToolbarBrowserLogic()

const { browserUrl } = useValues(logic)
const { onIframeLoad, setIframeWidth } = useActions(logic)

const { width: iframeWidth } = useResizeObserver<HTMLIFrameElement>({ ref: iframeRef })
useEffect(() => {
setIframeWidth(iframeWidth ?? null)
}, [iframeWidth])

return browserUrl ? (
<div className="relative flex-1 w-full h-full">
<IframeErrorOverlay />
<LoadingOverlay />
<iframe
ref={iframeRef}
className="w-full h-full"
src={appEditorUrl(browserUrl, {
userIntent: userIntent,
})}
// eslint-disable-next-line react/forbid-dom-props
style={{
background: '#FFF',
}}
onLoad={onIframeLoad}
// these two sandbox values are necessary so that the site and toolbar can run
// this is a very loose sandbox,
// but we specify it so that at least other capabilities are denied
sandbox="allow-scripts allow-same-origin"
// we don't allow things such as camera access though
allow=""
/>
</div>
) : null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, props, reducers, selectors } from 'kea'
import { authorizedUrlListLogic, AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import { CommonFilters, HeatmapFilters, HeatmapFixedPositionMode } from 'lib/components/heatmaps/types'
import {
calculateViewportRange,
DEFAULT_HEATMAP_FILTERS,
PostHogAppToolbarEvent,
} from 'lib/components/IframedToolbarBrowser/utils'
import { LemonBannerProps } from 'lib/lemon-ui/LemonBanner'
import posthog from 'posthog-js'
import { RefObject } from 'react'

import type { iframedToolbarBrowserLogicType } from './iframedToolbarBrowserLogicType'

export type IframedToolbarBrowserLogicProps = {
iframeRef: RefObject<HTMLIFrameElement | null>
clearBrowserUrlOnUnmount?: boolean
}

export interface IFrameBanner {
level: LemonBannerProps['type']
message: string | JSX.Element
}

export const iframedToolbarBrowserLogic = kea<iframedToolbarBrowserLogicType>([
path(['lib', 'components', 'iframedToolbarBrowser', 'iframedToolbarBrowserLogic']),
props({} as IframedToolbarBrowserLogicProps),

connect({
values: [
authorizedUrlListLogic({ actionId: null, type: AuthorizedUrlListType.TOOLBAR_URLS }),
['urlsKeyed', 'checkUrlIsAuthorized'],
],
}),

actions({
setBrowserUrl: (url: string | null) => ({ url }),
onIframeLoad: true,
sendToolbarMessage: (type: PostHogAppToolbarEvent, payload?: Record<string, any>) => ({
type,
payload,
}),
// TRICKY: duplicated with the heatmapLogic so that we can share the settings picker
patchHeatmapFilters: (filters: Partial<HeatmapFilters>) => ({ filters }),
setHeatmapColorPalette: (Palette: string | null) => ({ Palette }),
setHeatmapFixedPositionMode: (mode: HeatmapFixedPositionMode) => ({ mode }),
setCommonFilters: (filters: CommonFilters) => ({ filters }),
// TRICKY: duplication ends
setIframeWidth: (width: number | null) => ({ width }),
setIframeBanner: (banner: IFrameBanner | null) => ({ banner }),
startTrackingLoading: true,
stopTrackingLoading: true,
}),

reducers({
// they're called common filters in the toolbar because they're shared between heatmaps and clickmaps
// the name is continued here since they're passed down into the embedded iframe
commonFilters: [
{ date_from: '-7d' } as CommonFilters,
{
setCommonFilters: (_, { filters }) => filters,
},
],
heatmapColorPalette: [
'default' as string | null,
{
setHeatmapColorPalette: (_, { Palette }) => Palette,
},
],
heatmapFilters: [
DEFAULT_HEATMAP_FILTERS,
{
patchHeatmapFilters: (state, { filters }) => ({ ...state, ...filters }),
},
],
heatmapFixedPositionMode: [
'fixed' as HeatmapFixedPositionMode,
{
setHeatmapFixedPositionMode: (_, { mode }) => mode,
},
],
iframeWidth: [
null as number | null,
{
setIframeWidth: (_, { width }) => width,
},
],
browserUrl: [
null as string | null,
{ persist: true },
{
setBrowserUrl: (_, { url }) => url,
},
],
loading: [
false as boolean,
{
setBrowserUrl: (state, { url }) => (url?.trim().length ? true : state),
setIframeBanner: (state, { banner }) => (banner?.level == 'error' ? false : state),
startTrackingLoading: () => true,
stopTrackingLoading: () => false,
},
],
iframeBanner: [
null as IFrameBanner | null,
{
setIframeBanner: (_, { banner }) => banner,
},
],
}),

selectors({
isBrowserUrlAuthorized: [
(s) => [s.browserUrl, s.checkUrlIsAuthorized],
(browserUrl, checkUrlIsAuthorized) => {
if (!browserUrl) {
return false
}
return checkUrlIsAuthorized(browserUrl)
},
],

viewportRange: [
(s) => [s.heatmapFilters, s.iframeWidth],
(heatmapFilters, iframeWidth) => {
return iframeWidth ? calculateViewportRange(heatmapFilters, iframeWidth) : { min: 0, max: 1800 }
},
],
}),

listeners(({ actions, cache, props, values }) => ({
sendToolbarMessage: ({ type, payload }) => {
props.iframeRef?.current?.contentWindow?.postMessage(
{
type,
payload,
},
'*'
)
},

patchHeatmapFilters: ({ filters }) => {
actions.sendToolbarMessage(PostHogAppToolbarEvent.PH_PATCH_HEATMAP_FILTERS, { filters })
},

setHeatmapFixedPositionMode: ({ mode }) => {
actions.sendToolbarMessage(PostHogAppToolbarEvent.PH_HEATMAPS_FIXED_POSITION_MODE, {
fixedPositionMode: mode,
})
},

setHeatmapColorPalette: ({ Palette }) => {
actions.sendToolbarMessage(PostHogAppToolbarEvent.PH_HEATMAPS_COLOR_PALETTE, {
colorPalette: Palette,
})
},

setCommonFilters: ({ filters }) => {
actions.sendToolbarMessage(PostHogAppToolbarEvent.PH_HEATMAPS_COMMON_FILTERS, { commonFilters: filters })
},

onIframeLoad: () => {
// we get this callback whether the iframe loaded successfully or not
// and don't get a signal if the load was successful, so we have to check
// but there's no slam dunk way to do that

const init = (): void => {
actions.sendToolbarMessage(PostHogAppToolbarEvent.PH_APP_INIT, {
filters: values.heatmapFilters,
colorPalette: values.heatmapColorPalette,
fixedPositionMode: values.heatmapFixedPositionMode,
commonFilters: values.commonFilters,
})
actions.sendToolbarMessage(PostHogAppToolbarEvent.PH_HEATMAPS_CONFIG, {
enabled: true,
})
}

const onIframeMessage = (e: MessageEvent): void => {
const type: PostHogAppToolbarEvent = e?.data?.type

if (!type || !type.startsWith('ph-')) {
return
}
if (!values.checkUrlIsAuthorized(e.origin)) {
console.warn(
'ignoring message from iframe with origin not in authorized toolbar urls',
e.origin,
e.data
)
return
}

switch (type) {
case PostHogAppToolbarEvent.PH_TOOLBAR_INIT:
return init()
case PostHogAppToolbarEvent.PH_TOOLBAR_READY:
posthog.capture('in-app heatmap frame loaded', {
inapp_heatmap_page_url_visited: values.browserUrl,
inapp_heatmap_filters: values.heatmapFilters,
inapp_heatmap_color_palette: values.heatmapColorPalette,
inapp_heatmap_fixed_position_mode: values.heatmapFixedPositionMode,
})
// reset loading tracking - if we're e.g. slow this will avoid a flash of warning message
return actions.startTrackingLoading()
case PostHogAppToolbarEvent.PH_TOOLBAR_HEATMAP_LOADING:
return actions.startTrackingLoading()
case PostHogAppToolbarEvent.PH_TOOLBAR_HEATMAP_LOADED:
posthog.capture('in-app heatmap loaded', {
inapp_heatmap_page_url_visited: values.browserUrl,
inapp_heatmap_filters: values.heatmapFilters,
inapp_heatmap_color_palette: values.heatmapColorPalette,
inapp_heatmap_fixed_position_mode: values.heatmapFixedPositionMode,
})
return actions.stopTrackingLoading()
case PostHogAppToolbarEvent.PH_TOOLBAR_HEATMAP_FAILED:
posthog.capture('in-app heatmap failed', {
inapp_heatmap_page_url_visited: values.browserUrl,
inapp_heatmap_filters: values.heatmapFilters,
inapp_heatmap_color_palette: values.heatmapColorPalette,
inapp_heatmap_fixed_position_mode: values.heatmapFixedPositionMode,
})
actions.stopTrackingLoading()
actions.setIframeBanner({ level: 'error', message: 'The heatmap failed to load.' })
return
default:
console.warn(`[PostHog Heatmaps] Received unknown child window message: ${type}`)
}
}

window.addEventListener('message', onIframeMessage, false)
// We call init in case the toolbar got there first (unlikely)
init()
},

setBrowserUrl: ({ url }) => {
if (url?.trim().length) {
actions.startTrackingLoading()
}
},

startTrackingLoading: () => {
actions.setIframeBanner(null)

clearTimeout(cache.errorTimeout)
cache.errorTimeout = setTimeout(() => {
actions.setIframeBanner({ level: 'error', message: 'The heatmap failed to load (or is very slow).' })
}, 7500)

clearTimeout(cache.warnTimeout)
cache.warnTimeout = setTimeout(() => {
actions.setIframeBanner({ level: 'warning', message: 'Still waiting for the toolbar to load.' })
}, 3000)
},

stopTrackingLoading: () => {
actions.setIframeBanner(null)

clearTimeout(cache.errorTimeout)
clearTimeout(cache.warnTimeout)
},
})),

afterMount(({ actions, values }) => {
if (values.browserUrl?.trim().length) {
actions.startTrackingLoading()
}
}),
beforeUnmount(({ actions, props }) => {
props.clearBrowserUrlOnUnmount && actions.setBrowserUrl('')
}),
])
2 changes: 1 addition & 1 deletion frontend/src/scenes/heatmaps/HeatmapsBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUr
import { appEditorUrl, AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
import { HeatmapsSettings } from 'lib/components/heatmaps/HeatMapsSettings'
import { heatmapDateOptions } from 'lib/components/heatmaps/utils'
import { DetectiveHog } from 'lib/components/hedgehogs'
import { heatmapDateOptions } from 'lib/components/IframedToolbarBrowser/utils'
import { useResizeObserver } from 'lib/hooks/useResizeObserver'
import { IconChevronRight, IconOpenInNew } from 'lib/lemon-ui/icons'
import React, { useEffect, useRef } from 'react'
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/scenes/heatmaps/heatmapsBrowserLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { actionToUrl, router, urlToAction } from 'kea-router'
import api from 'lib/api'
import { authorizedUrlListLogic, AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import { CommonFilters, HeatmapFilters, HeatmapFixedPositionMode } from 'lib/components/heatmaps/types'
import { calculateViewportRange, DEFAULT_HEATMAP_FILTERS, PostHogAppToolbarEvent } from 'lib/components/heatmaps/utils'
import {
calculateViewportRange,
DEFAULT_HEATMAP_FILTERS,
PostHogAppToolbarEvent,
} from 'lib/components/IframedToolbarBrowser/utils'
import { LemonBannerProps } from 'lib/lemon-ui/LemonBanner'
import { objectsEqual } from 'lib/utils'
import posthog from 'posthog-js'
Expand Down
Loading

0 comments on commit 53e0c30

Please sign in to comment.