Merge branch 'dev' of github.com:openreplay/openreplay into funnels

This commit is contained in:
Shekar Siri 2022-05-13 16:05:30 +02:00
commit 3baa3ea9a5
27 changed files with 416 additions and 333 deletions

View file

@ -14,7 +14,7 @@ const SOME_ERROR_MSG = "Some error occured.";
const defaultValueToText = value => value;
const defaultOptionMapping = (values, valueToText) => values.map(value => ({ text: valueToText(value), value }));
const hiddenStyle = {
const hiddenStyle = {
whiteSpace: 'pre-wrap',
opacity: 0, position: 'fixed', left: '-3000px'
};
@ -32,10 +32,10 @@ class AutoComplete extends React.PureComponent {
values: [],
noResultsMessage: TYPE_TO_SEARCH_MSG,
ddOpen: false,
query: this.props.value,
query: this.props.value,
loading: false,
error: false
}
}
componentWillReceiveProps(newProps) {
if (this.props.value !== newProps.value) {
@ -49,8 +49,8 @@ class AutoComplete extends React.PureComponent {
requestValues = (q) => {
const { params, endpoint, method } = this.props;
this.setState({
loading: true,
this.setState({
loading: true,
error: false,
});
return new APIClient()[ method.toLowerCase() ](endpoint, { ...params, q })
@ -72,7 +72,7 @@ class AutoComplete extends React.PureComponent {
debouncedRequestValues = debounce(this.requestValues, 1000)
setError = () => this.setState({
setError = () => this.setState({
loading: false,
error: true,
noResultsMessage: SOME_ERROR_MSG,
@ -98,7 +98,7 @@ class AutoComplete extends React.PureComponent {
const _value = value.trim();
onSelect(null, {name, value: _value});
}
changed = false;
pasted = false;
}
@ -126,10 +126,10 @@ class AutoComplete extends React.PureComponent {
} = this.props;
const options = optionMapping(values, valueToText)
return (
<OutsideClickDetectingDiv
className={ cn("relative flex items-center", { "flex-1" : fullWidth }) }
<OutsideClickDetectingDiv
className={ cn("relative flex items-center", { "flex-1" : fullWidth }) }
onClickOutside={this.onClickOutside}
>
{/* <EventSearchInput /> */}
@ -140,7 +140,6 @@ class AutoComplete extends React.PureComponent {
onFocus={ () => this.setState({ddOpen: true})}
onChange={ this.onInputChange }
onBlur={ this.onBlur }
onFocus={ () => this.setState({ddOpen: true})}
value={ query }
autoFocus={ true }
type="text"
@ -150,6 +149,7 @@ class AutoComplete extends React.PureComponent {
this.hiddenInput.value = text;
pasted = true; // to use only the hidden input
} }
autocomplete="do-not-autofill-bad-chrome"
/>
<div className={stl.right} onClick={showCloseButton ? onRemoveValue : onAddValue}>
{ showCloseButton ? <Icon name="close" size="14" /> : <span className="px-1">or</span>}
@ -182,11 +182,11 @@ class AutoComplete extends React.PureComponent {
{ headerText && headerText }
{
options.map(item => (
<FilterItem
<FilterItem
label={ item.value }
icon={ item.icon }
onClick={ (e) => this.onItemClick(e, item) }
/>
onClick={ (e) => this.onItemClick(e, item) }
/>
))
}
</div>

View file

@ -13,7 +13,7 @@ const sortOptionsMap = {
'startTs-desc': 'Newest',
'startTs-asc': 'Oldest',
'eventsCount-asc': 'Events Ascending',
'eventsCount-desc': 'Events Descending',
'eventsCount-desc': 'Events Descending',
};
const sortOptions = Object.entries(sortOptionsMap)
.map(([ value, text ]) => ({ value, text }));
@ -50,10 +50,6 @@ function SessionListHeader({
value='list'
/>
</div> */}
<div className="flex items-center">
<span className="mr-2 color-gray-medium">Timezone</span>
<TimezoneDropdown />
</div>
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Sort By</span>
<SortDropdown options={ sortOptions }/>

View file

@ -13,7 +13,7 @@ const SOME_ERROR_MSG = "Some error occured.";
const defaultValueToText = value => value;
const defaultOptionMapping = (values, valueToText) => values.map(value => ({ text: valueToText(value), value }));
const hiddenStyle = {
const hiddenStyle = {
whiteSpace: 'pre-wrap',
opacity: 0, position: 'fixed', left: '-3000px'
};
@ -31,10 +31,10 @@ class AutoComplete extends React.PureComponent {
values: [],
noResultsMessage: TYPE_TO_SEARCH_MSG,
ddOpen: false,
query: this.props.value,
query: this.props.value,
loading: false,
error: false
}
}
componentWillReceiveProps(newProps) {
if (this.props.value !== newProps.value) {
@ -48,8 +48,8 @@ class AutoComplete extends React.PureComponent {
requestValues = (q) => {
const { params, endpoint, method } = this.props;
this.setState({
loading: true,
this.setState({
loading: true,
error: false,
});
return new APIClient()[ method.toLowerCase() ](endpoint, { ...params, q })
@ -71,13 +71,13 @@ class AutoComplete extends React.PureComponent {
debouncedRequestValues = debounce(this.requestValues, 1000)
setError = () => this.setState({
setError = () => this.setState({
loading: false,
error: true,
noResultsMessage: SOME_ERROR_MSG,
})
onInputChange = ({ target: { value } }) => {
onInputChange = ({ target: { value } }) => {
changed = true;
this.setState({ query: value, updated: true })
const _value = value.trim();
@ -96,7 +96,7 @@ class AutoComplete extends React.PureComponent {
const _value = value.trim();
onSelect(null, {name, value: _value});
}
changed = false;
pasted = false;
}
@ -123,10 +123,10 @@ class AutoComplete extends React.PureComponent {
} = this.props;
const options = optionMapping(values, valueToText)
return (
<OutsideClickDetectingDiv
className={ cn("relative", { "flex-1" : fullWidth }) }
<OutsideClickDetectingDiv
className={ cn("relative", { "flex-1" : fullWidth }) }
onClickOutside={this.onClickOutside}
>
{/* <Input
@ -153,7 +153,6 @@ class AutoComplete extends React.PureComponent {
onFocus={ () => this.setState({ddOpen: true})}
onChange={ this.onInputChange }
onBlur={ this.onBlur }
onFocus={ () => this.setState({ddOpen: true})}
value={ query }
autoFocus={ true }
type="text"
@ -163,6 +162,7 @@ class AutoComplete extends React.PureComponent {
this.hiddenInput.value = text;
pasted = true; // to use only the hidden input
} }
autocomplete="do-not-autofill-bad-chrome"
/>
<div className={cn(stl.right, 'cursor-pointer')} onLick={onAddOrRemove}>
{/* <Icon name="close" size="18" /> */}
@ -175,10 +175,10 @@ class AutoComplete extends React.PureComponent {
{ headerText && headerText }
{
options.map(item => (
<FilterItem
<FilterItem
label={ item.value }
icon={ item.icon }
onClick={ (e) => this.onItemClick(e, item) }
onClick={ (e) => this.onItemClick(e, item) }
/>
// <DropdownItem key={ item.value } value={ item.value } onSelect={ (e) => this.onItemClick(e, item) } />
))

View file

@ -119,7 +119,7 @@ export default class FilterModal extends React.PureComponent {
return (!displayed ? null :
<div className={ stl.modal }>
<div className={ stl.modal }>
<div className={ stl.filterListStatic }>
{
filteredList.map(category => (

View file

@ -5,7 +5,7 @@ import { debounce } from 'App/utils';
import stl from './FilterAutoComplete.css';
import cn from 'classnames';
const hiddenStyle = {
const hiddenStyle = {
whiteSpace: 'pre-wrap',
opacity: 0, position: 'fixed', left: '-3000px'
};
@ -43,8 +43,8 @@ function FilterAutoComplete(props: Props) {
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<any>([]);
const [query, setQuery] = useState(value);
const requestValues = (q) => {
const requestValues = (q) => {
setLoading(true);
return new APIClient()[method?.toLocaleLowerCase()](endpoint, { ...params, q })
@ -90,7 +90,7 @@ function FilterAutoComplete(props: Props) {
e.preventDefault();
if (query !== item.value) {
setQuery(item.value);
setQuery(item.value);
}
props.onSelect(e, item);
@ -107,6 +107,7 @@ function FilterAutoComplete(props: Props) {
autoFocus={ true }
type="text"
placeholder={ placeholder }
autoComplete="do-not-autofill-bad-chrome"
// onPaste={(e) => {
// const text = e.clipboardData.getData('Text');
// // this.hiddenInput.value = text;
@ -139,11 +140,11 @@ function FilterAutoComplete(props: Props) {
>
{ icon && <Icon name={ icon } size="16" marginRight="8" /> }
<span className={ stl.label }>{ item.value }</span>
</div>
</div>
))
}
</div>
)}
)}
</Loader>
</div>
)}
@ -151,4 +152,4 @@ function FilterAutoComplete(props: Props) {
);
}
export default FilterAutoComplete;
export default FilterAutoComplete;

View file

@ -15,7 +15,7 @@ interface Props {
searchQuery?: string,
}
function FilterModal(props: Props) {
const {
const {
filters,
metaOptions,
onFilterClick = () => null,
@ -32,13 +32,20 @@ function FilterModal(props: Props) {
_filter.value = [filter.value];
onFilterClick(_filter);
}
const isResultEmpty = !filterSearchList || Object.keys(filterSearchList).length === 0
return (
<div className={stl.wrapper} style={{ width: '480px', maxHeight: '380px', overflowY: 'auto'}}>
{ showSearchList && (
<Loader size="small" loading={fetchingFilterSearchList}>
<div className="-mx-6 px-6">
{ filterSearchList && Object.keys(filterSearchList).map((key, index) => {
{isResultEmpty && !fetchingFilterSearchList ? (
<div className="flex items-center">
<Icon className="color-gray-medium" name="binoculars" size="24" />
<div className="color-gray-medium font-medium px-3"> No Suggestions Found </div>
</div>
) : Object.keys(filterSearchList).map((key, index) => {
const filter = filterSearchList[key];
const option = filtersMap[key];
return option ? (
@ -65,7 +72,7 @@ function FilterModal(props: Props) {
</div>
</Loader>
)}
{ !hasSearchQuery && (
<div className="" style={{ columns: "auto 200px" }}>
{filters && Object.keys(filters).map((key) => (
@ -92,4 +99,4 @@ export default connect(state => ({
filterSearchList: state.getIn([ 'search', 'filterSearchList' ]),
metaOptions: state.getIn([ 'customFields', 'list' ]),
fetchingFilterSearchList: state.getIn([ 'search', 'fetchFilterSearch', 'loading' ]),
}))(FilterModal);
}))(FilterModal);

View file

@ -15,7 +15,7 @@ interface Props {
searchQuery?: string,
}
function LiveFilterModal(props: Props) {
const {
const {
filters,
metaOptions,
onFilterClick = () => null,
@ -32,7 +32,9 @@ function LiveFilterModal(props: Props) {
_filter.value = [filter.value];
onFilterClick(_filter);
}
const isResultEmpty = !filterSearchList || Object.keys(filterSearchList).filter(i => filtersMap[i].isLive).length === 0
return (
<div className={stl.wrapper} style={{ width: '490px', maxHeight: '400px', overflowY: 'auto'}}>
{ showSearchList && (
@ -62,10 +64,39 @@ function LiveFilterModal(props: Props) {
</div>
);
})}
{isResultEmpty && !fetchingFilterSearchList ? (
<div className="flex items-center">
<Icon className="color-gray-medium" name="binoculars" size="24" />
<div className="color-gray-medium font-medium px-3"> No Suggestions Found </div>
</div>
) : Object.keys(filterSearchList).filter(i => filtersMap[i].isLive).map((key, index) => {
const filter = filterSearchList[key];
const option = filtersMap[key];
return (
<div
key={index}
className={cn('mb-3')}
>
<div className="font-medium uppercase color-gray-medium text-sm mb-2">{option.label}</div>
<div>
{filter.map((f, i) => (
<div
key={i}
className={cn(stl.filterSearchItem, "cursor-pointer px-3 py-1 text-sm flex items-center")}
onClick={() => onFilterSearchClick({ type: key, value: f.value })}
>
<Icon className="mr-2" name={option.icon} size="16" />
<div className="whitespace-nowrap text-ellipsis overflow-hidden">{f.value}</div>
</div>
))}
</div>
</div>
);
})}
</div>
</Loader>
)}
{ !hasSearchQuery && (
<div className="">
{filters && Object.keys(filters).map((key) => (
@ -92,4 +123,4 @@ export default connect(state => ({
filterSearchList: state.getIn([ 'search', 'filterSearchList' ]),
metaOptions: state.getIn([ 'customFields', 'list' ]),
fetchingFilterSearchList: state.getIn([ 'search', 'fetchFilterSearch', 'loading' ]),
}))(LiveFilterModal);
}))(LiveFilterModal);

View file

@ -20,14 +20,14 @@ const PER_PAGE = 10;
interface Props {
loading: Boolean,
list: List<any>,
list: List<any>,
fetchLiveList: () => Promise<void>,
applyFilter: () => void,
filters: any,
addAttribute: (obj) => void,
addFilterByKeyAndValue: (key: FilterKey, value: string) => void,
updateCurrentPage: (page: number) => void,
currentPage: number,
currentPage: number,
metaList: any,
updateSort: (sort: any) => void,
sort: any,
@ -41,7 +41,7 @@ function LiveSessionList(props: Props) {
const sortOptions = metaList.map(i => ({
text: capitalize(i), value: i
})).toJS();
// const displayedCount = Math.min(currentPage * PER_PAGE, sessions.size);
// const addPage = () => props.updateCurrentPage(props.currentPage + 1)
@ -69,7 +69,7 @@ function LiveSessionList(props: Props) {
if (filter.key === FilterKey.USERID) {
const _userId = session.userId ? session.userId.toLowerCase() : '';
hasValidFilter = _values.length > 0 ? (_values.includes(_userId) && hasValidFilter) || _values.some(i => _userId.includes(i)) : hasValidFilter;
}
}
if (filter.category === FilterCategory.METADATA) {
const _source = session.metadata[filter.key] ? session.metadata[filter.key].toLowerCase() : '';
hasValidFilter = _values.length > 0 ? (_values.includes(_source) && hasValidFilter) || _values.some(i => _source.includes(i)) : hasValidFilter;
@ -80,7 +80,7 @@ function LiveSessionList(props: Props) {
setSessions(filteredSessions);
}, [filters, list]);
useEffect(() => {
useEffect(() => {
props.fetchLiveList();
timeout();
return () => {
@ -88,7 +88,7 @@ function LiveSessionList(props: Props) {
}
}, [])
const onUserClick = (userId, userAnonymousId) => {
const onUserClick = (userId: string, userAnonymousId: string) => {
if (userId) {
props.addFilterByKeyAndValue(FilterKey.USERID, userId);
} else {
@ -183,7 +183,7 @@ export default withPermissions(['ASSIST_LIVE'])(connect(
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
sort: state.getIn(['liveSearch', 'sort']),
}),
{
{
fetchLiveList,
applyFilter,
addAttribute,

View file

@ -0,0 +1,42 @@
import React, { useState, useEffect } from 'react'
import {
Link,
Icon,
} from 'UI';
import { session as sessionRoute, liveSession as liveSessionRoute } from 'App/routes';
const PLAY_ICON_NAMES = {
notPlayed: 'play-fill',
played: 'play-circle-light',
hovered: 'play-hover'
}
const getDefaultIconName = (isViewed) => !isViewed ? PLAY_ICON_NAMES.notPlayed : PLAY_ICON_NAMES.played
interface Props {
isAssist: boolean;
viewed: boolean;
sessionId: string;
}
export default function PlayLink(props: Props) {
const { isAssist, viewed, sessionId } = props
const defaultIconName = getDefaultIconName(viewed)
const [isHovered, toggleHover] = useState(false)
const [iconName, setIconName] = useState(defaultIconName)
useEffect(() => {
if (isHovered) setIconName(PLAY_ICON_NAMES.hovered)
else setIconName(getDefaultIconName(viewed))
}, [isHovered, viewed])
return (
<Link
to={ isAssist ? liveSessionRoute(sessionId) : sessionRoute(sessionId) }
onMouseEnter={() => toggleHover(true)}
onMouseLeave={() => toggleHover(false)}
>
<Icon name={iconName} size="42" color={isAssist ? "tealx" : "teal"} />
</Link>
)
}

View file

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

View file

@ -1,159 +0,0 @@
import { connect } from 'react-redux';
import cn from 'classnames';
import {
Link,
Icon,
CountryFlag,
Avatar,
TextEllipsis,
Label,
} from 'UI';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import { session as sessionRoute, liveSession as liveSessionRoute, withSiteId } from 'App/routes';
import { durationFormatted, formatTimeOrDate } from 'App/date';
import stl from './sessionItem.css';
import Counter from './Counter'
import { withRouter } from 'react-router-dom';
import SessionMetaList from './SessionMetaList';
import ErrorBars from './ErrorBars';
import { assist as assistRoute, liveSession, sessions as sessionsRoute, isRoute } from "App/routes";
import { capitalize } from 'App/utils';
const ASSIST_ROUTE = assistRoute();
const ASSIST_LIVE_SESSION = liveSession()
const SESSIONS_ROUTE = sessionsRoute();
@connect(state => ({
timezone: state.getIn(['sessions', 'timezone']),
siteId: state.getIn([ 'site', 'siteId' ]),
}), { toggleFavorite, setSessionPath })
@withRouter
export default class SessionItem extends React.PureComponent {
// eslint-disable-next-line complexity
render() {
const {
session: {
sessionId,
userBrowser,
userOs,
userId,
userAnonymousId,
userDisplayName,
userCountry,
startedAt,
duration,
eventsCount,
errorsCount,
pagesCount,
viewed,
favorite,
userDeviceType,
userUuid,
userNumericHash,
live,
metadata,
userSessionsCount,
issueTypes,
active,
},
timezone,
onUserClick = () => null,
hasUserFilter = false,
disableUser = false,
metaList = [],
showActive = false,
lastPlayedSessionId,
} = this.props;
const formattedDuration = durationFormatted(duration);
const hasUserId = userId || userAnonymousId;
const isSessions = isRoute(SESSIONS_ROUTE, this.props.location.pathname);
const isAssist = isRoute(ASSIST_ROUTE, this.props.location.pathname) || isRoute(ASSIST_LIVE_SESSION, this.props.location.pathname);
const isLastPlayed = lastPlayedSessionId === sessionId;
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.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 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)}
>
<TextEllipsis text={userDisplayName} maxWidth={200} popupProps={{ inverted: true, size: 'tiny' }} />
</div>
</div>
</div>
<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 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>
{ isSessions && (
<div style={{ width: "10%"}} className="self-center px-2 flex items-center">
<ErrorBars count={issueTypes.length} />
</div>
)}
</div>
<div className="flex items-center">
{ isAssist && showActive && (
<Label success className={cn("bg-green color-white text-right mr-4", { 'opacity-0' : !active})}>
<span className="color-white">ACTIVE</span>
</Label>
)}
<div className={ stl.playLink } id="play-button" data-viewed={ viewed }>
{ isSessions && (
<div className="mr-4 flex-shrink-0 w-24">
{ isLastPlayed && (
<Label className="bg-gray-lightest p-1 px-2 rounded-lg">
<span className="color-gray-medium text-xs" style={{ whiteSpace: 'nowrap'}}>LAST PLAYED</span>
</Label>
)}
</div>
)}
<Link to={ isAssist ? liveSessionRoute(sessionId) : sessionRoute(sessionId) }>
<Icon name={ !viewed && !isAssist ? 'play-fill' : 'play-circle-light' } size="42" color={isAssist ? "tealx" : "teal"} />
</Link>
</div>
</div>
</div>
{ _metaList.length > 0 && (
<SessionMetaList className="mt-4" metaList={_metaList} />
)}
</div>
);
}
}

View file

@ -1,90 +1,117 @@
import React from 'react';
import { connect } from 'react-redux';
import React from 'react'
import cn from 'classnames';
import {
Link,
Icon,
import {
CountryFlag,
Avatar,
TextEllipsis,
Label,
} from 'UI';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import { session as sessionRoute, liveSession as liveSessionRoute, withSiteId } from 'App/routes';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { durationFormatted, formatTimeOrDate } from 'App/date';
import stl from './sessionItem.css';
import Counter from './Counter'
import { withRouter } from 'react-router-dom';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import SessionMetaList from './SessionMetaList';
import PlayLink from './PlayLink';
import ErrorBars from './ErrorBars';
import { assist as assistRoute, liveSession, sessions as sessionsRoute, isRoute } from "App/routes";
import { capitalize } from 'App/utils';
import { SKIP_TO_ISSUE, TIMEZONE, DURATION_FILTER } from 'App/constants/storageKeys'
const ASSIST_ROUTE = assistRoute();
const ASSIST_LIVE_SESSION = liveSession()
const SESSIONS_ROUTE = sessionsRoute();
// @connect(state => ({
// timezone: state.getIn(['sessions', 'timezone']),
// siteId: state.getIn([ 'site', 'siteId' ]),
// }), { toggleFavorite, setSessionPath })
// @withRouter
function SessionItem(props) {
// render() {
const {
session: {
sessionId,
userBrowser,
userOs,
userId,
userAnonymousId,
userDisplayName,
userCountry,
startedAt,
duration,
eventsCount,
errorsCount,
pagesCount,
viewed,
favorite,
userDeviceType,
userUuid,
userNumericHash,
live,
metadata,
userSessionsCount,
issueTypes,
active,
},
timezone,
onUserClick = () => null,
hasUserFilter = false,
disableUser = false,
metaList = [],
showActive = false,
lastPlayedSessionId,
} = props;
const formattedDuration = durationFormatted(duration);
const hasUserId = userId || userAnonymousId;
const isSessions = isRoute(SESSIONS_ROUTE, props.location.pathname);
const isAssist = isRoute(ASSIST_ROUTE, props.location.pathname) || isRoute(ASSIST_LIVE_SESSION, props.location.pathname);
const isLastPlayed = lastPlayedSessionId === sessionId;
interface Props {
session: {
sessionId: string;
userBrowser: string;
userOs: string;
userId: string;
userAnonymousId: string;
userDisplayName: string;
userCountry: string;
startedAt: number;
duration: string;
eventsCount: number;
errorsCount: number;
pagesCount: number;
viewed: boolean;
favorite: boolean;
userDeviceType: string;
userUuid: string;
userNumericHash: number;
live: boolean
metadata: Record<string, any>;
userSessionsCount: number
issueTypes: [];
active: boolean;
},
onUserClick?: (userId: string, userAnonymousId: string) => void;
hasUserFilter?: boolean;
disableUser?: boolean;
metaList?: Array<any>;
showActive?: boolean;
lastPlayedSessionId?: string;
live?: boolean;
}
const _metaList = Object.keys(metadata).filter(i => metaList.includes(i)).map(key => {
const value = metadata[key];
return { label: key, value };
});
function SessionItem(props: RouteComponentProps<Props>) {
const { settingsStore } = useStore();
const { timezone } = settingsStore.sessionSettings;
return (
<div className={ cn(stl.sessionItem, "flex flex-col bg-white p-3 mb-3") } id="session-item" >
const {
session,
onUserClick = () => null,
hasUserFilter = false,
disableUser = false,
metaList = [],
showActive = false,
lastPlayedSessionId,
} = props;
const {
sessionId,
userBrowser,
userOs,
userId,
userAnonymousId,
userDisplayName,
userCountry,
startedAt,
duration,
eventsCount,
viewed,
userDeviceType,
userNumericHash,
live,
metadata,
issueTypes,
active,
} = session;
const location = props.location;
const formattedDuration = durationFormatted(duration);
const hasUserId = userId || userAnonymousId;
const isSessions = isRoute(SESSIONS_ROUTE, location.pathname);
const isAssist = isRoute(ASSIST_ROUTE, location.pathname) || isRoute(ASSIST_LIVE_SESSION, location.pathname);
const isLastPlayed = lastPlayedSessionId === sessionId;
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.sessionItem, "flex flex-col 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 justify-between items-center">
<div
className={cn('text-lg', {'color-teal cursor-pointer': !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
className={cn('text-lg', {'color-teal cursor-pointer': !disableUser && hasUserId, [stl.userName]: !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
onClick={() => (!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)}
>
<TextEllipsis text={userDisplayName} maxWidth={200} popupProps={{ inverted: true, size: 'tiny' }} />
@ -111,7 +138,7 @@ function SessionItem(props) {
<div className="color-gray-medium flex items-center">
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userBrowser) } popupProps={{ inverted: true, size: "tiny" }} />
</span>
</span>
<div className="mx-2 text-4xl">·</div>
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userOs) } popupProps={{ inverted: true, size: "tiny" }} />
@ -145,9 +172,11 @@ function SessionItem(props) {
)}
</div>
)}
<Link to={ isAssist ? liveSessionRoute(sessionId) : sessionRoute(sessionId) }>
<Icon name={ !viewed && !isAssist ? 'play-fill' : 'play-circle-light' } size="42" color={isAssist ? "tealx" : "teal"} />
</Link>
<PlayLink
isAssist={isAssist}
sessionId={sessionId}
viewed={viewed}
/>
</div>
</div>
</div>
@ -155,10 +184,7 @@ function SessionItem(props) {
<SessionMetaList className="mt-4" metaList={_metaList} />
)}
</div>
);
}
)
}
export default connect(state => ({
timezone: localStorage.getItem(TIMEZONE) || '',
siteId: state.getIn([ 'site', 'siteId' ]),
}), { toggleFavorite, setSessionPath })(withRouter(SessionItem));
export default withRouter<Props>(observer<Props>(SessionItem))

View file

@ -1,8 +1,9 @@
.sessionItem {
background-color: #fff;
user-select: none;
border-radius: 3px;
border: solid thin #EEEEEE;
transition: all 0.4s;
& .favorite {
opacity: 0;
@ -12,6 +13,10 @@
}
&:hover {
background-color: $active-blue;
border: solid thin $active-blue-border;
transition: all 0.2s;
& .playLink {
transition: all 0.4s;
opacity: 1;
@ -98,4 +103,13 @@
text-transform: uppercase;
font-size: 10px;
letter-spacing: 1px;
}
}
.userName {
text-decoration: none;
&:hover {
text-decoration: underline;
text-decoration-color: $teal;
}
}

View file

@ -5,17 +5,49 @@ import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite';
const str = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)
const d = str && str[1] || 'UTC';
const timezoneOptions = [
{ label: d, value: 'local' },
{ label: 'UTC', value: 'UTC' },
]
interface TimezonesDropdownValue {
label: string;
value: string;
}
type TimezonesDropdown = TimezonesDropdownValue[]
const generateGMTZones = (): TimezonesDropdown => {
const timezones: TimezonesDropdown = []
const positiveNumbers = [...Array(12).keys()];
const negativeNumbers = [...Array(12).keys()].reverse();
negativeNumbers.pop(); // remove trailing zero since we have one in positive numbers array
const combinedArray = [...negativeNumbers, ...positiveNumbers];
for (let i = 0; i < 23; i++) {
let symbol = i < 11 ? '-' : '+';
let isUTC = i === 11
let prefix = isUTC ? 'UTC / GMT' : 'GMT';
let value = String(combinedArray[i]).padStart(2, '0');
let tz = `${prefix} ${symbol}${String(combinedArray[i]).padStart(2, '0')}:00`
let dropdownValue = `UTC${symbol}${value}`
timezones.push({ label: tz, value: isUTC ? 'UTC' : dropdownValue })
}
timezones.splice(17, 0, { label: 'GMT +05:30', value: 'GMT +05:30' })
return timezones
}
const timezoneOptions: TimezonesDropdown = [...generateGMTZones()]
function DefaultTimezone(props) {
const [changed, setChanged] = React.useState(false);
const { settingsStore } = useStore();
const [timezone, setTimezone] = React.useState(settingsStore.sessionSettings.timezone);
const sessionSettings = useObserver(() => settingsStore.sessionSettings)
const sessionSettings = useObserver(() => settingsStore.sessionSettings);
useEffect(() => {
if (!timezone) setTimezone('local');
}, []);
return (
<>
@ -38,9 +70,9 @@ function DefaultTimezone(props) {
}}>Update</Button>
</div>
</div>
<div className="text-sm mt-3">This change will impact the timestamp on session card and player.</div>
<div className="text-sm mt-3">This change will impact the timestamp on session card and player.</div>
</>
);
}
export default DefaultTimezone;
export default DefaultTimezone;

View file

@ -18,7 +18,7 @@ function ListingVisibility(props) {
const { settingsStore } = useStore();
const sessionSettings = useObserver(() => settingsStore.sessionSettings)
const [durationSettings, setDurationSettings] = React.useState(sessionSettings.durationFilter);
return (
<>
<h3 className="text-lg">Listing Visibility</h3>
@ -27,7 +27,7 @@ function ListingVisibility(props) {
<div className="col-span-4">
<Select
options={numberOptions}
defaultValue={durationSettings.operator}
defaultValue={numberOptions[0].value}
onChange={({ value }) => {
setDurationSettings({ ...durationSettings, operator: value });
setChanged(true);
@ -39,6 +39,7 @@ function ListingVisibility(props) {
value={durationSettings.count}
type="number"
name="count"
placeholder="E.g 10"
style={{ height: '38px', width: '100%'}}
onChange={(e, { value }) => {
setDurationSettings({ ...durationSettings, count: value });
@ -48,7 +49,7 @@ function ListingVisibility(props) {
</div>
<div className="col-span-3">
<Select
defaultValue={durationSettings.countType}
defaultValue={periodOptions[1].value}
options={periodOptions}
onChange={({ value }) => {
setDurationSettings({ ...durationSettings, countType: value });
@ -67,4 +68,4 @@ function ListingVisibility(props) {
);
}
export default ListingVisibility;
export default ListingVisibility;

View file

@ -26,7 +26,7 @@ const IconButton = React.forwardRef(({
name,
disabled = false,
tooltip = false,
tooltipPosition = 'top',
tooltipPosition = 'top center',
compact = false,
...rest
}, ref) => (

View file

@ -1,7 +1,42 @@
import { useState, useRef, useEffect, forwardRef } from 'react';
import cn from 'classnames';
import { Popup } from 'UI';
import styles from './textEllipsis.css';
/** calculates text width in pixels
+ * by creating a hidden element with t
+ * ext and counting its width
+ * @param text String - text string
+ * @param fontProp String - font properties
+ * @returns width number
+ */
function findTextWidth(text, fontProp) {
const tag = document.createElement('div')
tag.style.position = 'absolute'
tag.style.left = '-99in'
tag.style.whiteSpace = 'nowrap'
tag.style.font = fontProp
tag.innerHTML = text
document.body.appendChild(tag)
const result = tag.clientWidth
document.body.removeChild(tag)
return result;
}
const Trigger = forwardRef(({ textOrChildren, maxWidth, style, className, ...rest }, ref) => (
<div
className={ cn(styles.textEllipsis, className) }
style={{ maxWidth, ...style }}
ref={ref}
{ ...rest }
>
{ textOrChildren }
</div>
))
const TextEllipsis = ({
text,
hintText = text,
@ -14,23 +49,50 @@ const TextEllipsis = ({
hintProps={},
...props
}) => {
const [showPopup, setShowPopup] = useState(false)
const textRef = useRef(null);
const textOrChildren = text || children;
const trigger = (
<div
className={ cn(styles.textEllipsis, className) }
style={{ maxWidth, ...style }}
{ ...props }
>
{ textOrChildren }
</div>
);
if (noHint) return trigger;
const popupId = (Math.random() + 1).toString(36).substring(7);
useEffect(() => {
const element = textRef.current;
const fontSize = window.getComputedStyle(element, null).getPropertyValue('font-size');
const textWidth = findTextWidth(element.innerText, fontSize)
if (textWidth > element.clientWidth) setShowPopup(true)
else setShowPopup(false)
}, [textRef.current])
if (noHint || !showPopup) return (
<Trigger
className={className}
maxWidth={maxWidth}
style={style}
textOrChildren={textOrChildren}
ref={textRef}
{...props}
/>
)
return (
<Popup
trigger={ trigger }
content={ <div className="customPopupText" { ...hintProps } >{ hintText || textOrChildren }</div> }
{ ...popupProps }
/>
trigger={
<Trigger
className={className}
maxWidth={maxWidth}
style={style}
textOrChildren={textOrChildren}
id={popupId}
ref={textRef}
{...props}
/>
}
content={ <div className="customPopupText" { ...hintProps } >{ hintText || textOrChildren }</div> }
{ ...popupProps }
/>
);
};

View file

@ -5,8 +5,13 @@ import stl from './timezoneDropdown.css';
import { connect } from 'react-redux';
import { setTimezone } from 'Duck/sessions';
const localMachineFormat = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1]
const middlePoint = localMachineFormat.length - 2
const readableLocalTimezone =
`${localMachineFormat.substring(0, 3)} ${localMachineFormat.substring(3, middlePoint)}:${localMachineFormat.substring(middlePoint)}`
const timezoneOptions = {
'local': new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1],
'local': readableLocalTimezone,
'UTC': 'UTC'
};

View file

@ -39,12 +39,10 @@
width: 20px;
left: 0;
bottom: -2px;
/* background-color: white; */
transition: .4s;
border-radius: 50%;
border: solid 1px rgba(0, 0, 0, 0.2);
background: #394EFF;
box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px rgba(0, 0, 0, 0.14), 0px 1px 3px rgba(0, 0, 0, 0.12);
}
@ -55,6 +53,7 @@
.slider.checked:before {
border: solid 1px $teal;
background-color: $teal !important;
transform: translateX(15px);
}

View file

@ -20,7 +20,7 @@ export const durationFormatted = (duration: Duration):string => {
export function durationFromMsFormatted(ms: number): string {
return durationFormatted(Duration.fromMillis(ms || 0));
}
}
export const durationFormattedFull = (duration: Duration): string => {
if (duration.as('minutes') < 1) { // show in seconds
@ -35,7 +35,7 @@ export const durationFormattedFull = (duration: Duration): string => {
} else if (duration.as('months') < 1) { // show in days and hours
let d = duration.toFormat('d');
duration = d + (d > 1 ? ' days' : ' day');
} else {
} else {
let d = Math.trunc(duration.as('months'));
duration = d + (d > 1 ? ' months' : ' month');;
}
@ -49,7 +49,7 @@ export const msToSec = (ms:number): number => Math.round(ms / 1000);
export const diffFromNowString = (ts:number): string =>
durationFormattedFull(DateTime.fromMillis(Date.now()).diff(DateTime.fromMillis(ts)));
export const diffFromNowShortString = (ts: number): string =>
export const diffFromNowShortString = (ts: number): string =>
durationFormatted(DateTime.fromMillis(Date.now()).diff(DateTime.fromMillis(ts)));
export const getDateFromMill = date =>
@ -69,11 +69,19 @@ export function formatDateTimeDefault(timestamp: number): string {
return isToday(date) ? 'Today' : date.toFormat('LLL dd, yyyy') + ', ' + date.toFormat('hh:mm a')
}
/**
* Formats timestamps into readable date
* @param {Number} timestamp
* @param {String} timezone fixed offset like UTC+6
* @returns {String} formatted date (or time if its today)
*/
export function formatTimeOrDate(timestamp: number, timezone: string): string {
var date = DateTime.fromMillis(timestamp)
if (timezone === 'UTC')
date = date.toUTC();
if (timezone) {
if (timezone === 'UTC') date = date.toUTC();
date = date.setZone(timezone)
}
return isToday(date) ? date.toFormat('hh:mm a') : date.toFormat('LLL dd, yyyy, hh:mm a');
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-binoculars" viewBox="0 0 16 16">
<path d="M3 2.5A1.5 1.5 0 0 1 4.5 1h1A1.5 1.5 0 0 1 7 2.5V5h2V2.5A1.5 1.5 0 0 1 10.5 1h1A1.5 1.5 0 0 1 13 2.5v2.382a.5.5 0 0 0 .276.447l.895.447A1.5 1.5 0 0 1 15 7.118V14.5a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 14.5v-3a.5.5 0 0 1 .146-.354l.854-.853V9.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v.793l.854.853A.5.5 0 0 1 7 11.5v3A1.5 1.5 0 0 1 5.5 16h-3A1.5 1.5 0 0 1 1 14.5V7.118a1.5 1.5 0 0 1 .83-1.342l.894-.447A.5.5 0 0 0 3 4.882V2.5zM4.5 2a.5.5 0 0 0-.5.5V3h2v-.5a.5.5 0 0 0-.5-.5h-1zM6 4H4v.882a1.5 1.5 0 0 1-.83 1.342l-.894.447A.5.5 0 0 0 2 7.118V13h4v-1.293l-.854-.853A.5.5 0 0 1 5 10.5v-1A1.5 1.5 0 0 1 6.5 8h3A1.5 1.5 0 0 1 11 9.5v1a.5.5 0 0 1-.146.354l-.854.853V13h4V7.118a.5.5 0 0 0-.276-.447l-.895-.447A1.5 1.5 0 0 1 12 4.882V4h-2v1.5a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5V4zm4-1h2v-.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5V3zm4 11h-4v.5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V14zm-8 0H2v.5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V14z"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-arrow-return-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View file

@ -0,0 +1,12 @@
<svg width="42" height="42" viewBox="0 0 42 42" fill="inherit" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_47)">
<path d="M21 0C25.1534 0 29.2135 1.23163 32.667 3.53914C36.1204 5.84665 38.812 9.1264 40.4015 12.9636C41.9909 16.8009 42.4068 21.0233 41.5965 25.0969C40.7862 29.1705 38.7861 32.9123 35.8492 35.8492C32.9123 38.7861 29.1705 40.7862 25.0969 41.5965C21.0233 42.4068 16.8009 41.9909 12.9636 40.4015C9.1264 38.812 5.84665 36.1204 3.53914 32.667C1.23163 29.2135 0 25.1534 0 21C0 15.4305 2.21249 10.089 6.15076 6.15076C10.089 2.21249 15.4305 0 21 0ZM21 1.27615C17.099 1.27615 13.2856 2.43294 10.042 4.60022C6.79845 6.76751 4.27039 9.84795 2.77754 13.452C1.28469 17.0561 0.894093 21.0219 1.65514 24.8479C2.41619 28.674 4.29471 32.1884 7.05313 34.9469C9.81156 37.7053 13.326 39.5838 17.1521 40.3449C20.9781 41.1059 24.9439 40.7153 28.548 39.2225C32.1521 37.7296 35.2325 35.2015 37.3998 31.958C39.5671 28.7144 40.7238 24.901 40.7238 21C40.7238 18.4098 40.2137 15.845 39.2225 13.452C38.2312 11.059 36.7784 8.88466 34.9469 7.05313C33.1153 5.22161 30.941 3.76876 28.548 2.77754C26.155 1.78633 23.5902 1.27615 21 1.27615Z" />
<circle cx="21" cy="21" r="20" fill="inherit" />
<path d="M29.3192 23.0838C31.1527 21.9531 31.1527 20.1438 29.3192 19.0454L18.6819 12.6565C16.8485 11.5258 15.3462 12.2608 15.3462 14.2719V27.7604C15.3462 29.7312 16.8485 30.4742 18.69 29.3758L29.3192 23.0838Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1_47">
<rect width="42" height="42" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -121,9 +121,8 @@ export const defaultFilters = [
{ label: 'Time Between Events', key: KEYS.TIME_BETWEEN_EVENTS, type: KEYS.TIME_BETWEEN_EVENTS, filterKey: KEYS.TIME_BETWEEN_EVENTS, icon: 'filters/click', isFilter: false },
{ label: 'Avg CPU Load', key: KEYS.AVG_CPU_LOAD, type: KEYS.AVG_CPU_LOAD, filterKey: KEYS.AVG_CPU_LOAD, icon: 'filters/click', isFilter: false },
{ label: 'Memory Usage', key: KEYS.AVG_MEMORY_USAGE, type: KEYS.AVG_MEMORY_USAGE, filterKey: KEYS.AVG_MEMORY_USAGE, icon: 'filters/click', isFilter: false },
{ label: 'Input', key: KEYS.INPUT, type: KEYS.INPUT, filterKey: KEYS.INPUT, icon: 'event/input', isFilter: false },
{ label: 'Page', key: KEYS.LOCATION, type: KEYS.LOCATION, filterKey: KEYS.LOCATION, icon: 'event/link', isFilter: false },
{ label: 'Path', key: KEYS.LOCATION, type: KEYS.LOCATION, filterKey: KEYS.LOCATION, icon: 'event/link', isFilter: false },
// { label: 'View', key: KEYS.VIEW, type: KEYS.VIEW, filterKey: KEYS.VIEW, icon: 'event/view', isFilter: false }
]
},

View file

@ -3,8 +3,8 @@ export enum FilterCategory {
GEAR = "Gear",
RECORDING_ATTRIBUTES = "Recording Attributes",
JAVASCRIPT = "Javascript",
USER = "User",
METADATA = "Metadata",
USER = "User Identification",
METADATA = "Session & User Metadata",
PERFORMANCE = "Performance",
};

View file

@ -11,7 +11,7 @@ export const filtersMap = {
// EVENTS
[FilterKey.CLICK]: { key: FilterKey.CLICK, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Click', operator: 'on', operatorOptions: filterOptions.targetOperators, icon: 'filters/click', isEvent: true },
[FilterKey.INPUT]: { key: FilterKey.INPUT, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Input', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/input', isEvent: true },
[FilterKey.LOCATION]: { key: FilterKey.LOCATION, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Page', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/location', isEvent: true },
[FilterKey.LOCATION]: { key: FilterKey.LOCATION, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Path', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/location', isEvent: true },
[FilterKey.CUSTOM]: { key: FilterKey.CUSTOM, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Custom Events', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/custom', isEvent: true },
// [FilterKey.REQUEST]: { key: FilterKey.REQUEST, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Fetch', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch', isEvent: true },
[FilterKey.FETCH]: { key: FilterKey.FETCH, type: FilterType.SUB_FILTERS, category: FilterCategory.JAVASCRIPT, operator: 'is', label: 'Network Request', filters: [
@ -37,10 +37,10 @@ export const filtersMap = {
[FilterKey.USER_BROWSER]: { key: FilterKey.USER_BROWSER, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Browser', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/browser' },
[FilterKey.USER_DEVICE]: { key: FilterKey.USER_DEVICE, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Device', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/device' },
[FilterKey.PLATFORM]: { key: FilterKey.PLATFORM, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.GEAR, label: 'Platform', operator: 'is', operatorOptions: filterOptions.baseOperators, icon: 'filters/platform', options: platformOptions },
[FilterKey.REVID]: { key: FilterKey.REVID, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'RevId', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/rev-id' },
[FilterKey.REFERRER]: { key: FilterKey.REFERRER, type: FilterType.MULTIPLE, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Referrer', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/referrer' },
[FilterKey.REVID]: { key: FilterKey.REVID, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'Version ID', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'collection' },
[FilterKey.REFERRER]: { key: FilterKey.REFERRER, type: FilterType.MULTIPLE, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Referrer', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/arrow-return-right' },
[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.USER_COUNTRY]: { key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.USER, 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.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' },