Merge pull request #111 from openreplay/heatmaps-ui

Heatmaps UI
This commit is contained in:
Shekar Siri 2021-08-04 13:50:02 +05:30 committed by GitHub
commit 6045f1c7f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 33638 additions and 309 deletions

View file

@ -2,10 +2,13 @@ import { configure, addDecorator } from '@storybook/react';
import { Provider } from 'react-redux';
import store from '../app/store';
import { MemoryRouter } from "react-router"
import { PlayerProvider } from '../app/player/store'
const withProvider = (story) => (
<Provider store={store}>
{ story() }
<PlayerProvider>
{ story() }
</PlayerProvider>
</Provider>
)

View file

@ -21,7 +21,8 @@ const siteIdRequiredPaths = [
'/sourcemaps',
'/errors',
'/funnels',
'/assist'
'/assist',
'/heatmaps'
];
const noStoringFetchPathStarts = [

View file

@ -109,7 +109,7 @@ const FunnelHeader = (props) => {
endDate={funnelFilters.endDate}
onDateChange={onDateChange}
customRangeRight
/>
/>
</div>
</div>
</div>

View file

@ -31,12 +31,7 @@ function Layout({ children, player, toolbar }) {
</div>
{ !player.fullscreen.enabled && <ToolPanel player={ player } toolbar={ toolbar }/> }
</div>
{ !player.fullscreen.enabled &&
<Events
style={{ width: "270px" }}
player={ player }
/>
}
</div>
</div>
);

View file

@ -0,0 +1,43 @@
import React, { useState } from 'react'
import EventsBlock from '../Session_/EventsBlock';
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel'
import { Controls as PlayerControls } from 'Player';
import { Tabs } from 'UI';
import { connectPlayer } from 'Player';
const EVENTS = 'Events';
const HEATMAPS = 'Heatmaps';
const TABS = [ EVENTS, HEATMAPS ].map(tab => ({ text: tab, key: tab }));
const EventsBlockConnected = connectPlayer(state => ({
currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0,
playing: state.playing,
}))(EventsBlock)
export default function RightBlock() {
const [activeTab, setActiveTab] = useState(EVENTS)
const renderActiveTab = (tab) => {
switch(tab) {
case EVENTS:
return <EventsBlockConnected player={PlayerControls}/>
case HEATMAPS:
return <PageInsightsPanel />
}
}
return (
<div style={{ width: '270px', height: 'calc(100vh- 50px)'}} className="flex flex-col">
<Tabs
tabs={ TABS }
active={ activeTab }
onClick={ (tab) => setActiveTab(tab) }
border={ true }
/>
{
renderActiveTab(activeTab)
}
</div>
)
}

View file

@ -8,22 +8,13 @@ import {
init as initPlayer,
clean as cleanPlayer,
} from 'Player';
import { Controls as PlayerControls, toggleEvents } from 'Player';
import cn from 'classnames'
import RightBlock from './RightBlock'
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
import EventsBlock from '../Session_/EventsBlock';
import PlayerBlock from '../Session_/PlayerBlock';
import styles from '../Session_/session.css';
import EventsToggleButton from './EventsToggleButton';
const EventsBlockConnected = connectPlayer(state => ({
currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0,
playing: state.playing,
}))(EventsBlock)
const InitLoader = connectPlayer(state => ({
@ -32,14 +23,14 @@ const InitLoader = connectPlayer(state => ({
const PlayerContentConnected = connectPlayer(state => ({
showEvents: !state.showEvents
}), { toggleEvents })(PlayerContent);
}))(PlayerContent);
function PlayerContent({ live, fullscreen, showEvents, toggleEvents }) {
function PlayerContent({ live, fullscreen, showEvents }) {
return (
<div className={ cn(styles.session, 'relative') } data-fullscreen={fullscreen}>
<PlayerBlock />
{ showEvents && !live && !fullscreen && <EventsBlockConnected player={PlayerControls}/> }
{ showEvents && !live && !fullscreen && <RightBlock /> }
</div>
)
}

View file

@ -1 +0,0 @@
export { default } from './AutoplayTimer'

View file

@ -6,7 +6,7 @@ export default function EventSearch(props) {
const [showSearch, setShowSearch] = useState(false)
return (
<div className="flex items-center w-full">
<div className="flex-1 relative">
<div className="flex flex-1 relative items-center" style={{ height: '32px' }}>
{ showSearch ?
<div className="flex items-center">
<Input

View file

@ -1,7 +1,6 @@
import { connect } from 'react-redux';
import cn from 'classnames';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized";
import { Avatar, Input, Dropdown, Icon } from 'UI';
import { TYPES } from 'Types/session/event';
import { setSelected } from 'Duck/events';
import { setEventFilter } from 'Duck/sessions';
@ -18,8 +17,7 @@ import EventSearch from './EventSearch/EventSearch';
eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]),
selectedEvents: state.getIn([ 'events', 'selected' ]),
targetDefinerDisplayed: state.getIn([ 'components', 'targetDefiner', 'isDisplayed' ]),
testsAvaliable: false,
//state.getIn([ 'user', 'account', 'appearance', 'tests' ]),
testsAvaliable: false,
}), {
showTargetDefiner,
setSelected,
@ -74,9 +72,6 @@ export default class EventsBlock extends React.PureComponent {
this.setState({ editingEvent: null });
}
if (prevProps.session !== this.props.session) { // Doesn't happen
// this.setState({
// groups: groupEvents(this.props.session.events),
// });
this.cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 300
@ -148,8 +143,7 @@ export default class EventsBlock extends React.PureComponent {
<CellMeasurer
key={key}
cache={this.cache}
parent={parent}
//columnIndex={0}
parent={parent}
rowIndex={index}
>
{({measure, registerChild}) => (
@ -176,14 +170,12 @@ export default class EventsBlock extends React.PureComponent {
render() {
const { query } = this.state;
const {
playing,
const {
testsAvaliable,
session: {
events,
userNumericHash,
userDisplayName,
userUuid,
userDisplayName,
userId,
userAnonymousId
},
@ -193,7 +185,7 @@ export default class EventsBlock extends React.PureComponent {
const _events = filteredEvents || events;
return (
<div className={ cn("flex flex-col", styles.eventsBlock) }>
<>
<div className={ cn(styles.header, 'p-3') }>
<UserCard
className=""
@ -203,8 +195,7 @@ export default class EventsBlock extends React.PureComponent {
userAnonymousId={userAnonymousId}
/>
<div className={ cn(styles.hAndProgress, 'mt-3') }>
{/* <div className="text-lg">{ `User Events (${ events.size })` }</div> */}
<div className={ cn(styles.hAndProgress, 'mt-3') }>
<EventSearch
onChange={this.write}
clearSearch={this.clearSearch}
@ -213,29 +204,7 @@ export default class EventsBlock extends React.PureComponent {
<div className="text-lg">{ `User Events (${ events.size })` }</div>
}
/>
</div>
<div className="flex mt-3">
{/* <Dropdown
trigger={
<div className={cn("py-3 px-3 bg-white flex items-center text-sm mb-2 border rounded ml-2")} style={{ height: '32px' }}>
<Icon name="filter" size="12" color="teal" />
</div>
}
options={ [
// { text: 'Visited', value: TYPES.LOCATION },
{ text: 'Clicked', value: TYPES.CLICK },
{ text: 'Input', value: TYPES.INPUT },
] }
name="filter"
icon={null}
onChange={this.onSetEventFilter}
basic
direction="left"
scrolling
selectOnBlur={true}
closeOnChange={true}
/> */}
</div>
</div>
</div>
<div
className={ cn("flex-1 px-3 pb-3", styles.eventsList) }
@ -263,7 +232,7 @@ export default class EventsBlock extends React.PureComponent {
</AutoSizer>
</div>
{ testsAvaliable && <AutomateButton /> }
</div>
</>
);
}
}

View file

@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react'
import { Dropdown, Loader } from 'UI'
import DateRange from 'Shared/DateRange';
import { connect } from 'react-redux';
import { fetchInsights } from 'Duck/sessions';
import SelectorsList from './components/SelectorsList/SelectorsList';
import { markTargets, Controls as Player } from 'Player';
const JUMP_OFFSET = 1000;
interface Props {
filters: any
fetchInsights: (filters) => void
urls: []
insights: any
events: Array<any>
urlOptions: Array<any>
loading: boolean
}
function PageInsightsPanel({ filters, fetchInsights, events = [], insights, urlOptions, loading = true }: Props) {
const [insightsFilters, setInsightsFilters] = useState(filters)
const onDateChange = (e) => {
setInsightsFilters({ ...insightsFilters, startDate: e.startDate, endDate: e.endDate })
}
useEffect(() => {
markTargets(insights.toJS());
return () => {
markTargets(null)
}
}, [insights])
useEffect(() => {
const url = insightsFilters.url ? insightsFilters.url : urlOptions[0].value
fetchInsights({ ...insightsFilters, url })
}, [insightsFilters])
const onPageSelect = (e, { name, value }) => {
const event = events.find(item => item.url === value)
Player.jump(event.time + JUMP_OFFSET)
setInsightsFilters({ ...insightsFilters, url: value })
markTargets([])
};
return (
<div className="px-4 bg-gray-lightest">
<div className="my-3 flex -ml-2">
<DateRange
// rangeValue={filters.rangeValue}
startDate={filters.startDate}
endDate={filters.endDate}
onDateChange={onDateChange}
customRangeRight
/>
</div>
<div className="mb-4 flex items-center">
<div className="mr-2 flex-shrink-0">In Page</div>
<Dropdown
labeled
placeholder="change"
selection
options={ urlOptions }
name="url"
defaultValue={urlOptions[0].value}
onChange={ onPageSelect }
id="change-dropdown"
className="customDropdown"
style={{ minWidth: '80px', width: '100%' }}
/>
</div>
<Loader loading={ loading }>
<SelectorsList />
</Loader>
</div>
)
}
export default connect(state => {
const events = state.getIn([ 'sessions', 'visitedEvents' ])
return {
filters: state.getIn(['sessions', 'insightFilters']),
insights: state.getIn([ 'sessions', 'insights' ]),
events: events,
urlOptions: events.map(({ url }) => ({ text: url, value: url})),
loading: state.getIn([ 'sessions', 'fetchInsightsRequest', 'loading' ]),
}
}, { fetchInsights })(PageInsightsPanel);

View file

@ -0,0 +1,44 @@
.wrapper {
padding: 10px;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
border-radius: 3px;
background-color: white;
margin-bottom: 15px;
& .top {
display: flex;
cursor: pointer;
user-select: none;
}
& .index {
margin-right: 10px;
width: 20px;
height: 20px;
border-radius: 10px;
background-color: $tealx;
flex-shrink: 0;
border: solid thin white;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: white;
}
& .counts {
text-align: center;
padding: 5px;
margin: 20px 0;
& div:first-child {
font-size: 18px;
margin-bottom: 5px;
}
}
}
.active {
background-color: #f9ffff;
}

View file

@ -0,0 +1,28 @@
import React, { useState } from 'react'
import stl from './SelectorCard.css'
import cn from 'classnames';
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
import { activeTarget } from 'Player';
interface Props {
index?: number,
target: MarkedTarget,
showContent: boolean
}
export default function SelectorCard({ index = 1, target, showContent } : Props) {
return (
<div className={cn(stl.wrapper, { [stl.active]: showContent })}>
<div className={stl.top} onClick={() => activeTarget(index)}>
<div className={stl.index}>{index + 1}</div>
<div className="truncate">{target.selector}</div>
</div>
{ showContent && (
<div className={stl.counts}>
<div>{target.count} Clicks - {target.percent}%</div>
<div className="color-gray-medium">TOTAL CLICKS</div>
</div>
) }
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './SelectorCard'

View file

@ -0,0 +1,30 @@
import React, { useState } from 'react'
import { NoContent } from 'UI'
import { connectPlayer } from 'Player/store';
import SelectorCard from '../SelectorCard/SelectorCard';
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
interface Props {
targets: Array<MarkedTarget>,
activeTargetIndex: number
}
function SelectorsList({ targets, activeTargetIndex }: Props) {
return (
<NoContent
title="No data available."
size="small"
show={ targets && targets.length === 0 }
>
{ targets && targets.map((target, index) => (
<SelectorCard target={target} index={index} showContent={activeTargetIndex === index} />
))}
</NoContent>
)
}
export default connectPlayer(state => ({
targets: state.markedTargets,
activeTargetIndex: state.activeTargetIndex,
}))(SelectorsList)

View file

@ -0,0 +1 @@
export { default } from './SelectorsList'

View file

@ -0,0 +1 @@
export { default } from './PageInsightsPanel'

View file

@ -0,0 +1,116 @@
import { storiesOf } from '@storybook/react';
import { List } from 'immutable';
import PageInsightsPanel from './';
const list = [
{
"alertId": 2,
"projectId": 1,
"name": "new alert",
"description": null,
"active": true,
"threshold": 240,
"detectionMethod": "threshold",
"query": {
"left": "avgPageLoad",
"right": 1.0,
"operator": ">="
},
"createdAt": 1591893324078,
"options": {
"message": [
{
"type": "slack",
"value": "51"
},
],
"LastNotification": 1592929583000,
"renotifyInterval": 120
}
},
{
"alertId": 14,
"projectId": 1,
"name": "alert 19.06",
"description": null,
"active": true,
"threshold": 30,
"detectionMethod": "threshold",
"query": {
"left": "avgPageLoad",
"right": 3000.0,
"operator": ">="
},
"createdAt": 1592579750935,
"options": {
"message": [
{
"type": "slack",
"value": "51"
}
],
"renotifyInterval": 120
}
},
{
"alertId": 15,
"projectId": 1,
"name": "notify every 60min",
"description": null,
"active": true,
"threshold": 30,
"detectionMethod": "threshold",
"query": {
"left": "avgPageLoad",
"right": 1.0,
"operator": ">="
},
"createdAt": 1592848779604,
"options": {
"message": [
{
"type": "slack",
"value": "51"
},
],
"LastNotification": 1599135058000,
"renotifyInterval": 60
}
},
{
"alertId": 21,
"projectId": 1,
"name": "always notify",
"description": null,
"active": true,
"threshold": 30,
"detectionMethod": "threshold",
"query": {
"left": "avgPageLoad",
"right": 1.0,
"operator": ">="
},
"createdAt": 1592849011350,
"options": {
"message": [
{
"type": "slack",
"value": "51"
}
],
"LastNotification": 1599135058000,
"renotifyInterval": 10
}
}
]
const notifications = List([
{ title: 'test', type: 'change', createdAt: 1591893324078, description: 'Lorem ipusm'},
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
])
storiesOf('PageInsights', module)
.add('Panel', () => (
<PageInsightsPanel />
))

View file

@ -73,7 +73,7 @@ function getStorageName(type) {
skip: state.skip,
skipToIssue: state.skipToIssue,
speed: state.speed,
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode,
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode || state.markedTargets,
inspectorMode: state.inspectorMode,
fullscreenDisabled: state.messagesLoading,
logCount: state.logListNow.length,

View file

@ -0,0 +1,79 @@
import React, {useEffect} from 'react';
import { connectPlayer, markTargets } from 'Player';
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
import AutoplayTimer from './Overlay/AutoplayTimer';
import PlayIconLayer from './Overlay/PlayIconLayer';
import LiveStatusText from './Overlay/LiveStatusText';
import Loader from './Overlay/Loader';
import ElementsMarker from './Overlay/ElementsMarker';
interface Props {
playing: boolean,
completed: boolean,
inspectorMode: boolean,
messagesLoading: boolean,
loading: boolean,
live: boolean,
liveStatusText: string,
autoplay: boolean,
markedTargets: MarkedTarget[] | null,
activeTargetIndex: number,
nextId: string,
togglePlay: () => void,
}
function Overlay({
playing,
completed,
inspectorMode,
messagesLoading,
loading,
live,
liveStatusText,
autoplay,
markedTargets,
activeTargetIndex,
nextId,
togglePlay,
}: Props) {
// useEffect(() =>{
// setTimeout(() => markTargets([{ selector: 'div', count:6}]), 5000)
// setTimeout(() => markTargets(null), 8000)
// },[])
const showAutoplayTimer = !live && completed && autoplay && nextId
const showPlayIconLayer = !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer;
const showLiveStatusText = live && liveStatusText && !loading;
return (
<>
{ showAutoplayTimer && <AutoplayTimer /> }
{ showLiveStatusText &&
<LiveStatusText text={liveStatusText} />
}
{ messagesLoading && <Loader/> }
{ showPlayIconLayer &&
<PlayIconLayer playing={playing} togglePlay={togglePlay} />
}
{ markedTargets && <ElementsMarker targets={ markedTargets } activeIndex={activeTargetIndex}/>
}
</>
);
}
export default connectPlayer(state => ({
playing: state.playing,
messagesLoading: state.messagesLoading,
loading: state.messagesLoading || state.cssLoading,
completed: state.completed,
autoplay: state.autoplay,
live: state.live,
liveStatusText: getStatusText(state.peerConnectionStatus),
markedTargets: state.markedTargets,
activeTargetIndex: state.activeTargetIndex,
}))(Overlay);

View file

@ -0,0 +1,3 @@
.overlayBg {
background-color: rgba(255, 255, 255, 0.8);
}

View file

@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react'
import cn from 'classnames';
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom';
import { Button, Link } from 'UI'
import { session as sessionRoute, withSiteId } from 'App/routes'
import stl from './AutoplayTimer.css'
import { withRouter } from 'react-router-dom';
import stl from './AutoplayTimer.css';
import clsOv from './overlay.css';
function AutoplayTimer({ nextId, siteId, history }) {
let timer
@ -33,7 +35,7 @@ function AutoplayTimer({ nextId, siteId, history }) {
return ''
return (
<div className={stl.overlay}>
<div className={ cn(clsOv.overlay, stl.overlayBg) } >
<div className="border p-6 shadow-lg bg-white rounded">
<div className="py-4">Next recording will be played in {counter}s</div>
<div className="flex items-center">

View file

@ -0,0 +1,9 @@
import React from 'react';
import Marker from './ElementsMarker/Marker';
export default function ElementsMarker({ targets, activeIndex }) {
return targets && targets.map(t => <Marker target={t} active={activeIndex === t.index}/>)
}

View file

@ -0,0 +1,65 @@
.marker {
position: absolute;
z-index: 100;
border: 2px dotted transparent;
cursor: pointer;
user-select: none;
&:hover {
& .index {
opacity: 1;
transition: 0.3s;
}
}
& .index {
opacity: 0.3;
position: absolute;
top: -10px;
left: -10px;
width: 20px;
height: 20px;
border-radius: 10px;
background-color: $tealx;
flex-shrink: 0;
border: solid thin white;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: white;
}
& .tooltip {
padding: 10px;
border-radius: 3px;
background-color: $tealx;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
font-size: 12px;
/* position: absolute; */
/* bottom: 100%; */
/* left: 0; */
/* margin-bottom: 20px; */
color: white;
&::before {
top: 100%;
left: 40%;
border-color: $tealx transparent transparent transparent;
content: "";
display: block;
border-style: solid;
border-width: 10px 10px 10px 10px;
position: absolute;
}
}
}
.active {
border: 2px dotted $tealx;
& .index {
opacity: 1
}
}

View file

@ -0,0 +1,37 @@
import React from 'react';
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
import { Tooltip } from 'react-tippy';
import cn from 'classnames';
import stl from './Marker.css';
import { activeTarget } from 'Player';
interface Props {
target: MarkedTarget;
active: boolean;
}
export default function Marker({ target, active }: Props) {
const style = {
top: `${ target.boundingRect.top }px`,
left: `${ target.boundingRect.left }px`,
width: `${ target.boundingRect.width }px`,
height: `${ target.boundingRect.height }px`,
}
return (
<div className={ cn(stl.marker, { [stl.active] : active }) } style={ style } onClick={() => activeTarget(target.index)}>
<div className={stl.index}>{target.index + 1}</div>
<Tooltip
open={active}
arrow
sticky
distance={15}
html={(
<div>{target.count} Clicks</div>
)}
trigger="mouseenter"
>
<div className="absolute inset-0"></div>
</Tooltip>
</div>
)
}

View file

@ -0,0 +1,4 @@
.text {
color: $gray-light;
font-size: 40px;
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import stl from './LiveStatusText.css';
import ovStl from './overlay.css';
interface Props {
text: string;
}
export default function LiveStatusText({ text }: Props) {
return <div className={ovStl.overlay}><span className={stl.text}>{text}</span></div>
}

View file

@ -0,0 +1,7 @@
import React from 'react';
import { Loader } from 'UI';
import ovStl from './overlay.css';
export default function OverlayLoader() {
return <div className={ovStl.overlay}><Loader loading /></div>
}

View file

@ -0,0 +1,17 @@
.iconWrapper {
background-color: rgba(0, 0, 0, 0.1);
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0;
transition: all .2s; /* Animation */
}
.zoomIcon {
opacity: 1;
transform: scale(1.8);
transition: all .8s;
}

View file

@ -0,0 +1,38 @@
import React, { useState, useCallback } from 'react';
import cn from 'classnames';
import { Icon } from 'UI';
import cls from './PlayIconLayer.css';
import clsOv from './overlay.css';
interface Props {
togglePlay: () => void,
playing: boolean,
}
export default function PlayIconLayer({ playing, togglePlay }: Props) {
const [ showPlayOverlayIcon, setShowPlayOverlayIcon ] = useState(false);
const togglePlayAnimated = useCallback(() => {
setShowPlayOverlayIcon(true);
togglePlay();
setTimeout(
() => setShowPlayOverlayIcon(false),
800,
);
}, []);
return (
<div className={ clsOv.overlay } onClick={ togglePlayAnimated }>
<div
className={ cn(cls.iconWrapper, {
[ cls.zoomIcon ]: showPlayOverlayIcon
}) }
>
<Icon
name={ playing ? "play" : "pause" }
color="gray-medium"
size={30}
/>
</div>
</div>
)
}

View file

@ -1,8 +1,3 @@
.wrapper {
width: 30%;
height: 30%;
}
.overlay {
position: absolute;
top: 0;
@ -13,5 +8,4 @@
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
}
}

View file

@ -2,29 +2,17 @@ import { connect } from 'react-redux';
import { findDOMNode } from 'react-dom';
import cn from 'classnames';
import { Loader, IconButton, EscapeButton } from 'UI';
import { hide as hideTargetDefiner, toggleInspectorMode } from 'Duck/components/targetDefiner';
import { hide as hideTargetDefiner } from 'Duck/components/targetDefiner';
import { fullscreenOff } from 'Duck/components/player';
import withOverlay from 'Components/hocs/withOverlay';
import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player';
import Controls from './Controls';
import Overlay from './Overlay';
import stl from './player.css';
import AutoplayTimer from '../AutoplayTimer';
import EventsToggleButton from '../../Session/EventsToggleButton';
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screenWrapper } />));
@connectPlayer(state => ({
playing: state.playing,
loading: state.messagesLoading,
disconnected: state.disconnected,
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode,
removeOverlay: !state.messagesLoading && state.inspectorMode || state.live,
completed: state.completed,
autoplay: state.autoplay,
live: state.live,
liveStatusText: getStatusText(state.peerConnectionStatus),
}))
@connect(state => ({
//session: state.getIn([ 'sessions', 'current' ]),
@ -32,15 +20,9 @@ const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screen
nextId: state.getIn([ 'sessions', 'nextId' ]),
}), {
hideTargetDefiner,
toggleInspectorMode: () => toggleInspectorMode(false),
fullscreenOff,
})
export default class Player extends React.PureComponent {
state = {
showPlayOverlayIcon: false,
startedToPlayAt: Date.now(),
};
screenWrapper = React.createRef();
componentDidMount() {
@ -48,69 +30,14 @@ export default class Player extends React.PureComponent {
attachPlayer(parentElement);
}
componentDidUpdate(prevProps) {
if (prevProps.targetSelector !== this.props.targetSelector) {
PlayerControls.mark(this.props.targetSelector);
}
if (prevProps.playing !== this.props.playing) {
if (this.props.playing) {
this.setState({ startedToPlayAt: Date.now() });
} else {
this.updateWatchingTime();
}
}
}
componentWillUnmount() {
if (this.props.playing) {
this.updateWatchingTime();
}
}
updateWatchingTime() {
const diff = Date.now() - this.state.startedToPlayAt;
}
// onTargetClick = (targetPath) => {
// const { targetCustomList, location } = this.props;
// const targetCustomFromList = targetCustomList !== this.props.targetSelector
// .find(({ path }) => path === targetPath);
// const target = targetCustomFromList
// ? targetCustomFromList.set('location', location)
// : { path: targetPath, isCustom: true, location };
// this.props.showTargetDefiner(target);
// }
togglePlay = () => {
this.setState({ showPlayOverlayIcon: true });
PlayerControls.togglePlay();
setTimeout(
() => this.setState({ showPlayOverlayIcon: false }),
800,
);
}
render() {
const {
showPlayOverlayIcon,
} = this.state;
const {
className,
playing,
disabled,
removeOverlay,
bottomBlockIsActive,
loading,
disconnected,
fullscreen,
fullscreenOff,
completed,
autoplay,
nextId,
live,
liveStatusText,
} = this.props;
return (
@ -120,40 +47,12 @@ export default class Player extends React.PureComponent {
>
{ fullscreen &&
<EscapeButton onClose={ fullscreenOff } />
// <IconButton
// size="18"
// className="ml-auto mb-5"
// style={{ marginTop: '-5px' }}
// onClick={ fullscreenOff }
// size="small"
// icon="close"
// label="Esc"
// />
}
{!live && !fullscreen && <EventsToggleButton /> }
<div className="relative flex-1">
{ (!removeOverlay || live && liveStatusText) &&
<div
className={ stl.overlay }
onClick={ disabled ? null : this.togglePlay }
>
{ live && liveStatusText
? <span className={stl.liveStatusText}>{liveStatusText}</span>
: <Loader loading={ loading } />
}
{ !live &&
<div
className={ cn(stl.iconWrapper, {
[ stl.zoomIcon ]: showPlayOverlayIcon
}) }
>
<div className={ playing ? stl.playIcon : stl.pauseIcon } />
</div>
}
</div>
}
{ completed && autoplay && nextId && <AutoplayTimer /> }
<ScreenWrapper
<div className="relative flex-1 overflow-hidden">
<Overlay nextId={nextId} togglePlay={PlayerControls.togglePlay} />
<div
className={ stl.screenWrapper }
ref={ this.screenWrapper }
/>
</div>

View file

@ -1,5 +1,3 @@
@import 'icons.css';
.playerBody {
background: $white;
/* border-radius: 3px; */
@ -25,61 +23,8 @@
font-weight: 200;
color: $gray-medium;
}
.overlay {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
/* &[data-protect] {
pointer-events: none;
background: $white;
opacity: 0.3;
}
*/
& .iconWrapper {
background-color: rgba(0, 0, 0, 0.1);
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0;
transition: all .2s; /* Animation */
}
& .zoomIcon {
opacity: 1;
transform: scale(1.8);
transition: all .8s;
}
& .playIcon {
@mixin icon play, $gray-medium, 30px;
}
& .pauseIcon {
@mixin icon pause, $gray-medium, 30px;
}
}
.playerView {
position: relative;
flex: 1;
}
.inspectorMode {
z-index: 99991 !important;
}
.liveStatusText {
color: $gray-light;
font-size: 40px;
}

View file

@ -1,9 +1,11 @@
.wrapper {
& .body {
display: flex;
border-bottom: solid thin $gray-light;
padding: 5px;
}
background-color: white;
outline: solid thin #CCC;
& .body {
display: flex;
border-bottom: solid thin $gray-light;
padding: 5px;
}
}
.preSelections {

View file

@ -1,3 +1,4 @@
import React from 'react';
import cn from 'classnames';
import SVG from 'UI/SVG';
import styles from './icon.css';

View file

@ -5,6 +5,8 @@ import Watchdog, { getSessionWatchdogTypes } from 'Types/watchdog';
import { clean as cleanParams } from 'App/api_client';
import withRequestState, { RequestTypes } from './requestStateCreator';
import { getRE } from 'App/utils';
import { LAST_7_DAYS } from 'Types/app/period';
import { getDateRangeFromValue } from 'App/dateRange';
const INIT = 'sessions/INIT';
@ -15,6 +17,7 @@ const FETCH_FAVORITE_LIST = new RequestTypes('sessions/FETCH_FAVORITE_LIST');
const FETCH_LIVE_LIST = new RequestTypes('sessions/FETCH_LIVE_LIST');
const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE');
const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK');
const FETCH_INSIGHTS = new RequestTypes('sessions/FETCH_INSIGHTS');
const SORT = 'sessions/SORT';
const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET';
const SET_TIMEZONE = 'sessions/SET_TIMEZONE';
@ -24,6 +27,14 @@ const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW';
const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB';
const range = getDateRangeFromValue(LAST_7_DAYS);
const defaultDateFilters = {
url: '',
rangeValue: LAST_7_DAYS,
startDate: range.start.unix() * 1000,
endDate: range.end.unix() * 1000
}
const initialState = Map({
list: List(),
sessionIds: [],
@ -39,7 +50,10 @@ const initialState = Map({
sourcemapUploaded: true,
filteredEvents: null,
showChatWindow: false,
liveSessions: List()
liveSessions: List(),
visitedEvents: List(),
insights: List(),
insightFilters: defaultDateFilters
});
const reducer = (state = initialState, action = {}) => {
@ -136,21 +150,32 @@ const reducer = (state = initialState, action = {}) => {
const session = Session(action.data);
const matching = [];
const visitedEvents = []
const tmpMap = {}
session.events.forEach(event => {
if (event.type === 'LOCATION' && !tmpMap.hasOwnProperty(event.url)) {
tmpMap[event.url] = event.url
visitedEvents.push(event)
}
})
events.forEach(({ key, operator, value }) => {
events.forEach(({ key, operator, value }) => {
session.events.forEach((e, index) => {
if (key === e.type) {
const val = (e.type === 'LOCATION' ? e.url : e.value);
if (key === e.type) {
const val = (e.type === 'LOCATION' ? e.url : e.value);
if (operator === 'is' && value === val) {
matching.push(index);
}
if (operator === 'contains' && val.includes(value)) {
matching.push(index);
}
}
}
}
})
})
return state.set('current', current.merge(session)).set('eventsIndex', matching);
return state.set('current', current.merge(session))
.set('eventsIndex', matching)
.set('visitedEvents', visitedEvents);
}
case FETCH_FAVORITE_LIST.SUCCESS:
return state
@ -202,9 +227,11 @@ const reducer = (state = initialState, action = {}) => {
.set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS())
case SET_TIMEZONE:
return state.set('timezone', action.timezone)
case TOGGLE_CHAT_WINDOW:
console.log(action)
case TOGGLE_CHAT_WINDOW:
return state.set('showChatWindow', action.state)
case FETCH_INSIGHTS.SUCCESS: 
return state.set('insights', List(action.data).sort((a, b) => b.count - a.count));
default:
return state;
}
@ -215,6 +242,7 @@ export default withRequestState({
fetchFavoriteListRequest: FETCH_FAVORITE_LIST,
toggleFavoriteRequest: TOGGLE_FAVORITE,
fetchErrorStackList: FETCH_ERROR_STACK,
fetchInsightsRequest: FETCH_INSIGHTS,
}, reducer);
function init(session) {
@ -263,6 +291,13 @@ export function fetchFavoriteList() {
};
}
export function fetchInsights(params) {
return {
types: FETCH_INSIGHTS.toArray(),
call: client => client.post('/heatmaps/url', params),
};
}
export function fetchLiveList() {
return {
types: FETCH_LIVE_LIST.toArray(),

View file

@ -24,8 +24,7 @@ const initialState = Map({
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case FETCH_LIST.SUCCESS: {
console.log(action);
case FETCH_LIST.SUCCESS: {
return state.set('list', List(action.data).map(i => {
const type = i.type === 'navigate' ? i.type : 'location';
return {...i, type: type.toUpperCase()}

View file

@ -306,9 +306,7 @@ export default class MessageDistributor extends StatedScreen {
this.pagesManager.moveReady(t).then(() => {
const lastScroll = this.scrollManager.moveToLast(t, index);
// @ts-ignore ??can't see double inheritance
if (!!lastScroll && this.window) {
// @ts-ignore
this.window.scrollTo(lastScroll.x, lastScroll.y);
}
// Moving mouse and setting :hover classes on ready view
@ -479,7 +477,6 @@ export default class MessageDistributor extends StatedScreen {
// TODO: clean managers?
clean() {
// @ts-ignore
super.clean();
//if (this._socket) this._socket.close();
update(INITIAL_STATE);

View file

@ -18,7 +18,7 @@ export const INITIAL_STATE: State = {
export default abstract class BaseScreen {
public readonly overlay: HTMLDivElement;
private readonly iframe: HTMLIFrameElement;
private readonly _screen: HTMLDivElement;
protected readonly screen: HTMLDivElement;
protected parentElement: HTMLElement | null = null;
constructor() {
const iframe = document.createElement('iframe');
@ -44,7 +44,7 @@ export default abstract class BaseScreen {
screen.className = styles.screen;
screen.appendChild(iframe);
screen.appendChild(overlay);
this._screen = screen;
this.screen = screen;
}
attach(parentElement: HTMLElement) {
@ -52,7 +52,7 @@ export default abstract class BaseScreen {
throw new Error("BaseScreen: Trying to attach an attached screen.");
}
parentElement.appendChild(this._screen);
parentElement.appendChild(this.screen);
this.parentElement = parentElement;
// parentElement.onresize = this.scale;
@ -115,29 +115,38 @@ export default abstract class BaseScreen {
return this.getElementsFromInternalPoint(this.getInternalCoordinates(point));
}
getElementBySelector(selector: string): Element | null {
if (!selector) return null;
return this.document?.querySelector(selector) || null;
}
display(flag: boolean = true) {
this._screen.style.display = flag ? '' : 'none';
this.screen.style.display = flag ? '' : 'none';
}
displayFrame(flag: boolean = true) {
this.iframe.style.display = flag ? '' : 'none';
}
private s: number = 1;
getScale() {
return this.s;
}
_scale() {
if (!this.parentElement) return;
let s = 1;
const { height, width } = getState();
const { offsetWidth, offsetHeight } = this.parentElement;
s = Math.min(offsetWidth / width, offsetHeight / height);
if (s > 1) {
s = 1;
this.s = Math.min(offsetWidth / width, offsetHeight / height);
if (this.s > 1) {
this.s = 1;
} else {
s = Math.round(s * 1e3) / 1e3;
this.s = Math.round(this.s * 1e3) / 1e3;
}
this._screen.style.transform = `scale(${ s }) translate(-50%, -50%)`;
this._screen.style.width = width + 'px';
this._screen.style.height = height + 'px';
this.screen.style.transform = `scale(${ this.s }) translate(-50%, -50%)`;
this.screen.style.width = width + 'px';
this.screen.style.height = height + 'px';
this.iframe.style.width = width + 'px';
this.iframe.style.height = height + 'px';

View file

@ -1,12 +1,33 @@
import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './Screen';
import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './Screen/Screen';
import { update, getState } from '../../store';
//export interface targetPosition
interface BoundingRect {
top: number,
left: number,
width: number,
height: number,
}
export interface MarkedTarget {
boundingRect: BoundingRect,
el: Element,
selector: string,
count: number,
index: number,
active?: boolean,
percent: number
}
export interface State extends SuperState {
messagesLoading: boolean,
cssLoading: boolean,
disconnected: boolean,
userPageLoading: boolean,
markedTargets: MarkedTarget[] | null,
activeTargetIndex: number
}
export const INITIAL_STATE: State = {
@ -15,40 +36,88 @@ export const INITIAL_STATE: State = {
cssLoading: false,
disconnected: false,
userPageLoading: false,
}
markedTargets: null,
activeTargetIndex: 0
};
export default class StatedScreen extends Screen {
constructor() { super(); }
setMessagesLoading(messagesLoading: boolean) {
// @ts-ignore
this.display(!messagesLoading);
update({ messagesLoading });
}
setCSSLoading(cssLoading: boolean) {
// @ts-ignore
this.displayFrame(!cssLoading);
update({ cssLoading });
}
setDisconnected(disconnected: boolean) {
if (!getState().live) return; //?
// @ts-ignore
this.display(!disconnected);
update({ disconnected });
}
setUserPageLoading(userPageLoading: boolean) {
// @ts-ignore
this.display(!userPageLoading);
update({ userPageLoading });
}
setSize({ height, width }: { height: number, width: number }) {
update({ width, height });
// @ts-ignore
this.scale();
const { markedTargets } = getState();
if (markedTargets) {
update({
markedTargets: markedTargets.map(mt => ({
...mt,
boundingRect: this.calculateRelativeBoundingRect(mt.el),
})),
});
}
}
private calculateRelativeBoundingRect(el: Element): BoundingRect {
if (!this.parentElement) return {top:0, left:0, width:0,height:0} //TODO
const { top, left, width, height } = el.getBoundingClientRect();
const s = this.getScale();
const scrinRect = this.screen.getBoundingClientRect();
const parentRect = this.parentElement.getBoundingClientRect();
return {
top: top*s + scrinRect.top - parentRect.top,
left: left*s + scrinRect.left - parentRect.left,
width: width*s,
height: height*s,
}
}
setActiveTarget(index) {
update({ activeTargetIndex: index });
}
setMarkedTargets(selections: { selector: string, count: number }[] | null) {
if (selections) {
const targets: MarkedTarget[] = [];
const totalCount = selections.reduce((a, b) => {
return a + b.count
}, 0);
selections.forEach((s, index) => {
const el = this.getElementBySelector(s.selector);
if (!el) return;
targets.push({
...s,
el,
index,
percent: Math.round((s.count * totalCount) / 100),
boundingRect: this.calculateRelativeBoundingRect(el),
})
});
update({ markedTargets: targets });
} else {
update({ markedTargets: null });
}
}
}

View file

@ -125,9 +125,9 @@ export default class AssistManager {
this.md.setMessagesLoading(false);
}
if (status === ConnectionStatus.Connected) {
this.md.display(true);
// this.md.display(true);
} else {
this.md.display(false);
// this.md.display(false);
}
update({ peerConnectionStatus: status });
}

View file

@ -1,6 +1,6 @@
import { goTo as listsGoTo } from './lists';
import { update, getState } from './store';
import MessageDistributor, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './MessageDistributor';
import MessageDistributor, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './MessageDistributor/MessageDistributor';
const fps = 60;
const performance = window.performance || { now: Date.now.bind(Date) };
@ -35,7 +35,7 @@ const initialSkipToIssue = !!localStorage.getItem(SKIP_TO_ISSUE_STORAGE_KEY);
const initialAutoplay = !!localStorage.getItem(AUTOPLAY_STORAGE_KEY);
const initialShowEvents = !!localStorage.getItem(SHOW_EVENTS_STORAGE_KEY);
export const INITIAL_STATE: SuperState = {
export const INITIAL_STATE = {
...SUPER_INITIAL_STATE,
time: 0,
playing: false,
@ -191,6 +191,15 @@ export default class Player extends MessageDistributor {
update({ inspectorMode: false });
}
}
markTargets(targets: { selector: string, count: number }[] | null) {
this.pause();
this.setMarkedTargets(targets);
}
activeTarget(index) {
this.setActiveTarget(index);
}
toggleSkipToIssue() {
const skipToIssue = !getState().skipToIssue;

View file

@ -69,6 +69,8 @@ export const markElement = initCheck((...args) => instance.marker && instance.ma
export const scale = initCheck(() => instance.scale());
export const toggleInspectorMode = initCheck((...args) => instance.toggleInspectorMode(...args));
export const callPeer = initCheck((...args) => instance.assistManager.call(...args))
export const markTargets = initCheck((...args) => instance.markTargets(...args))
export const activeTarget = initCheck((...args) => instance.activeTarget(...args))
export const Controls = {
jump,

View file

@ -258,4 +258,19 @@ p {
&:hover {
text-decoration: underline !important;
}
}
.tippy-tooltip.openreplay-theme {
background-color: $tealx;
color: white;
}
.tippy-tooltip.openreplay-theme[data-animatefill] {
background-color: transparent;
}
.tippy-tooltip.openreplay-theme .tippy-backdrop {
background-color: $tealx;
}

View file

@ -4,4 +4,5 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@import "tailwindcss/utilities";
@import 'react-tippy/dist/tippy.css';

View file

@ -23,16 +23,18 @@
border-color: $teal !important;
}
.ui.search.customDropdown>input.search,
.ui.search.customDropdown>input.search,
.ui.search.customDropdown.active>input.search,
.ui.search.customDropdown.visible>input.search {
padding: 6px !important;
}
.ui.customDropdown>.text,
.ui.search.customDropdown>.text {
max-width: 90%;
overflow: hidden;
max-width: 90% !important;
overflow: hidden !important;
white-space: nowrap;
text-overflow: ellipsis;
text-overflow: ellipsis !important;
}
.ui.selection.customDropdown>.dropdown.icon {
top: 5px !important;

32817
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,7 @@
"react-redux": "^5.1.2",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-tippy": "^1.4.0",
"react-toastify": "^5.5.0",
"react-virtualized": "^9.22.2",
"recharts": "^1.8.5",

View file

@ -84,3 +84,5 @@ Below is the list of dependencies used in OpenReplay software. Licenses may chan
| schedule | MIT | Python |
| croniter | MIT | Python |
| lib/pq | MIT | Go |
| peerjs | MIT | JavaScript |
| antonmedv/finder | MIT | JavaScript |