diff --git a/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js b/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js index b528e7bb9..dbd6f9729 100644 --- a/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js +++ b/frontend/app/components/BugFinder/AutoComplete/AutoComplete.js @@ -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 ( - {/* */} @@ -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" />
{ showCloseButton ? : or} @@ -182,11 +182,11 @@ class AutoComplete extends React.PureComponent { { headerText && headerText } { options.map(item => ( - this.onItemClick(e, item) } - /> + onClick={ (e) => this.onItemClick(e, item) } + /> )) }
diff --git a/frontend/app/components/BugFinder/SessionList/SessionListHeader.js b/frontend/app/components/BugFinder/SessionList/SessionListHeader.js index e4f949473..57d1a21b4 100644 --- a/frontend/app/components/BugFinder/SessionList/SessionListHeader.js +++ b/frontend/app/components/BugFinder/SessionList/SessionListHeader.js @@ -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' /> */} -
- Timezone - -
Sort By diff --git a/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js b/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js index e075b17bd..c4588354b 100644 --- a/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js +++ b/frontend/app/components/shared/EventFilter/AutoComplete/AutoComplete.js @@ -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 ( - {/* 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" />
{/* */} @@ -175,10 +175,10 @@ class AutoComplete extends React.PureComponent { { headerText && headerText } { options.map(item => ( - this.onItemClick(e, item) } + onClick={ (e) => this.onItemClick(e, item) } /> // this.onItemClick(e, item) } /> )) diff --git a/frontend/app/components/shared/EventFilter/FilterModal/FilterModal.js b/frontend/app/components/shared/EventFilter/FilterModal/FilterModal.js index cbe8c9546..2b8219894 100644 --- a/frontend/app/components/shared/EventFilter/FilterModal/FilterModal.js +++ b/frontend/app/components/shared/EventFilter/FilterModal/FilterModal.js @@ -119,7 +119,7 @@ export default class FilterModal extends React.PureComponent { return (!displayed ? null : -
+
{ filteredList.map(category => ( diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx index a97f3573c..fb0eb4a6b 100644 --- a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx @@ -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([]); 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 && } { item.value } -
+
)) }
- )} + )}
)} @@ -151,4 +152,4 @@ function FilterAutoComplete(props: Props) { ); } -export default FilterAutoComplete; \ No newline at end of file +export default FilterAutoComplete; diff --git a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx index 04688de6b..a11695d5c 100644 --- a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx +++ b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx @@ -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 (
+ { showSearchList && (
- { filterSearchList && Object.keys(filterSearchList).map((key, index) => { + {isResultEmpty && !fetchingFilterSearchList ? ( +
+ +
No Suggestions Found
+
+ ) : Object.keys(filterSearchList).map((key, index) => { const filter = filterSearchList[key]; const option = filtersMap[key]; return option ? ( @@ -65,7 +72,7 @@ function FilterModal(props: Props) {
)} - + { !hasSearchQuery && (
{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); \ No newline at end of file +}))(FilterModal); diff --git a/frontend/app/components/shared/Filters/LiveFilterModal/LiveFilterModal.tsx b/frontend/app/components/shared/Filters/LiveFilterModal/LiveFilterModal.tsx index c75d0f4ca..b00897add 100644 --- a/frontend/app/components/shared/Filters/LiveFilterModal/LiveFilterModal.tsx +++ b/frontend/app/components/shared/Filters/LiveFilterModal/LiveFilterModal.tsx @@ -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 (
{ showSearchList && ( @@ -62,10 +64,39 @@ function LiveFilterModal(props: Props) {
); })} + {isResultEmpty && !fetchingFilterSearchList ? ( +
+ +
No Suggestions Found
+
+ ) : Object.keys(filterSearchList).filter(i => filtersMap[i].isLive).map((key, index) => { + const filter = filterSearchList[key]; + const option = filtersMap[key]; + return ( +
+
{option.label}
+
+ {filter.map((f, i) => ( +
onFilterSearchClick({ type: key, value: f.value })} + > + +
{f.value}
+
+ ))} +
+
+ ); + })}
)} - + { !hasSearchQuery && (
{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); \ No newline at end of file +}))(LiveFilterModal); diff --git a/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx b/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx index b0dbd80ac..ab6f0c662 100644 --- a/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx +++ b/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx @@ -20,14 +20,14 @@ const PER_PAGE = 10; interface Props { loading: Boolean, - list: List, + list: List, fetchLiveList: () => Promise, 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, diff --git a/frontend/app/components/shared/SessionItem/PlayLink/PlayLink.tsx b/frontend/app/components/shared/SessionItem/PlayLink/PlayLink.tsx new file mode 100644 index 000000000..203b72089 --- /dev/null +++ b/frontend/app/components/shared/SessionItem/PlayLink/PlayLink.tsx @@ -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 ( + toggleHover(true)} + onMouseLeave={() => toggleHover(false)} + > + + + ) +} \ No newline at end of file diff --git a/frontend/app/components/shared/SessionItem/PlayLink/index.ts b/frontend/app/components/shared/SessionItem/PlayLink/index.ts new file mode 100644 index 000000000..7ff31c16c --- /dev/null +++ b/frontend/app/components/shared/SessionItem/PlayLink/index.ts @@ -0,0 +1 @@ +export { default } from './PlayLink' \ No newline at end of file diff --git a/frontend/app/components/shared/SessionItem/SessionItem.js b/frontend/app/components/shared/SessionItem/SessionItem.js deleted file mode 100644 index af00fa2a2..000000000 --- a/frontend/app/components/shared/SessionItem/SessionItem.js +++ /dev/null @@ -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 ( -
-
-
-
-
-
-
(!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)} - > - -
-
-
-
-
{formatTimeOrDate(startedAt, timezone) }
-
- {!isAssist && ( - <> -
- { eventsCount } - { eventsCount === 0 || eventsCount > 1 ? 'Events' : 'Event' } -
-
·
- - )} -
{ live ? : formattedDuration }
-
-
-
- -
- - - -
·
- - - -
·
- - - -
-
- { isSessions && ( -
- -
- )} -
- -
- { isAssist && showActive && ( - - )} -
- { isSessions && ( -
- { isLastPlayed && ( - - )} -
- )} - - - -
-
-
- { _metaList.length > 0 && ( - - )} -
- ); - } -} \ No newline at end of file diff --git a/frontend/app/components/shared/SessionItem/SessionItem.tsx b/frontend/app/components/shared/SessionItem/SessionItem.tsx index dc51bfc17..7129a04c0 100644 --- a/frontend/app/components/shared/SessionItem/SessionItem.tsx +++ b/frontend/app/components/shared/SessionItem/SessionItem.tsx @@ -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; + userSessionsCount: number + issueTypes: []; + active: boolean; + }, + onUserClick?: (userId: string, userAnonymousId: string) => void; + hasUserFilter?: boolean; + disableUser?: boolean; + metaList?: Array; + 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) { + const { settingsStore } = useStore(); + const { timezone } = settingsStore.sessionSettings; - return ( -
+ 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 ( +
(!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)} > @@ -111,7 +138,7 @@ function SessionItem(props) {
- +
·
@@ -145,9 +172,11 @@ function SessionItem(props) { )}
)} - - - +
@@ -155,10 +184,7 @@ function SessionItem(props) { )}
- ); - } + ) +} -export default connect(state => ({ - timezone: localStorage.getItem(TIMEZONE) || '', - siteId: state.getIn([ 'site', 'siteId' ]), -}), { toggleFavorite, setSessionPath })(withRouter(SessionItem)); \ No newline at end of file +export default withRouter(observer(SessionItem)) diff --git a/frontend/app/components/shared/SessionItem/index.js b/frontend/app/components/shared/SessionItem/index.ts similarity index 100% rename from frontend/app/components/shared/SessionItem/index.js rename to frontend/app/components/shared/SessionItem/index.ts diff --git a/frontend/app/components/shared/SessionItem/sessionItem.css b/frontend/app/components/shared/SessionItem/sessionItem.css index 4b042b160..897ee327f 100644 --- a/frontend/app/components/shared/SessionItem/sessionItem.css +++ b/frontend/app/components/shared/SessionItem/sessionItem.css @@ -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; -} \ No newline at end of file +} + +.userName { + text-decoration: none; + + &:hover { + text-decoration: underline; + text-decoration-color: $teal; + } +} diff --git a/frontend/app/components/shared/SessionSettings/components/DefaultTimezone.tsx b/frontend/app/components/shared/SessionSettings/components/DefaultTimezone.tsx index efb91fed9..2332b62da 100644 --- a/frontend/app/components/shared/SessionSettings/components/DefaultTimezone.tsx +++ b/frontend/app/components/shared/SessionSettings/components/DefaultTimezone.tsx @@ -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
-
This change will impact the timestamp on session card and player.
+
This change will impact the timestamp on session card and player.
); } -export default DefaultTimezone; \ No newline at end of file +export default DefaultTimezone; diff --git a/frontend/app/components/shared/SessionSettings/components/ListingVisibility.tsx b/frontend/app/components/shared/SessionSettings/components/ListingVisibility.tsx index d9f137daa..c358f9389 100644 --- a/frontend/app/components/shared/SessionSettings/components/ListingVisibility.tsx +++ b/frontend/app/components/shared/SessionSettings/components/ListingVisibility.tsx @@ -18,7 +18,7 @@ function ListingVisibility(props) { const { settingsStore } = useStore(); const sessionSettings = useObserver(() => settingsStore.sessionSettings) const [durationSettings, setDurationSettings] = React.useState(sessionSettings.durationFilter); - + return ( <>

Listing Visibility

@@ -27,7 +27,7 @@ function ListingVisibility(props) {
{ setDurationSettings({ ...durationSettings, countType: value }); @@ -67,4 +68,4 @@ function ListingVisibility(props) { ); } -export default ListingVisibility; \ No newline at end of file +export default ListingVisibility; diff --git a/frontend/app/components/ui/IconButton/IconButton.js b/frontend/app/components/ui/IconButton/IconButton.js index 6aa9f3d5f..0b62c41de 100644 --- a/frontend/app/components/ui/IconButton/IconButton.js +++ b/frontend/app/components/ui/IconButton/IconButton.js @@ -26,7 +26,7 @@ const IconButton = React.forwardRef(({ name, disabled = false, tooltip = false, - tooltipPosition = 'top', + tooltipPosition = 'top center', compact = false, ...rest }, ref) => ( diff --git a/frontend/app/components/ui/TextEllipsis/TextEllipsis.js b/frontend/app/components/ui/TextEllipsis/TextEllipsis.js index a6d3e6abc..b3528ff23 100644 --- a/frontend/app/components/ui/TextEllipsis/TextEllipsis.js +++ b/frontend/app/components/ui/TextEllipsis/TextEllipsis.js @@ -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) => ( +
+ { textOrChildren } +
+)) + 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 = ( -
- { textOrChildren } -
- ); - 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 ( + + ) + return ( { hintText || textOrChildren }
} - { ...popupProps } - /> + trigger={ + + } + content={
{ hintText || textOrChildren }
} + { ...popupProps } + /> ); }; diff --git a/frontend/app/components/ui/TimezoneDropdown/TimezoneDropdown.js b/frontend/app/components/ui/TimezoneDropdown/TimezoneDropdown.js index bafebfa76..2b7b8834e 100644 --- a/frontend/app/components/ui/TimezoneDropdown/TimezoneDropdown.js +++ b/frontend/app/components/ui/TimezoneDropdown/TimezoneDropdown.js @@ -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' }; diff --git a/frontend/app/components/ui/Toggler/toggler.css b/frontend/app/components/ui/Toggler/toggler.css index cf9cf0838..6d5fae271 100644 --- a/frontend/app/components/ui/Toggler/toggler.css +++ b/frontend/app/components/ui/Toggler/toggler.css @@ -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); } diff --git a/frontend/app/date.ts b/frontend/app/date.ts index 8a9501a86..e32704db1 100644 --- a/frontend/app/date.ts +++ b/frontend/app/date.ts @@ -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'); } diff --git a/frontend/app/svg/icons/binoculars.svg b/frontend/app/svg/icons/binoculars.svg new file mode 100644 index 000000000..e85d493b3 --- /dev/null +++ b/frontend/app/svg/icons/binoculars.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/svg/icons/filters/arrow-return-right.svg b/frontend/app/svg/icons/filters/arrow-return-right.svg new file mode 100644 index 000000000..bbdcd41f7 --- /dev/null +++ b/frontend/app/svg/icons/filters/arrow-return-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/svg/icons/play-hover.svg b/frontend/app/svg/icons/play-hover.svg new file mode 100644 index 000000000..4939d51d9 --- /dev/null +++ b/frontend/app/svg/icons/play-hover.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/app/types/filter/filter.js b/frontend/app/types/filter/filter.js index 9566c9d4b..06cab8d14 100644 --- a/frontend/app/types/filter/filter.js +++ b/frontend/app/types/filter/filter.js @@ -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 } ] }, diff --git a/frontend/app/types/filter/filterType.ts b/frontend/app/types/filter/filterType.ts index 83511e20a..f77d15a11 100644 --- a/frontend/app/types/filter/filterType.ts +++ b/frontend/app/types/filter/filterType.ts @@ -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", }; diff --git a/frontend/app/types/filter/newFilter.js b/frontend/app/types/filter/newFilter.js index 22a27f144..725b94560 100644 --- a/frontend/app/types/filter/newFilter.js +++ b/frontend/app/types/filter/newFilter.js @@ -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' },