Merge pull request #345 from openreplay/assist-ui

Assist UI
This commit is contained in:
Shekar Siri 2022-02-22 22:00:19 +01:00 committed by GitHub
commit 984ba66ed8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 742 additions and 240 deletions

View file

@ -11,6 +11,7 @@ import UpdatePassword from 'Components/UpdatePassword/UpdatePassword';
import ClientPure from 'Components/Client/Client';
import OnboardingPure from 'Components/Onboarding/Onboarding';
import SessionPure from 'Components/Session/Session';
import AssistPure from 'Components/Assist';
import BugFinderPure from 'Components/BugFinder/BugFinder';
import DashboardPure from 'Components/Dashboard/Dashboard';
import ErrorsPure from 'Components/Errors/Errors';
@ -18,6 +19,7 @@ import Header from 'Components/Header/Header';
// import ResultsModal from 'Shared/Results/ResultsModal';
import FunnelDetails from 'Components/Funnels/FunnelDetails';
import FunnelIssueDetails from 'Components/Funnels/FunnelIssueDetails';
import { fetchList as fetchIntegrationVariables } from 'Duck/customField';
import APIClient from './api_client';
import * as routes from './routes';
@ -29,6 +31,7 @@ import { setSessionPath } from 'Duck/sessions';
const BugFinder = withSiteIdUpdater(BugFinderPure);
const Dashboard = withSiteIdUpdater(DashboardPure);
const Session = withSiteIdUpdater(SessionPure);
const Assist = withSiteIdUpdater(AssistPure);
const Client = withSiteIdUpdater(ClientPure);
const Onboarding = withSiteIdUpdater(OnboardingPure);
const Errors = withSiteIdUpdater(ErrorsPure);
@ -39,6 +42,7 @@ const withObTab = routes.withObTab;
const DASHBOARD_PATH = routes.dashboard();
const SESSIONS_PATH = routes.sessions();
const ASSIST_PATH = routes.assist();
const ERRORS_PATH = routes.errors();
const ERROR_PATH = routes.error();
const FUNNEL_PATH = routes.funnel();
@ -74,7 +78,7 @@ const ONBOARDING_REDIRECT_PATH = routes.onboarding(OB_DEFAULT_TAB);
onboarding: state.getIn([ 'user', 'onboarding' ])
};
}, {
fetchUserInfo, fetchTenants, setSessionPath
fetchUserInfo, fetchTenants, setSessionPath, fetchIntegrationVariables
})
class Router extends React.Component {
state = {
@ -83,7 +87,11 @@ class Router extends React.Component {
constructor(props) {
super(props);
if (props.isLoggedIn) {
Promise.all([props.fetchUserInfo()])
Promise.all([
props.fetchUserInfo().then(() => {
props.fetchIntegrationVariables()
}),
])
// .then(() => this.onLoginLogout());
}
props.fetchTenants();
@ -145,6 +153,7 @@ class Router extends React.Component {
<Redirect to={ routes.client(routes.CLIENT_TABS.SITES) } />
}
<Route exact strict path={ withSiteId(DASHBOARD_PATH, siteIdList) } component={ Dashboard } />
<Route exact strict path={ withSiteId(ASSIST_PATH, siteIdList) } component={ Assist } />
<Route exact strict path={ withSiteId(ERRORS_PATH, siteIdList) } component={ Errors } />
<Route exact strict path={ withSiteId(ERROR_PATH, siteIdList) } component={ Errors } />
<Route exact strict path={ withSiteId(FUNNEL_PATH, siteIdList) } component={ Funnels } />

View file

@ -1,11 +1,25 @@
import React from 'react';
import ChatWindow from './ChatWindow';
import LiveSessionList from 'Shared/LiveSessionList';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import cn from 'classnames'
import withPageTitle from 'HOCs/withPageTitle';
import withPermissions from 'HOCs/withPermissions'
export default function Assist() {
// @withPageTitle("Assist - OpenReplay")
function Assist() {
return (
<div className="absolute">
{/* <ChatWindow /> */}
<div className="page-margin container-90 flex relative">
<div className="flex-1 flex">
{/* <div className="side-menu">
</div> */}
<div className={cn("w-full mx-auto")} style={{ maxWidth: '1300px'}}>
<LiveSessionSearch />
<div className="my-4" />
<LiveSessionList />
</div>
</div>
</div>
)
}
export default withPageTitle("Assist - OpenReplay")(withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(Assist));

View file

@ -15,7 +15,7 @@
&.disabled {
/* background-color: red; */
& svg {
fill: red;
fill: $red;
}
}
}

View file

@ -28,17 +28,17 @@ function ChatControls({ stream, endCall, videoEnabled, setVideoEnabled } : Props
return (
<div className={cn(stl.controls, "flex items-center w-full justify-start bottom-0 px-2")}>
<div className="flex items-center">
<div className={cn(stl.btnWrapper, { [stl.disabled]: !audioEnabled})}>
<Button plain size="small" onClick={toggleAudio} noPadding className="flex items-center">
<div className={cn(stl.btnWrapper, { [stl.disabled]: audioEnabled})}>
<Button plain size="small" onClick={toggleAudio} noPadding className="flex items-center" hover>
<Icon name={audioEnabled ? 'mic' : 'mic-mute'} size="16" />
<span className="ml-2 color-gray-medium text-sm">{audioEnabled ? 'Mute' : 'Unmute'}</span>
<span className={cn("ml-1 color-gray-medium text-sm", { 'color-red' : audioEnabled })}>{audioEnabled ? 'Mute' : 'Unmute'}</span>
</Button>
</div>
<div className={cn(stl.btnWrapper, { [stl.disabled]: !videoEnabled})}>
<Button plain size="small" onClick={toggleVideo} noPadding className="flex items-center">
<div className={cn(stl.btnWrapper, { [stl.disabled]: videoEnabled})}>
<Button plain size="small" onClick={toggleVideo} noPadding className="flex items-center" hover>
<Icon name={ videoEnabled ? 'camera-video' : 'camera-video-off' } size="16" />
<span className="ml-2 color-gray-medium text-sm">{videoEnabled ? 'Stop Video' : 'Start Video'}</span>
<span className={cn("ml-1 color-gray-medium text-sm", { 'color-red' : videoEnabled })}>{videoEnabled ? 'Stop Video' : 'Start Video'}</span>
</Button>
</div>
</div>

View file

@ -20,7 +20,6 @@ const ChatWindow: FC<Props> = function ChatWindow({ userId, incomeStream, localS
const [localVideoEnabled, setLocalVideoEnabled] = useState(false)
const [remoteVideoEnabled, setRemoteVideoEnabled] = useState(false)
useEffect(() => {
if (!incomeStream) { return }
const iid = setInterval(() => {
@ -42,9 +41,9 @@ const ChatWindow: FC<Props> = function ChatWindow({ userId, incomeStream, localS
className={cn(stl.wrapper, "fixed radius bg-white shadow-xl mt-16")}
style={{ width: '280px' }}
>
<div className="handle flex items-center p-2 cursor-move select-none">
<div className={stl.headerTitle}><b>Meeting</b> {userId}</div>
<Counter startTime={new Date().getTime() } className="text-sm ml-auto" />
<div className="handle flex items-center p-2 cursor-move select-none border-b">
<div className={stl.headerTitle}><b>Talking to </b> {userId ? userId : 'Anonymous User'}</div>
<Counter startTime={new Date().getTime() } className="text-sm ml-auto" />
</div>
<div className={cn(stl.videoWrapper, {'hidden' : minimize}, 'relative')}>
<VideoContainer stream={ incomeStream } />

View file

@ -1,9 +1,10 @@
.wrapper {
background-color: white;
border: solid thin #000;
border: solid thin $gray-light;
border-radius: 3px;
position: fixed;
width: 300px;
width: 300px;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
}
.headerTitle {

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Popup, Icon } from 'UI'
import { Popup, Icon, IconButton } from 'UI'
import { connect } from 'react-redux'
import cn from 'classnames'
import { toggleChatWindow } from 'Duck/sessions';
@ -77,27 +77,48 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
const onCall = calling === CallingState.OnCall || calling === CallingState.Reconnecting
const cannotCall = (peerConnectionStatus !== ConnectionStatus.Connected) || (isEnterprise && !hasPermission)
const remoteActive = remoteControlStatus === RemoteControlStatus.Enabled
return (
<div className="flex items-center">
<div
className={
cn(
'cursor-pointer p-2 flex items-center',
{[stl.disabled]: cannotCall}
)
}
onClick={ requestReleaseRemoteControl }
role="button"
>
{/* <Icon
name="remote-control"
size="20"
color={ remoteControlStatus === RemoteControlStatus.Enabled ? "green" : "gray-darkest"}
/>
<span className={cn("ml-2", { 'color-green' : remoteControlStatus === RemoteControlStatus.Enabled })}>{ 'Remote Control' }</span> */}
<IconButton label={`${remoteActive ? 'Stop ' : ''} Remote Control`} icon="remote-control" primaryText redText={remoteActive} />
</div>
<Popup
trigger={
<div
className={
cn(
'cursor-pointer p-2 mr-2 flex items-center',
'cursor-pointer p-2 flex items-center',
{[stl.disabled]: cannotCall}
)
}
onClick={ onCall ? callObject?.end : confirmCall}
role="button"
>
<Icon
{/* <Icon
name="headset"
size="20"
color={ onCall ? "red" : "gray-darkest" }
/>
<span className={cn("ml-2", { 'color-red' : onCall })}>{ onCall ? 'End Call' : 'Call' }</span>
<span className={cn("ml-2", { 'color-red' : onCall })}>{ onCall ? 'End Call' : 'Call' }</span> */}
<IconButton size="small" primary={!onCall} red={onCall} label={onCall ? 'End' : 'Call'} icon="headset" />
</div>
}
content={ cannotCall ? "You dont have the permissions to perform this action." : `Call ${userId ? userId : 'User'}` }
@ -105,22 +126,7 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
inverted
position="top right"
/>
<div
className={
cn(
'cursor-pointer p-2 mr-2 flex items-center',
)
}
onClick={ requestReleaseRemoteControl }
role="button"
>
<Icon
name="remote-control"
size="20"
color={ remoteControlStatus === RemoteControlStatus.Enabled ? "green" : "gray-darkest"}
/>
<span className={cn("ml-2", { 'color-green' : remoteControlStatus === RemoteControlStatus.Enabled })}>{ 'Remote Control' }</span>
</div>
<div className="fixed ml-3 left-0 top-0" style={{ zIndex: 999 }}>
{ onCall && callObject && <ChatWindow endCall={callObject.end} userId={userId} incomeStream={incomeStream} localStream={localStream} /> }
</div>

View file

@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import { SlideModal, Icon } from 'UI';
import { SlideModal, Avatar, Icon } from 'UI';
import SessionList from '../SessionList';
import stl from './assistTabs.css'
interface Props {
userId: any,
userNumericHash: any,
}
const AssistTabs = (props: Props) => {
@ -15,16 +16,16 @@ const AssistTabs = (props: Props) => {
<div className="flex items-center">
{props.userId && (
<>
<div className="flex items-center mr-3">
{/* <Icon name="user-alt" color="gray-darkest" /> */}
<Avatar iconSize="20" width="30px" height="30px" seed={ props.userNumericHash } />
<div className="ml-2 font-medium">{props.userId}'s</div>
</div>
<div
className={stl.btnLink}
onClick={() => setShowMenu(!showMenu)}
>
More Live Sessions
</div>
<span className="mx-3 color-gray-medium">by</span>
<div className="flex items-center">
<Icon name="user-alt" color="gray-darkest" />
<div className="ml-2">{props.userId}</div>
Active Sessions
</div>
</>
)}

View file

@ -14,7 +14,7 @@ import stl from './bugFinder.css';
import { fetchList as fetchSiteList } from 'Duck/site';
import withLocationHandlers from "HOCs/withLocationHandlers";
import { fetch as fetchFilterVariables } from 'Duck/sources';
import { fetchList as fetchIntegrationVariables, fetchSources } from 'Duck/customField';
import { fetchSources } from 'Duck/customField';
import { RehydrateSlidePanel } from './WatchDogs/components';
import { setActiveTab, setFunnelPage } from 'Duck/sessions';
import SessionsMenu from './SessionsMenu/SessionsMenu';
@ -23,11 +23,8 @@ import { resetFunnel } from 'Duck/funnels';
import { resetFunnelFilters } from 'Duck/funnelFilters'
import NoSessionsMessage from 'Shared/NoSessionsMessage';
import TrackerUpdateMessage from 'Shared/TrackerUpdateMessage';
import LiveSessionList from './LiveSessionList'
import SessionSearch from 'Shared/SessionSearch';
import MainSearchBar from 'Shared/MainSearchBar';
import LiveSearchBar from 'Shared/LiveSearchBar';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import { clearSearch, fetchSessions } from 'Duck/search';
const weakEqual = (val1, val2) => {
@ -54,7 +51,6 @@ const allowedQueryKeys = [
@withLocationHandlers()
@connect(state => ({
filter: state.getIn([ 'filters', 'appliedFilter' ]),
showLive: state.getIn([ 'user', 'account', 'appearance', 'sessionsLive' ]),
variables: state.getIn([ 'customFields', 'list' ]),
sources: state.getIn([ 'customFields', 'sources' ]),
filterValues: state.get('filterValues'),
@ -68,8 +64,7 @@ const allowedQueryKeys = [
fetchFavoriteSessionList,
applyFilter,
addAttribute,
fetchFilterVariables,
fetchIntegrationVariables,
fetchFilterVariables,
fetchSources,
clearEvents,
setActiveTab,
@ -101,15 +96,6 @@ export default class BugFinder extends React.PureComponent {
// keys: this.props.sources.filter(({type}) => type === 'logTool').map(({ label, key }) => ({ type: 'ERROR', source: key, label: label, key, icon: 'integrations/' + key, isFilter: false })).toJS()
// };
// });
// // TODO should cache the response
props.fetchIntegrationVariables().then(() => {
defaultFilters[5] = {
category: 'Metadata',
type: 'custom',
keys: this.props.variables.map(({ key }) => ({ type: 'METADATA', key, label: key, icon: 'filters/metadata', isFilter: true })).toJS()
};
});
props.fetchSessions();
props.resetFunnel();
props.resetFunnelFilters();
@ -172,28 +158,11 @@ export default class BugFinder extends React.PureComponent {
<div className={cn("side-menu-margined", stl.searchWrapper) }>
<TrackerUpdateMessage />
<NoSessionsMessage />
{/* Recorde Sessions */}
{ activeTab.type !== 'live' && (
<>
<div className="mb-5">
<MainSearchBar />
<SessionSearch />
</div>
{ activeTab.type !== 'live' && <SessionList onMenuItemClick={this.setActiveTab} /> }
</>
)}
{/* Live Sessions */}
{ activeTab.type === 'live' && (
<>
<div className="mb-5">
{/* <LiveSearchBar /> */}
<LiveSessionSearch />
</div>
{ activeTab.type === 'live' && <LiveSessionList /> }
</>
)}
<div className="mb-5">
<MainSearchBar />
<SessionSearch />
</div>
<SessionList onMenuItemClick={this.setActiveTab} />
</div>
</div>
<RehydrateSlidePanel

View file

@ -19,6 +19,7 @@ var timeoutId;
allList: state.getIn([ 'sessions', 'list' ]),
total: state.getIn([ 'sessions', 'total' ]),
filters: state.getIn([ 'search', 'instance', 'filters' ]),
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
}), {
applyFilter,
addAttribute,
@ -47,7 +48,7 @@ export default class SessionList extends React.PureComponent {
if (userId) {
this.props.addFilterByKeyAndValue(FilterKey.USERID, userId);
} else {
this.props.addFilterByKeyAndValue(FilterKey.USERANONYMOUSID, userAnonymousId);
this.props.addFilterByKeyAndValue(FilterKey.USERID, '', 'isUndefined');
}
}
@ -81,7 +82,8 @@ export default class SessionList extends React.PureComponent {
filters,
onMenuItemClick,
allList,
activeTab
activeTab,
metaList,
} = this.props;
const _filterKeys = filters.map(i => i.key);
const hasUserFilter = _filterKeys.includes(FilterKey.USERID) || _filterKeys.includes(FilterKey.USERANONYMOUSID);
@ -118,6 +120,7 @@ export default class SessionList extends React.PureComponent {
session={ session }
hasUserFilter={hasUserFilter}
onUserClick={this.onUserClick}
metaList={metaList}
/>
))}
</Loader>

View file

@ -5,6 +5,7 @@ import SortDropdown from '../Filters/SortDropdown';
import DateRange from '../DateRange';
import { TimezoneDropdown } from 'UI';
import { numberWithCommas } from 'App/utils';
import DropdownPlain from 'Shared/DropdownPlain';
const DEFAULT_SORT = 'startTs';
const DEFAULT_ORDER = 'desc';
@ -38,6 +39,17 @@ function SessionListHeader({
</div>
</div>
<div className="flex items-center">
{/* <div className="flex items-center">
<span className="mr-2 color-gray-medium">Session View</span>
<DropdownPlain
options={[
{ text: 'List', value: 'list' },
{ text: 'Grouped', value: 'grouped' }
]}
onChange={() => {}}
value='list'
/>
</div> */}
<div className="flex items-center">
<span className="mr-2 color-gray-medium">Timezone</span>
<TimezoneDropdown />

View file

@ -73,7 +73,7 @@ function SessionsMenu(props) {
/>
))}
<div className={stl.divider} />
{/* <div className={stl.divider} />
<div className="my-3">
<SideMenuitem
title={
@ -94,7 +94,7 @@ function SessionsMenu(props) {
onClick={() => onMenuItemClick({ name: 'Assist', type: 'live' })}
/>
</div>
</div> */}
<div className={stl.divider} />
<div className="my-3">

View file

@ -26,7 +26,7 @@ export default class ProfileSettings extends React.PureComponent {
<div><Settings /></div>
</div>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
@ -36,7 +36,7 @@ export default class ProfileSettings extends React.PureComponent {
<div><ChangePassword /></div>
</div>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
@ -46,7 +46,7 @@ export default class ProfileSettings extends React.PureComponent {
<div><Api /></div>
</div>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
@ -58,7 +58,7 @@ export default class ProfileSettings extends React.PureComponent {
{ !isEnterprise && (
<>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
<h4 className="text-lg mb-4">{ 'Data Collection' }</h4>
@ -71,7 +71,7 @@ export default class ProfileSettings extends React.PureComponent {
{ account.license && (
<>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>

View file

@ -4,6 +4,7 @@ import { NavLink, withRouter } from 'react-router-dom';
import cn from 'classnames';
import {
sessions,
assist,
client,
errors,
dashboard,
@ -27,6 +28,7 @@ import Alerts from '../Alerts/Alerts';
const DASHBOARD_PATH = dashboard();
const SESSIONS_PATH = sessions();
const ASSIST_PATH = assist();
const ERRORS_PATH = errors();
const CLIENT_PATH = client(CLIENT_DEFAULT_TAB);
const AUTOREFRESH_INTERVAL = 30 * 1000;
@ -86,6 +88,13 @@ const Header = (props) => {
>
{ 'Sessions' }
</NavLink>
<NavLink
to={ withSiteId(ASSIST_PATH, siteId) }
className={ styles.nav }
activeClassName={ styles.active }
>
{ 'Assist' }
</NavLink>
<NavLink
to={ withSiteId(ERRORS_PATH, siteId) }
className={ styles.nav }

View file

@ -10,6 +10,7 @@ import styles from './siteDropdown.css';
import cn from 'classnames';
import NewSiteForm from '../Client/Sites/NewSiteForm';
import { clearSearch } from 'Duck/search';
import { fetchList as fetchIntegrationVariables } from 'Duck/customField';
@withRouter
@connect(state => ({
@ -21,10 +22,15 @@ import { clearSearch } from 'Duck/search';
pushNewSite,
init,
clearSearch,
fetchIntegrationVariables,
})
export default class SiteDropdown extends React.PureComponent {
state = { showProductModal: false }
componentDidMount() {
this.props.fetchIntegrationVariables();
}
closeModal = (e, newSite) => {
this.setState({ showProductModal: false })
};
@ -37,6 +43,7 @@ export default class SiteDropdown extends React.PureComponent {
switchSite = (siteId) => {
this.props.setSiteId(siteId);
this.props.clearSearch();
this.props.fetchIntegrationVariables();
}
render() {

View file

@ -9,9 +9,7 @@ import {
init as initPlayer,
clean as cleanPlayer,
} from 'Player';
import withPermissions from 'HOCs/withPermissions'
import Assist from 'Components/Assist'
import withPermissions from 'HOCs/withPermissions';
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
import EventsBlock from '../Session_/EventsBlock';
@ -54,7 +52,6 @@ function WebPlayer ({ showAssist, session, toggleFullscreen, closeBottomBlock, l
return (
<PlayerProvider>
<InitLoader className="flex-1 p-3">
{ showAssist && <Assist session={session} /> }
<PlayerBlockHeader fullscreen={fullscreen}/>
<div className={ styles.session } data-fullscreen={fullscreen}>
<PlayerBlock />

View file

@ -297,7 +297,7 @@ export default class Controls extends React.Component {
>
<div>{ speed + 'x' }</div>
</button>
<div className={ styles.divider } />
<button
className={ cn(styles.skipIntervalButton, { [styles.withCheckIcon]: skip }) }
onClick={ this.props.toggleSkip }
@ -308,7 +308,9 @@ export default class Controls extends React.Component {
</button>
</React.Fragment>
}
<div className={ styles.divider } />
{ !live && <div className={ styles.divider } /> }
{ !live &&
<ControlButton
disabled={ disabled }
@ -413,7 +415,7 @@ export default class Controls extends React.Component {
icon="business-time"
/>
} */}
<div className={ styles.divider } />
{ !live &&
<React.Fragment>
<ControlButton

View file

@ -3,19 +3,22 @@ import { withRouter } from 'react-router-dom';
import { browserIcon, osIcon, deviceTypeIcon } from 'App/iconNames';
import { formatTimeOrDate } from 'App/date';
import { sessions as sessionsRoute, withSiteId } from 'App/routes';
import { Icon, CountryFlag, IconButton, BackLink } from 'UI';
import { Icon, CountryFlag, IconButton, BackLink, Popup } from 'UI';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import cn from 'classnames';
import { connectPlayer } from 'Player';
import HeaderInfo from './HeaderInfo';
import SharePopup from '../shared/SharePopup/SharePopup';
import { fetchList as fetchListIntegration } from 'Duck/integrations/actions';
import { countries } from 'App/constants';
import SessionMetaList from 'Shared/SessionItem/SessionMetaList';
import stl from './playerBlockHeader.css';
import Issues from './Issues/Issues';
import Autoplay from './Autoplay';
import AssistActions from '../Assist/components/AssistActions';
import AssistTabs from '../Assist/components/AssistTabs';
import SessionInfoItem from './SessionInfoItem'
const SESSIONS_ROUTE = sessionsRoute();
@ -42,6 +45,7 @@ function capitalise(str) {
funnelRef: state.getIn(['funnels', 'navRef']),
siteId: state.getIn([ 'user', 'siteId' ]),
hasSessionsPath: hasSessioPath && !isAssist,
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
}
}, {
toggleFavorite, fetchListIntegration, setSessionPath
@ -53,11 +57,13 @@ export default class PlayerBlockHeader extends React.PureComponent {
this.props.fetchListIntegration('issues')
}
getDimension = (width, height) => (
<div className="flex items-center">
{ width || 'x' } <Icon name="close" size="12" className="mx-1" /> { height || 'x' }
</div>
);
getDimension = (width, height) => {
return width && height ? (
<div className="flex items-center">
{ width || 'x' } <Icon name="close" size="12" className="mx-1" /> { height || 'x' }
</div>
) : <span className="">Resolution N/A</span>;
}
backHandler = () => {
const { history, siteId, sessionPath } = this.props;
@ -81,14 +87,17 @@ export default class PlayerBlockHeader extends React.PureComponent {
sessionId,
userCountry,
userId,
userNumericHash,
favorite,
startedAt,
userBrowser,
userOs,
userOsVersion,
userDevice,
userBrowserVersion,
userDeviceType,
live,
metadata,
},
loading,
// live,
@ -97,17 +106,24 @@ export default class PlayerBlockHeader extends React.PureComponent {
fullscreen,
hasSessionsPath,
sessionPath,
metaList,
} = this.props;
const _live = live && !hasSessionsPath;
const _metaList = Object.keys(metadata).filter(i => metaList.includes(i)).map(key => {
const value = metadata[key];
return { label: key, value };
});
return (
<div className={ cn(stl.header, "flex justify-between", { "hidden" : fullscreen}) }>
<div className="flex w-full">
<div className="flex w-full items-center">
<BackLink onClick={this.backHandler} label="Back" />
<div className={ stl.divider } />
{ _live && <AssistTabs userId={userId} userNumericHash={userNumericHash} />}
<div className="mx-4 flex items-center">
{/* <div className="mx-4 flex items-center">
<CountryFlag country={ userCountry } />
<div className="ml-2 font-normal color-gray-dark mt-1 text-sm">
{ formatTimeOrDate(startedAt) } <span>{ this.props.local === 'UTC' ? 'UTC' : ''}</span>
@ -117,22 +133,49 @@ export default class PlayerBlockHeader extends React.PureComponent {
<HeaderInfo icon={ browserIcon(userBrowser) } label={ `v${ userBrowserVersion }` } />
<HeaderInfo icon={ deviceTypeIcon(userDeviceType) } label={ capitalise(userDevice) } />
<HeaderInfo icon="expand-wide" label={ this.getDimension(width, height) } />
<HeaderInfo icon={ osIcon(userOs) } label={ userOs } />
<HeaderInfo icon={ osIcon(userOs) } label={ userOs } /> */}
<div className='ml-auto flex items-center'>
{ live && hasSessionsPath && (
<div className={stl.liveSwitchButton} onClick={() => this.props.setSessionPath('')}>
This Session is Now Continuing Live
</div>
<>
<div className={stl.liveSwitchButton} onClick={() => this.props.setSessionPath('')}>
This Session is Now Continuing Live
</div>
<div className={ stl.divider } />
</>
)}
{ _live && <AssistTabs userId={userId} />}
{ _live && (
<>
<SessionMetaList className="" metaList={_metaList} />
<div className={ stl.divider } />
</>
)}
<Popup
trigger={(
<IconButton icon="info-circle" primaryText label="More Info" disabled={disabled} />
)}
content={(
<div className=''>
<SessionInfoItem comp={<CountryFlag country={ userCountry } />} label={countries[userCountry]} value={ formatTimeOrDate(startedAt) } />
<SessionInfoItem icon={browserIcon(userBrowser)} label={userBrowser} value={ `v${ userBrowserVersion }` } />
<SessionInfoItem icon={osIcon(userOs)} label={userOs} value={ userOsVersion } />
<SessionInfoItem icon={deviceTypeIcon(userDeviceType)} label={userDeviceType} value={ this.getDimension(width, height) } isLast />
</div>
)}
on="click"
position="top center"
hideOnScroll
/>
<div className={ stl.divider } />
{ _live && <AssistActions isLive userId={userId} /> }
{ !_live && (
<>
<Autoplay />
<div className={ stl.divider } />
<IconButton
className="mr-2"
// className="mr-2"
tooltip="Bookmark"
tooltipPosition="top right"
onClick={ this.toggleFavorite }
@ -140,13 +183,14 @@ export default class PlayerBlockHeader extends React.PureComponent {
icon={ favorite ? 'star-solid' : 'star' }
plain
/>
<div className={ stl.divider } />
<SharePopup
entity="sessions"
id={ sessionId }
showCopyLink={true}
trigger={
<IconButton
className="mr-2"
// className="mr-2"
tooltip="Share Session"
tooltipPosition="top right"
disabled={ disabled }
@ -164,3 +208,4 @@ export default class PlayerBlockHeader extends React.PureComponent {
);
}
}

View file

@ -0,0 +1,24 @@
import React from 'react'
import { Icon } from 'UI'
import cn from 'classnames'
interface Props {
label: string,
icon?: string,
comp?: React.ReactNode,
value: string,
isLast?: boolean,
}
export default function SessionInfoItem(props: Props) {
const { label, icon, value, comp, isLast = false } = props
return (
<div className={cn("flex items-center w-full py-2", {'border-b' : !isLast})}>
<div className="px-2 capitalize" style={{ width: '30px' }}>
{ icon && <Icon name={icon} size="16" /> }
{ comp && comp }
</div>
<div className="px-2 capitalize" style={{ minWidth: '160px' }}>{label}</div>
<div className="color-gray-medium px-2" style={{ minWidth: '130px' }}>{value}</div>
</div>
)
}

View file

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

View file

@ -1,26 +1,19 @@
.header {
height: 50px;
border-bottom: solid thin $gray-light;
padding: 10px 15px;
padding: 0px 15px;
background-color: white;
}
.divider {
width: 1px;
height: 100%;
height: 49px;
margin: 0 15px;
background-color: $gray-light;
}
.liveSwitchButton {
cursor: pointer;
padding: 3px 8px;
border: solid thin $green;
color: $green;
border-radius: 3px;
margin-right: 10px;
&:hover {
background-color: $green;
color: white;
}
text-decoration: underline;
}

View file

@ -0,0 +1,23 @@
.dropdown {
display: flex !important;
padding: 4px 6px;
border-radius: 3px;
color: $gray-darkest;
font-weight: 500;
&:hover {
background-color: $gray-light;
}
}
.dropdownTrigger {
padding: 4px 8px;
border-radius: 3px;
&:hover {
background-color: $gray-light;
}
}
.dropdownIcon {
margin-top: 2px;
margin-left: 3px;
}

View file

@ -0,0 +1,30 @@
import React from 'react'
import stl from './DropdownPlain.css';
import { Dropdown, Icon } from 'UI';
interface Props {
options: any[];
onChange: (e, { name, value }) => void;
icon?: string;
direction?: string;
value: any;
}
export default function DropdownPlain(props: Props) {
const { value, options, icon = "chevron-down", direction = "left" } = props;
return (
<div>
<Dropdown
value={value}
name="sort"
className={ stl.dropdown }
direction={direction}
options={ options }
onChange={ props.onChange }
scrolling
// defaultValue={ value }
icon={ icon ? <Icon name="chevron-down" color="gray-dark" size="14" className={stl.dropdownIcon} /> : null }
/>
</div>
)
}

View file

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

View file

@ -18,7 +18,7 @@ interface Props {
function FilterAutoCompleteLocal(props: Props) {
const {
showCloseButton = false,
placeholder = 'Type to search',
placeholder = 'Enter',
showOrButton = false,
onRemoveValue = () => null,
onAddValue = () => null,

View file

@ -14,7 +14,7 @@ interface Props {
}
function FilterItem(props: Props) {
const { isFilter = false, filterIndex, filter } = props;
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny");
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny" || filter.operator === "isUndefined");
const replaceFilter = (filter) => {
props.onUpdate({ ...filter, value: [""]});

View file

@ -4,9 +4,9 @@ import LiveFilterModal from '../LiveFilterModal';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import { Icon } from 'UI';
import { connect } from 'react-redux';
import { dashboard as dashboardRoute, isRoute } from "App/routes";
import { assist as assistRoute, isRoute } from "App/routes";
const DASHBOARD_ROUTE = dashboardRoute();
const ASSIST_ROUTE = assistRoute();
interface Props {
filter?: any; // event/filter
@ -43,7 +43,7 @@ function FilterSelection(props: Props) {
</OutsideClickDetectingDiv>
{showModal && (
<div className="absolute left-0 top-20 border shadow rounded bg-white z-50">
{ (isLive && !isRoute(DASHBOARD_ROUTE, window.location.pathname)) ? <LiveFilterModal onFilterClick={onFilterClick} /> : <FilterModal onFilterClick={onFilterClick} /> }
{ isRoute(ASSIST_ROUTE, window.location.pathname) ? <LiveFilterModal onFilterClick={onFilterClick} /> : <FilterModal onFilterClick={onFilterClick} /> }
</div>
)}
</div>

View file

@ -8,7 +8,11 @@ import withPermissions from 'HOCs/withPermissions'
import { KEYS } from 'Types/filter/customFilter';
import { applyFilter, addAttribute } from 'Duck/filters';
import { FilterCategory, FilterKey } from 'App/types/filter/filterType';
import { addFilterByKeyAndValue, updateCurrentPage } from 'Duck/liveSearch';
import { addFilterByKeyAndValue, updateCurrentPage, toggleSortOrder } from 'Duck/liveSearch';
import DropdownPlain from 'Shared/DropdownPlain';
import SortOrderButton from 'Shared/SortOrderButton';
import { TimezoneDropdown } from 'UI';
import { capitalize } from 'App/utils';
const AUTOREFRESH_INTERVAL = .5 * 60 * 1000
const PER_PAGE = 20;
@ -23,14 +27,21 @@ interface Props {
addFilterByKeyAndValue: (key: FilterKey, value: string) => void,
updateCurrentPage: (page: number) => void,
currentPage: number,
metaList: any,
sortOrder: string,
toggleSortOrder: (sortOrder: string) => void,
}
function LiveSessionList(props: Props) {
const { loading, filters, list, currentPage } = props;
const { loading, filters, list, currentPage, metaList = [], sortOrder } = props;
var timeoutId;
const hasUserFilter = filters.map(i => i.key).includes(KEYS.USERID);
const [sessions, setSessions] = React.useState(list);
const sortOptions = metaList.map(i => ({
text: capitalize(i), value: i
})).toJS();
const [sortBy, setSortBy] = React.useState('');
const displayedCount = Math.min(currentPage * PER_PAGE, sessions.size);
const addPage = () => props.updateCurrentPage(props.currentPage + 1)
@ -41,6 +52,12 @@ function LiveSessionList(props: Props) {
}
}, []);
useEffect(() => {
if (metaList.size === 0 || !!sortBy) return;
setSortBy(sortOptions[0] && sortOptions[0].value)
}, [metaList]);
useEffect(() => {
const filteredSessions = filters.size > 0 ? props.list.filter(session => {
let hasValidFilter = true;
@ -78,6 +95,10 @@ function LiveSessionList(props: Props) {
}
}
const onSortChange = (e, { value }) => {
setSortBy(value);
}
const timeout = () => {
timeoutId = setTimeout(() => {
props.fetchLiveList();
@ -87,6 +108,29 @@ function LiveSessionList(props: Props) {
return (
<div>
<div className="flex mb-6 justify-between items-end">
<div className="flex items-baseline">
<h3 className="text-2xl capitalize">
<span>Live Sessions</span>
<span className="ml-2 font-normal color-gray-medium">{sessions.size}</span>
</h3>
</div>
<div className="flex items-center">
<div className="flex items-center">
<span className="mr-2 color-gray-medium">Timezone</span>
<TimezoneDropdown />
</div>
<div className="flex items-center ml-6 mr-4">
<span className="mr-2 color-gray-medium">Sort By</span>
<DropdownPlain
options={sortOptions}
onChange={onSortChange}
value={sortBy}
/>
</div>
<SortOrderButton onChange={props.toggleSortOrder} sortOrder={sortOrder} />
</div>
</div>
<NoContent
title={"No live sessions."}
subtext={
@ -99,22 +143,25 @@ function LiveSessionList(props: Props) {
show={ !loading && sessions && sessions.size === 0}
>
<Loader loading={ loading }>
{sessions && sessions.take(displayedCount).map(session => (
{sessions && sessions.sortBy(i => i.metadata[sortBy]).update(list => {
return sortOrder === 'desc' ? list.reverse() : list;
}).take(displayedCount).map(session => (
<SessionItem
key={ session.sessionId }
session={ session }
live
hasUserFilter={hasUserFilter}
onUserClick={onUserClick}
metaList={metaList}
/>
))}
<LoadMoreButton
className="mt-3"
displayedCount={displayedCount}
totalCount={sessions.size}
onClick={addPage}
/>
<LoadMoreButton
className="mt-3"
displayedCount={displayedCount}
totalCount={sessions.size}
onClick={addPage}
/>
</Loader>
</NoContent>
</div>
@ -127,6 +174,15 @@ export default withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(connect(
loading: state.getIn([ 'sessions', 'loading' ]),
filters: state.getIn([ 'liveSearch', 'instance', 'filters' ]),
currentPage: state.getIn(["liveSearch", "currentPage"]),
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
sortOrder: state.getIn(['liveSearch', 'sortOrder']),
}),
{ fetchLiveList, applyFilter, addAttribute, addFilterByKeyAndValue, updateCurrentPage }
{
fetchLiveList,
applyFilter,
addAttribute,
addFilterByKeyAndValue,
updateCurrentPage,
toggleSortOrder,
}
)(LiveSessionList));

View file

@ -0,0 +1,3 @@
.bar {
height: 2px;
}

View file

@ -0,0 +1,44 @@
import React from 'react'
import cn from 'classnames'
import stl from './ErrorBars.css'
const GOOD = 'Good'
const LESS_CRITICAL = 'Few Issues'
const CRITICAL = 'Many Issues'
const getErrorState = (count: number) => {
if (count === 0) { return GOOD }
if (count < 2) { return LESS_CRITICAL }
return CRITICAL
}
interface Props {
count?: number
}
export default function ErrorBars(props: Props) {
const { count = 2 } = props
const state = React.useMemo(() => getErrorState(count), [count])
const isGood = state === GOOD
const showFirstBar = (state === LESS_CRITICAL || state === CRITICAL)
const showSecondBar = (state === CRITICAL)
// const showThirdBar = (state === GOOD || state === CRITICAL);
// const bgColor = { 'bg-red' : state === CRITICAL, 'bg-red2' : state === LESS_CRITICAL }
const bgColor = 'bg-red2'
return isGood ? <></> : (
<div>
<div className="relative" style={{ width: '100px' }}>
<div className="grid grid-cols-3 gap-1 absolute inset-0" style={{ opacity: '1'}}>
{ showFirstBar && <div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div> }
{ showSecondBar && <div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div> }
{/* { showThirdBar && <div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div> } */}
</div>
<div className="grid grid-cols-3 gap-1" style={{ opacity: '0.3'}}>
<div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div>
<div className={cn(bgColor, stl.bar)}></div>
{/* <div className={cn("rounded-tr rounded-br", bgColor, stl.bar)}></div> */}
</div>
</div>
<div className="mt-1 color-gray-medium text-sm">{state}</div>
</div>
)
}

View file

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

View file

@ -0,0 +1,22 @@
import React from 'react'
import cn from 'classnames'
import { TextEllipsis } from 'UI'
interface Props {
className?: string,
label: string,
value?: string,
}
export default function MetaItem(props: Props) {
const { className = '', label, value } = props
return (
<div className={cn("flex items-center rounded", className)}>
<span className="rounded-tl rounded-bl bg-gray-light-shade px-2 color-gray-medium capitalize" style={{ maxWidth: "150px"}}>
<TextEllipsis text={label} className="p-0" popupProps={{ size: 'small', disabled: true }} />
</span>
<span className="rounded-tr rounded-br bg-gray-lightest px-2 color-gray-dark capitalize" style={{ maxWidth: "150px"}}>
<TextEllipsis text={value} className="p-0" popupProps={{ size: 'small', disabled: true }} />
</span>
</div>
)
}

View file

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

View file

@ -0,0 +1,32 @@
import React from 'react'
import { Popup } from 'UI'
import MetaItem from '../MetaItem'
interface Props {
list: any[],
maxLength: number,
}
export default function MetaMoreButton(props: Props) {
const { list, maxLength } = props
return (
<Popup
trigger={ (
<div className="flex items-center">
<span className="rounded bg-active-blue color-teal px-2 color-gray-dark cursor-pointer">
+{list.length - maxLength} More
</span>
</div>
) }
content={
<div className="flex flex-col">
{list.slice(maxLength).map(({ label, value }, index) => (
<MetaItem key={index} label={label} value={value} className="mb-3" />
))}
</div>
}
on="click"
position="center center"
hideOnScroll
/>
)
}

View file

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

View file

@ -18,13 +18,18 @@ import LiveTag from 'Shared/LiveTag';
import Bookmark from 'Shared/Bookmark';
import Counter from './Counter'
import { withRouter } from 'react-router-dom';
import SessionMetaList from './SessionMetaList';
import ErrorBars from './ErrorBars';
import { assist as assistRoute, isRoute } from "App/routes";
import { capitalize } from 'App/utils';
const ASSIST_ROUTE = assistRoute();
const Label = ({ label = '', color = 'color-gray-medium'}) => (
<div className={ cn('font-light text-sm', color)}>{label}</div>
)
@connect(state => ({
timezone: state.getIn(['sessions', 'timezone']),
isAssist: state.getIn(['sessions', 'activeTab']).type === 'live',
siteId: state.getIn([ 'user', 'siteId' ]),
}), { toggleFavorite, setSessionPath })
@withRouter
@ -50,75 +55,96 @@ export default class SessionItem extends React.PureComponent {
userDeviceType,
userUuid,
userNumericHash,
live
live,
metadata,
userSessionsCount,
},
timezone,
onUserClick = () => null,
hasUserFilter = false,
disableUser = false
disableUser = false,
metaList = [],
} = this.props;
const formattedDuration = durationFormatted(duration);
const hasUserId = userId || userAnonymousId;
const isAssist = isRoute(ASSIST_ROUTE, this.props.location.pathname);
const _metaList = Object.keys(metadata).filter(i => metaList.includes(i)).map(key => {
const value = metadata[key];
return { label: key, value };
});
return (
<div className={ stl.sessionItem } id="session-item" >
<div className={ cn('flex items-center mr-auto')}>
<div className="flex items-center mr-6" style={{ width: '200px' }}>
<Avatar seed={ userNumericHash } />
<div className="flex flex-col ml-3 overflow-hidden">
<div
className={cn({'color-teal cursor-pointer': !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
onClick={() => (!disableUser && !hasUserFilter && hasUserId) && onUserClick(userId, userAnonymousId)}
>
<TextEllipsis text={ userDisplayName } noHint />
<div className={ cn(stl.sessionItem, "flex flex-col bg-white p-3 mb-3") } id="session-item" >
<div className="flex items-start">
<div className={ cn('flex items-center w-full')}>
<div className="flex items-center pr-2" style={{ width: "30%"}}>
<div><Avatar seed={ userNumericHash } isAssist={isAssist} /></div>
{/* <div className="flex flex-col overflow-hidden color-gray-medium ml-3"> */}
<div className="flex flex-col overflow-hidden color-gray-medium ml-3 justify-between items-center">
<div
className={cn('text-lg', {'color-teal cursor-pointer': !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
onClick={() => (!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)}
>
{userDisplayName}
</div>
{/* <div
className="color-gray-medium text-dotted-underline cursor-pointer"
onClick={() => (!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)}
>
{userSessionsCount} Sessions
</div> */}
</div>
<Label label={ formatTimeOrDate(startedAt, timezone) } />
</div>
</div>
<div className={ cn(stl.iconStack, 'flex-1') }>
<div className={ stl.icons }>
<CountryFlag country={ userCountry } className="mr-6" />
<BrowserIcon browser={ userBrowser } size="16" className="mr-6" />
<OsIcon os={ userOs } size="16" className="mr-6" />
<Icon name={ deviceTypeIcon(userDeviceType) } size="16" className="mr-6" />
<div style={{ width: "20%", height: "38px" }} className="px-2 flex flex-col justify-between">
<div>{formatTimeOrDate(startedAt, timezone) }</div>
<div className="flex items-center color-gray-medium">
{!isAssist && (
<>
<div className="color-gray-medium">
<span className="mr-1">{ eventsCount }</span>
<span>{ eventsCount === 0 || eventsCount > 1 ? 'Events' : 'Event' }</span>
</div>
<div className="mx-2 text-4xl">·</div>
</>
)}
<div>{ live ? <Counter startTime={startedAt} /> : formattedDuration }</div>
</div>
</div>
</div>
<div className="flex flex-col items-center px-4" style={{ width: '150px'}}>
<div className="text-xl">
{ live ? <Counter startTime={startedAt} /> : formattedDuration }
<div style={{ width: "30%", height: "38px" }} className="px-2 flex flex-col justify-between">
<CountryFlag country={ userCountry } className="mr-2" label />
<div className="color-gray-medium flex items-center">
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userBrowser) } popupProps={{ inverted: true, size: "tiny" }} />
</span>
<div className="mx-2 text-4xl">·</div>
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userOs) } popupProps={{ inverted: true, size: "tiny" }} />
</span>
<div className="mx-2 text-4xl">·</div>
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userDeviceType) } popupProps={{ inverted: true, size: "tiny" }} />
</span>
</div>
</div>
<Label label="Duration" />
{ !isAssist && (
<div style={{ width: "10%"}} className="self-center px-2 flex items-center">
<ErrorBars count={errorsCount} />
</div>
)}
</div>
{!live && (
<div className="flex flex-col items-center px-4">
<div className={ stl.count }>{ eventsCount }</div>
<Label label={ eventsCount === 0 || eventsCount > 1 ? 'Events' : 'Event' } />
<div className="flex items-center">
<div className={ stl.playLink } id="play-button" data-viewed={ viewed }>
<Link to={ sessionRoute(sessionId) }>
<Icon name={ !viewed && !isAssist ? 'play-fill' : 'play-circle-light' } size="42" color={isAssist ? "tealx" : "teal"} />
</Link>
</div>
)}
</div>
<div className="flex items-center">
{!live && (
<div className="flex flex-col items-center px-4">
<div className={ cn(stl.count, { "color-gray-medium": errorsCount === 0 }) } >{ errorsCount }</div>
<Label label="Errors" color={errorsCount > 0 ? '' : 'color-gray-medium'} />
</div>
)}
{ live && <LiveTag isLive={true} /> }
<div className={ cn(stl.iconDetails, stl.favorite, 'px-4') } data-favourite={favorite} >
<Bookmark sessionId={sessionId} favorite={favorite} />
</div>
<div className={ stl.playLink } id="play-button" data-viewed={ viewed }>
<Link to={ sessionRoute(sessionId) }>
<Icon name={ viewed ? 'play-fill' : 'play-circle-light' } size="30" color="teal" />
</Link>
</div>
</div>
{ _metaList.length > 0 && (
<SessionMetaList className="mt-4" metaList={_metaList} />
)}
</div>
);
}

View file

@ -0,0 +1,25 @@
import React from 'react'
import { Popup } from 'UI'
import cn from 'classnames'
import MetaItem from '../MetaItem';
import MetaMoreButton from '../MetaMoreButton';
interface Props {
className?: string,
metaList: []
}
const MAX_LENGTH = 3;
export default function SessionMetaList(props: Props) {
const { className = '', metaList } = props
return (
<div className={cn("text-sm flex items-start", className)}>
{metaList.slice(0, MAX_LENGTH).map(({ label, value }, index) => (
<MetaItem key={index} label={label} value={''+value} className="mr-3" />
))}
{metaList.length > MAX_LENGTH && (
<MetaMoreButton list={metaList} maxLength={MAX_LENGTH} />
)}
</div>
)
}

View file

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

View file

@ -12,12 +12,12 @@
user-select: none;
@mixin defaultHover;
border-radius: 3px;
padding: 10px 10px;
padding-right: 15px;
margin-bottom: 15px;
background-color: white;
display: flex;
align-items: center;
/* padding: 10px 10px; */
/* padding-right: 15px; */
/* margin-bottom: 15px; */
/* background-color: white; */
/* display: flex; */
/* align-items: center; */
border: solid thin #EEEEEE;
& .favorite {

View file

@ -0,0 +1,44 @@
import React from 'react'
import { Icon, Popup } from 'UI'
import cn from 'classnames'
interface Props {
sortOrder: string,
onChange?: (sortOrder: string) => void,
}
export default React.memo(function SortOrderButton(props: Props) {
const { sortOrder, onChange = () => null } = props
const isAscending = sortOrder === 'asc'
return (
<div className="flex items-center border">
<Popup
inverted
size="mini"
trigger={
<div
className={cn("p-1 hover:bg-active-blue", { 'cursor-pointer bg-white' : !isAscending, 'bg-active-blue' : isAscending })}
onClick={() => onChange('asc')}
>
<Icon name="arrow-up" size="14" color={isAscending ? 'teal' : 'gray-medium'} />
</div>
}
content={'Ascending'}
/>
<Popup
inverted
size="mini"
trigger={
<div
className={cn("p-1 hover:bg-active-blue border-l", { 'cursor-pointer bg-white' : isAscending, 'bg-active-blue' : !isAscending })}
onClick={() => onChange('desc')}
>
<Icon name="arrow-down" size="14" color={!isAscending ? 'teal' : 'gray-medium'} />
</div>
}
content={'Descending'}
/>
</div>
)
})

View file

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

View file

@ -11,14 +11,15 @@ const ICON_LIST = ['icn_chameleon', 'icn_fox', 'icn_gorilla', 'icn_hippo', 'icn_
'icn_wild1', 'icn_wild_bore']
const Avatar = ({ className, width = "38px", height = "38px", iconSize = 26, seed }) => {
const Avatar = ({ isAssist = false, className, width = "38px", height = "38px", iconSize = 26, seed }) => {
var iconName = avatarIconName(seed);
return (
<div
className={ cn(stl.wrapper, "p-2 border flex items-center justify-center rounded-full")}
className={ cn(stl.wrapper, "p-2 border flex items-center justify-center rounded-full relative")}
style={{ width, height }}
>
<Icon name={iconName} size={iconSize} color="tealx"/>
{isAssist && <div className="w-2 h-2 bg-green rounded-full absolute right-0 bottom-0" style={{ marginRight: '3px', marginBottom: '3px'}} /> }
</div>
);
};

View file

@ -22,7 +22,7 @@ const Confirmation = ({
content={confirmation}
header={header}
className="confirmCustom"
confirmButton={<Button size="small" id="confirm-button" primary>{ confirmButton }</Button>}
confirmButton={<Button size="small" id="confirm-button" className="ml-0" primary>{ confirmButton }</Button>}
cancelButton={<Button size="small" id="cancel-button" plain className={ stl.cancelButton }>{ cancelButton }</Button>}
onCancel={() => proceed(false)}
onConfirm={() => proceed(true)}

View file

@ -1,24 +1,34 @@
import cn from 'classnames';
import { countries } from 'App/constants';
import { Popup } from 'UI';
import { Popup, Icon } from 'UI';
import stl from './countryFlag.css';
const CountryFlag = ({ country, className }) => {
const CountryFlag = React.memo(({ country, className, style = {}, label = false }) => {
const knownCountry = !!country && country !== 'UN';
const countryFlag = knownCountry ? country.toLowerCase() : '';
const countryName = knownCountry ? countries[ country ] : 'Unknown Country';
const countryFlag = knownCountry ? country.toLowerCase() : '';
const countryName = knownCountry ? countries[ country ] : 'Unknown Country';
return (
<Popup
trigger={ knownCountry
? <span className={ cn(`flag flag-${ countryFlag }`, className, stl.default) } />
: <span className={ className } >{ "N/A" }</span>
}
content={ countryName }
inverted
size="tiny"
/>
<div className="flex items-center" style={style}>
<Popup
trigger={ knownCountry
? <div className={ cn(`flag flag-${ countryFlag }`, className, stl.default) } />
: (
<div className="flex items-center w-full">
<Icon name="flag-na" size="22" className="" />
<div className="ml-2 leading-none" style={{ whiteSpace: 'nowrap'}}>Unknown Country</div>
</div>
)
// : <div className={ cn('text-sm', className) }>{ "N/A" }</div>
}
content={ countryName }
inverted
size="tiny"
/>
{ knownCountry && label && <div className={ stl.label }>{ countryName }</div> }
</div>
);
}
})
CountryFlag.displayName = "CountryFlag";

View file

@ -1,4 +1,8 @@
.default {
width: 22px !important;
height: 14px !important;
}
.label {
line-height: 0 !important;
}

View file

@ -9,8 +9,10 @@ const IconButton = React.forwardRef(({
onClick,
plain = false,
shadow = false,
red = false,
primary = false,
primaryText = false,
redText = false,
outline = false,
loading = false,
roundedOutline = false,
@ -40,7 +42,9 @@ const IconButton = React.forwardRef(({
[ stl.active ]: active,
[ stl.shadow ]: shadow,
[ stl.primary ]: primary,
[ stl.red ]: red,
[ stl.primaryText ]: primaryText,
[ stl.redText ]: redText,
[ stl.outline ]: outline,
[ stl.circle ]: circle,
[ stl.roundedOutline ]: roundedOutline,

View file

@ -67,17 +67,47 @@
&.primary {
background-color: $teal;
box-shadow: 0 0 0 1px rgba(62, 170, 175, .8) inset !important;
box-shadow: 0 0 0 1px $teal inset !important;
& .icon {
fill: white;
}
& svg {
fill: white;
}
& .label {
color: white !important;
}
&:hover {
background-color: $teal-dark;
}
}
&.red {
background-color: $red;
box-shadow: 0 0 0 1px $red inset !important;
& .icon {
fill: white;
}
& svg {
fill: white;
}
& .label {
color: white !important;
}
&:hover {
background-color: $red;
filter: brightness(90%);
}
}
&.outline {
box-shadow: 0 0 0 1px $teal inset !important;
& .label {
@ -116,4 +146,14 @@
.primaryText .label {
color: $teal !important;
}
.redText {
& .label {
color: $red !important;
}
& svg {
fill: $red;
}
}

View file

@ -1,7 +1,7 @@
.loader {
display: block;
margin: auto;
background-image: svg-load(openreplay-preloader.svg, fill=#CCC);
background-image: svg-load(openreplay-preloader.svg, fill=#ffffff00);
background-repeat: no-repeat;
background-size: contain;
background-position: center center;

View file

@ -1,7 +1,7 @@
.textEllipsis {
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
/* display: inline-block; */
white-space: nowrap;
max-width: 100%;
}

View file

@ -15,21 +15,24 @@ const EDIT = editType(name);
const CLEAR_SEARCH = `${name}/CLEAR_SEARCH`;
const APPLY = `${name}/APPLY`;
const UPDATE_CURRENT_PAGE = `${name}/UPDATE_CURRENT_PAGE`;
const TOGGLE_SORT_ORDER = `${name}/TOGGLE_SORT_ORDER`;
const initialState = Map({
list: List(),
instance: new Filter({ filters: [] }),
filterSearchList: {},
currentPage: 1,
sortOrder: 'asc',
});
function reducer(state = initialState, action = {}) {
switch (action.type) {
case EDIT:
return state.mergeIn(['instance'], action.instance);
case UPDATE_CURRENT_PAGE:
return state.set('currentPage', action.page);
case TOGGLE_SORT_ORDER:
return state.set('sortOrder', action.order);
}
return state;
}
@ -98,4 +101,11 @@ export function updateCurrentPage(page) {
type: UPDATE_CURRENT_PAGE,
page,
};
}
export function toggleSortOrder (order) {
return {
type: TOGGLE_SORT_ORDER,
order,
};
}

View file

@ -243,9 +243,12 @@ export const addFilter = (filter) => (dispatch, getState) => {
}
}
export const addFilterByKeyAndValue = (key, value) => (dispatch, getState) => {
export const addFilterByKeyAndValue = (key, value, operator = undefined) => (dispatch, getState) => {
let defaultFilter = filtersMap[key];
defaultFilter.value = value;
if (operator) {
defaultFilter.operator = operator;
}
dispatch(addFilter(defaultFilter));
}

View file

@ -270,12 +270,7 @@ function init(session) {
}
export const fetchList = (params = {}, clear = false, live = false) => (dispatch, getState) => {
const activeTab = getState().getIn([ 'sessions', 'activeTab' ]);
return dispatch((activeTab && activeTab.type === 'live' || live )? {
types: FETCH_LIVE_LIST.toArray(),
call: client => client.post('/assist/sessions', params),
} : {
return dispatch({
types: FETCH_LIST.toArray(),
call: client => client.post('/sessions/search2', params),
clear,
@ -283,13 +278,6 @@ export const fetchList = (params = {}, clear = false, live = false) => (dispatch
})
}
// export const fetchLiveList = (id) => (dispatch, getState) => {
// return dispatch({
// types: FETCH_LIVE_LIST.toArray(),
// call: client => client.get('/assist/sessions'),
// })
// }
export function fetchErrorStackList(sessionId, errorId) {
return {
types: FETCH_ERROR_STACK.toArray(),

View file

@ -82,6 +82,7 @@ const routerOBTabString = `:activeTab(${ Object.values(OB_TABS).join('|') })`;
export const onboarding = (tab = routerOBTabString) => `/onboarding/${ tab }`;
export const sessions = params => queried('/sessions', params);
export const assist = params => queried('/assist', params);
export const session = (sessionId = ':sessionId', hash) => hashed(`/session/${ sessionId }`, hash);
export const liveSession = (sessionId = ':sessionId', hash) => hashed(`/live/session/${ sessionId }`, hash);
@ -105,7 +106,7 @@ export const METRICS_QUERY_KEY = 'metrics';
export const SOURCE_QUERY_KEY = 'source';
export const WIDGET_QUERY_KEY = 'widget';
const REQUIRED_SITE_ID_ROUTES = [ liveSession(''), session(''), sessions(), dashboard(''), error(''), errors(), onboarding(''), funnel(''), funnelIssue(''), ];
const REQUIRED_SITE_ID_ROUTES = [ liveSession(''), session(''), sessions(), assist(), dashboard(''), error(''), errors(), onboarding(''), funnel(''), funnelIssue(''), ];
const routeNeedsSiteId = path => REQUIRED_SITE_ID_ROUTES.some(r => path.startsWith(r));
const siteIdToUrl = (siteId = ':siteId') => {
if (Array.isArray(siteId)) {
@ -128,7 +129,7 @@ export function isRoute(route, path){
routeParts.every((p, i) => p.startsWith(':') || p === pathParts[ i ]);
}
const SITE_CHANGE_AVALIABLE_ROUTES = [ sessions(), dashboard(), errors(), onboarding('')];
const SITE_CHANGE_AVALIABLE_ROUTES = [ sessions(), assist(), dashboard(), errors(), onboarding('')];
export const siteChangeAvaliable = path => SITE_CHANGE_AVALIABLE_ROUTES.some(r => isRoute(r, path));

View file

@ -123,4 +123,22 @@
&:hover {
background-color: $active-blue;
}
}
.text-dotted-underline {
text-decoration: underline dotted !important;
}
.divider {
width: 1px;
margin: 0 15px;
background-color: $gray-light;
}
.divider-h {
height: 1px;
width: 100%;
margin: 25px 0;
background-color: $gray-light;
}

View file

@ -336,4 +336,13 @@ a:hover {
overflow: hidden;
text-overflow: ellipsis;
margin-right: 15px;
}
.ui.mini.modal>.header:not(.ui) {
padding: 10px 17px !important;
font-size: 16px !important;
}
.ui.modal>.content {
padding: 10px 17px !important;
}

View file

@ -0,0 +1,3 @@
<svg width="22" height="14" viewBox="0 0 22 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 0C0.895431 0 0 0.89543 0 2V12C0 13.1046 0.89543 14 2 14H20C21.1046 14 22 13.1046 22 12V2C22 0.895431 21.1046 0 20 0H2ZM11.0757 10V3.60156H9.98145V8.19385L7.10303 3.60156H6V10H7.10303V5.4165L9.97266 10H11.0757ZM15.0396 3.60156H15.3032L17.7202 10H16.5601L16.0423 8.50146H13.5654L13.0488 10H11.8931L14.3013 3.60156H14.5605H15.0396ZM13.8668 7.62695H15.7402L14.8024 4.91255L13.8668 7.62695Z" fill="#C4C4C4"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View file

@ -1 +1,7 @@
<svg id="e8s3e2insbce1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 70 70" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><style><![CDATA[#e8s3e2insbce2_tr {animation: e8s3e2insbce2_tr__tr 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce2_tr__tr { 0% {transform: translate(35px,35px) rotate(-359deg)} 50% {transform: translate(35px,35px) rotate(0deg)} 100% {transform: translate(35px,35px) rotate(359deg)} }#e8s3e2insbce2_ts {animation: e8s3e2insbce2_ts__ts 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce2_ts__ts { 0% {transform: scale(0.510000,0.510000)} 50% {transform: scale(1,1)} 100% {transform: scale(0.510000,0.510000)} }#e8s3e2insbce3 {animation: e8s3e2insbce3_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce3_c_o { 0% {opacity: 0} 27.272727% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 100% {opacity: 0} }#e8s3e2insbce4 {animation: e8s3e2insbce4_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce4_c_o { 0% {opacity: 0} 18.181818% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 90.909091% {opacity: 0} 100% {opacity: 0} }#e8s3e2insbce5 {animation: e8s3e2insbce5_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce5_c_o { 0% {opacity: 0} 9.090909% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 81.818182% {opacity: 0} 100% {opacity: 0} }#e8s3e2insbce6 {animation: e8s3e2insbce6_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce6_c_o { 0% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 72.727273% {opacity: 0} 100% {opacity: 0} }]]></style><g id="e8s3e2insbce2_tr" transform="translate(35,35) rotate(-359)"><g id="e8s3e2insbce2_ts" transform="scale(0.510000,0.510000)"><g id="e8s3e2insbce2" transform="translate(-35,-35)"><circle id="e8s3e2insbce3" r="10.500000" transform="matrix(1 0 0 1 19.50000000000000 50.50000000000000)" opacity="0" fill="rgb(66,174,94)" fill-rule="evenodd" stroke="none" stroke-width="1"/><circle id="e8s3e2insbce4" r="10.500000" transform="matrix(1 0 0 1 50.50000000000000 50.50000000000000)" opacity="0" fill="rgb(57,177,255)" fill-rule="evenodd" stroke="none" stroke-width="1"/><circle id="e8s3e2insbce5" r="10.500000" transform="matrix(1 0 0 1 50.50000000000000 19.50000000000000)" opacity="0" fill="rgb(57,78,255)" fill-rule="evenodd" stroke="none" stroke-width="1"/><circle id="e8s3e2insbce6" r="10.500000" transform="matrix(1 0 0 1 19.50000000000000 19.50000000000000)" opacity="0" fill="rgb(0,145,147)" fill-rule="evenodd" stroke="none" stroke-width="1"/></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 55 55" fill="none" height="55" width="55"><style>
@keyframes uro78awc4tzn00dgpjdreb25_t { 0% { transform: translate(27.5px,27.5px) rotate(0deg) scale(1,1) translate(0px,0px); } 100% { transform: translate(27.5px,27.5px) rotate(355deg) scale(1,1) translate(0px,0px); } }
@keyframes uro78awc4tzn00dgpjdreb25_sw { 0% { stroke-width: 3px; } 100% { stroke-width: 3px; } }
@keyframes xsc8rs7q380bevvc8x83et5f_t { 0% { transform: translate(29.7px,27.5px) scale(1,1) translate(-15.2px,-18px); } 50% { transform: translate(29.7px,27.5px) scale(1,1) translate(-15.2px,-18px); } 100% { transform: translate(29.7px,27.5px) scale(1,1) translate(-15.2px,-18px); } }
@keyframes xsc8rs7q380bevvc8x83et5f_o { 0% { opacity: .1; } 50% { opacity: 1; } 100% { opacity: .1; } }
@keyframes xsc8rs7q380bevvc8x83et5f_d { 0% { d: path('M20.2,18L9.8,11.9L9.8,24.1L20.2,18ZM21.7,16.7C21.9,16.9,22.1,17.1,22.2,17.3C22.3,17.5,22.4,17.7,22.4,18C22.4,18.3,22.3,18.5,22.2,18.7C22.1,18.9,21.9,19.1,21.7,19.3L10.2,25.9C9.3,26.4,8,25.8,8,24.6L8,11.4C8,10.2,9.3,9.6,10.2,10.1L21.7,16.7Z'); } 50% { d: path('M23,18L6.9,8.7L6.9,27.3L23,18ZM25.2,16.1C25.5,16.3,25.8,16.5,26,16.9C26.2,17.2,26.3,17.6,26.3,18C26.3,18.4,26.2,18.8,26,19.1C25.8,19.5,25.5,19.7,25.2,19.9L7.5,30.2C6.1,31,4.1,30,4.1,28.2L4.1,7.8C4.1,6,6.1,5,7.5,5.8L25.2,16.1Z'); } 100% { d: path('M20.2,18L9.8,11.9L9.8,24.1L20.2,18ZM21.7,16.7C21.9,16.9,22.1,17.1,22.2,17.3C22.3,17.5,22.4,17.7,22.4,18C22.4,18.3,22.3,18.5,22.2,18.7C22.1,18.9,21.9,19.1,21.7,19.3L10.2,25.9C9.3,26.4,8,25.8,8,24.6L8,11.4C8,10.2,9.3,9.6,10.2,10.1L21.7,16.7Z'); } }
</style><ellipse stroke="#6070f8" stroke-width="3" stroke-dasharray="6 6" rx="25" ry="25" transform="translate(27.5,27.5)" style="animation: 1s linear infinite both uro78awc4tzn00dgpjdreb25_t, 1s linear infinite both uro78awc4tzn00dgpjdreb25_sw;"/><path d="M20.2 18l-10.4-6.1v12.2l10.4-6.1Zm1.5-1.3c.2 .2 .4 .4 .5 .6c.1 .2 .2 .4 .2 .7c0 .3-0.1 .5-0.2 .7c-0.1 .2-0.3 .4-0.5 .6l-11.5 6.6c-0.9 .5-2.2-0.1-2.2-1.3v-13.2c0-1.2 1.3-1.8 2.2-1.3l11.5 6.6Z" fill="#122af5" opacity=".1" transform="translate(29.7,27.5) translate(-15.2,-18)" style="animation: 1s linear infinite both xsc8rs7q380bevvc8x83et5f_t, 1s linear infinite both xsc8rs7q380bevvc8x83et5f_o, 1s linear infinite both xsc8rs7q380bevvc8x83et5f_d;"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -34,6 +34,7 @@ export default Record({
rangeValue,
startDate,
endDate,
groupByUser: true,
sort: 'startTs',
order: 'desc',

View file

@ -44,7 +44,7 @@ export const filtersMap = {
[FilterKey.DURATION]: { key: FilterKey.DURATION, type: FilterType.DURATION, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Duration', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is']), icon: 'filters/duration' },
[FilterKey.USER_COUNTRY]: { key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'User Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
// [FilterKey.CONSOLE]: { key: FilterKey.CONSOLE, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Console', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/console' },
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid' },
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'is', operatorOptions: filterOptions.stringOperators.concat([{ text: 'is undefined', value: 'isUndefined'}]), icon: 'filters/userid' },
[FilterKey.USERANONYMOUSID]: { key: FilterKey.USERANONYMOUSID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User AnonymousId', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid' },
// PERFORMANCE

View file

@ -36,7 +36,7 @@ export default Record({
stackEvents: List(),
resources: List(),
missedResources: List(),
metadata: List(),
metadata: Map(),
favorite: false,
filterId: '',
messagesUrl: '',
@ -76,6 +76,7 @@ export default Record({
socket: null,
isIOS: false,
revId: '',
userSessionsCount: 0,
}, {
fromJS:({
startTs=0,