Session list - redesign (#621)

* change(ui) - removed env

* change(ui) - no content component updates

* feat(ui) - session list - wip

* feat(ui) - session list - wip

* feat(ui) - session list - wip

* feat(ui) - session list - wip

* fix(ui) - live session list key

* feat(ui) - session list - wip

* feat(ui) - session list - wip

* feat(backend): set default size of first part of session mob file to 1mb

* feat(backend): added extra information for db metrics

* fix(ui) - siteform loader, trash btn project exists check, IconButton replace

Co-authored-by: Alexander Zavorotynskiy <zavorotynskiy@pm.me>
This commit is contained in:
Shekar Siri 2022-07-19 14:43:43 +02:00 committed by GitHub
parent 361f990486
commit 78d7df72a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 401 additions and 84 deletions

View file

@ -31,7 +31,7 @@ const LiveSessionPure = lazy(() => import('Components/Session/LiveSession'));
const OnboardingPure = lazy(() => import('Components/Onboarding/Onboarding'));
const ClientPure = lazy(() => import('Components/Client/Client'));
const AssistPure = lazy(() => import('Components/Assist'));
const BugFinderPure = lazy(() => import('Components/BugFinder/BugFinder'));
const BugFinderPure = lazy(() => import('Components/Overview'));
const DashboardPure = lazy(() => import('Components/Dashboard/NewDashboard'));
const ErrorsPure = lazy(() => import('Components/Errors/Errors'));
const FunnelDetailsPure = lazy(() => import('Components/Funnels/FunnelDetails'));

View file

@ -0,0 +1,28 @@
import React from 'react';
import withPageTitle from 'HOCs/withPageTitle';
import NoSessionsMessage from 'Shared/NoSessionsMessage';
import MainSearchBar from 'Shared/MainSearchBar';
import SessionSearch from 'Shared/SessionSearch';
import SessionListContainer from 'Shared/SessionListContainer/SessionListContainer';
function Overview() {
return (
<div className="page-margin container-90 flex relative">
<div className="flex-1 flex">
<div className={'w-full mx-auto'} style={{ maxWidth: '1300px' }}>
<NoSessionsMessage />
<div className="mb-5">
<MainSearchBar />
<SessionSearch />
<div className="my-4" />
<SessionListContainer />
</div>
</div>
</div>
</div>
);
}
export default withPageTitle('Sessions - OpenReplay')(Overview);

View file

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

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { Fragment, useEffect } from 'react';
import { connect } from 'react-redux';
import { NoContent, Loader, Pagination } from 'UI';
import { List } from 'immutable';

View file

@ -0,0 +1,17 @@
import React from 'react';
import SessionList from './components/SessionList';
import SessionHeader from './components/SessionHeader';
interface Props {}
function SessionListContainer(props: Props) {
return (
<div className="widget-wrapper">
<SessionHeader />
<div className="p-4">
<SessionList />
</div>
</div>
);
}
export default SessionListContainer;

View file

@ -0,0 +1,20 @@
import React from 'react';
import { connect } from 'react-redux';
function NoContentMessage({ activeTab }: any) {
return <div>{getNoContentMessage(activeTab)}</div>;
}
export default connect((state: any) => ({
activeTab: state.getIn(['search', 'activeTab']),
}))(NoContentMessage);
function getNoContentMessage(activeTab: any) {
let str = 'No recordings found';
if (activeTab.type !== 'all') {
str += ' with ' + activeTab.name;
return str;
}
return str + '!';
}

View file

@ -0,0 +1,48 @@
import React from 'react';
import { numberWithCommas } from 'App/utils';
import { applyFilter } from 'Duck/search';
import Period from 'Types/app/period';
import SelectDateRange from 'Shared/SelectDateRange';
import SessionTags from '../SessionTags';
import { connect } from 'react-redux';
import SessionSort from '../SessionSort';
interface Props {
listCount: number;
filter: any;
applyFilter: (filter: any) => void;
}
function SessionHeader(props: Props) {
const { listCount, filter: { startDate, endDate, rangeValue } } = props;
const period = Period({ start: startDate, end: endDate, rangeName: rangeValue });
const onDateChange = (e: any) => {
const dateValues = e.toJSON();
props.applyFilter(dateValues);
};
return (
<div className="flex items-center p-4 justify-between">
<div className="flex items-center">
<div className="mr-3 text-lg">
<span className="font-bold">Sessions</span> <span className="color-gray-medium ml-2">{listCount}</span>
</div>
<SessionTags />
</div>
<div className="flex items-center">
<SelectDateRange period={period} onChange={onDateChange} />
<div className="mx-2" />
<SessionSort />
</div>
</div>
);
}
export default connect(
(state: any) => ({
filter: state.getIn(['search', 'instance']),
listCount: numberWithCommas(state.getIn(['sessions', 'total'])),
}),
{ applyFilter }
)(SessionHeader);

View file

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

View file

@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { FilterKey } from 'Types/filter/filterType';
import SessionItem from 'Shared/SessionItem';
import { NoContent, Loader, Pagination } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import NoContentMessage from '../NoContentMessage';
import { fetchSessions, addFilterByKeyAndValue, updateCurrentPage, setScrollPosition } from 'Duck/search';
interface Props {
loading: boolean;
list: any;
currentPage: number;
total: number;
filters: any;
lastPlayedSessionId: string;
metaList: any;
scrollY: number;
addFilterByKeyAndValue: (key: string, value: any, operator?: string) => void;
updateCurrentPage: (page: number) => void;
setScrollPosition: (scrollPosition: number) => void;
fetchSessions: () => void;
}
function SessionList(props: Props) {
const { loading, list, currentPage, total, filters, lastPlayedSessionId, metaList } = props;
const _filterKeys = filters.map((i: any) => i.key);
const hasUserFilter = _filterKeys.includes(FilterKey.USERID) || _filterKeys.includes(FilterKey.USERANONYMOUSID);
useEffect(() => {
const { scrollY } = props;
window.scrollTo(0, scrollY);
if (total === 0) {
props.fetchSessions()
}
return () => {
props.setScrollPosition(window.scrollY);
};
}, []);
const onUserClick = (userId: any) => {
if (userId) {
props.addFilterByKeyAndValue(FilterKey.USERID, userId);
} else {
props.addFilterByKeyAndValue(FilterKey.USERID, '', 'isUndefined');
}
};
return (
<Loader loading={loading}>
<NoContent
title={
<div className="flex items-center justify-center flex-col">
<AnimatedSVG name={ICONS.NO_RESULTS} size={170} />
<div className="mt-2" />
<NoContentMessage />
</div>
}
subtext={<div>Please try changing your search parameters.</div>}
show={!loading && list.size === 0}
>
{list.map((session: any) => (
<React.Fragment key={session.sessionId}>
<SessionItem
session={session}
hasUserFilter={hasUserFilter}
onUserClick={onUserClick}
metaList={metaList}
lastPlayedSessionId={lastPlayedSessionId}
/>
<div className="border-b" />
</React.Fragment>
))}
</NoContent>
{total > 0 && (
<div className="w-full flex items-center justify-center py-6">
<Pagination
page={currentPage}
totalPages={Math.ceil(total / 10)}
onPageChange={(page) => props.updateCurrentPage(page)}
limit={10}
debounceRequest={1000}
/>
</div>
)}
</Loader>
);
}
export default connect(
(state: any) => ({
list: state.getIn(['sessions', 'list']),
filters: state.getIn(['search', 'instance', 'filters']),
lastPlayedSessionId: state.getIn(['sessions', 'lastPlayedSessionId']),
metaList: state.getIn(['customFields', 'list']).map((i: any) => i.key),
loading: state.getIn(['sessions', 'loading']),
currentPage: state.getIn(['search', 'currentPage']) || 1,
total: state.getIn(['sessions', 'total']) || 0,
scrollY: state.getIn(['search', 'scrollY']),
}),
{ updateCurrentPage, addFilterByKeyAndValue, setScrollPosition, fetchSessions }
)(SessionList);

View file

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

View file

@ -0,0 +1,42 @@
import React from 'react';
import { connect } from 'react-redux';
import Select from 'Shared/Select';
import { sort } from 'Duck/sessions';
import { applyFilter } from 'Duck/search';
const sortOptionsMap = {
'startTs-desc': 'Newest',
'startTs-asc': 'Oldest',
'eventsCount-asc': 'Events Ascending',
'eventsCount-desc': 'Events Descending',
};
const sortOptions = Object.entries(sortOptionsMap).map(([value, label]) => ({ value, label }));
interface Props {
filter: any;
options: any;
applyFilter: (filter: any) => void;
sort: (sort: string, sign: number) => void;
}
function SessionSort(props: Props) {
const { sort, order } = props.filter;
const onSort = ({ value }: any) => {
value = value.value;
const [sort, order] = value.split('-');
const sign = order === 'desc' ? -1 : 1;
props.applyFilter({ order, sort });
props.sort(sort, sign);
};
const defaultOption = `${sort}-${order}`;
return <Select name="sortSessions" plain right options={sortOptions} onChange={onSort} defaultValue={defaultOption} />;
}
export default connect(
(state: any) => ({
filter: state.getIn(['search', 'instance']),
}),
{ sort, applyFilter }
)(SessionSort);

View file

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

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,66 @@
import React from 'react';
import { setActiveTab } from 'Duck/search';
import { connect } from 'react-redux';
import { issues_types } from 'Types/session/issue';
import { Icon } from 'UI';
import cn from 'classnames';
interface Props {
setActiveTab: typeof setActiveTab;
activeTab: any;
tags: any;
total: number;
}
function SessionTags(props: Props) {
const { activeTab, tags, total } = props;
const disable = activeTab.type === 'all' && total === 0;
return (
<div className="flex items-center">
{tags &&
tags.map((tag: any, index: any) => (
<div key={index}>
<TagItem
onClick={() => props.setActiveTab(tag)}
label={tag.name}
isActive={activeTab.type === tag.type}
icon={tag.icon}
disabled={disable && tag.type !== 'all'}
/>
</div>
))}
</div>
);
}
export default connect(
(state: any) => {
const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee';
return {
activeTab: state.getIn(['search', 'activeTab']),
tags: issues_types.filter((tag: any) => (isEnterprise ? tag.type !== 'bookmark' : tag.type !== 'vault')),
total: state.getIn(['sessions', 'total']) || 0,
};
},
{
setActiveTab,
}
)(SessionTags);
function TagItem({ isActive, onClick, label, icon = '', disabled = false }: any) {
return (
<div>
<button
onClick={onClick}
className={cn('transition group rounded ml-2 px-2 py-1 flex items-center uppercase text-sm hover:bg-teal hover:text-white', {
'bg-teal text-white': isActive,
'bg-active-blue color-teal': !isActive,
'disabled': disabled,
})}
>
{icon && <Icon name={icon} color="teal" size="14" className={cn('group-hover:fill-white mr-2', { 'fill-white': isActive })} />}
<span className="leading-none">{label}</span>
</button>
</div>
);
}

View file

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

View file

@ -1,30 +0,0 @@
import React from 'react';
import { Icon } from 'UI';
import styles from './noContent.module.css';
export default ({
title = <div>No data available.</div>,
subtext,
icon,
iconSize = 100,
size,
show = true,
children = null,
empty = false,
image = null,
style = {},
}) => (!show ? children :
<div className={ `${ styles.wrapper } ${ size && styles[ size ] }` } style={style}>
{
icon && <Icon name={icon} size={iconSize} />
}
{ title && <div className={ styles.title }>{ title }</div> }
{
subtext &&
<div className={ styles.subtext }>{ subtext }</div>
}
{
image && <div className="mt-4 flex justify-center">{ image } </div>
}
</div>
);

View file

@ -0,0 +1,29 @@
import React from 'react';
import { Icon } from 'UI';
import styles from './noContent.module.css';
interface Props {
title?: any;
subtext?: any;
icon?: string;
iconSize?: number;
size?: number;
show?: boolean;
children?: any;
image?: any;
style?: any;
}
export default function NoContent(props: Props) {
const { title = '', subtext = '', icon, iconSize, size, show, children, image, style } = props;
return !show ? (
children
) : (
<div className={`${styles.wrapper} ${size && styles[size]}`} style={style}>
{icon && <Icon name={icon} size={iconSize} />}
{title && <div className={styles.title}>{title}</div>}
{subtext && <div className={styles.subtext}>{subtext}</div>}
{image && <div className="mt-4 flex justify-center">{image} </div>}
</div>
);
}

View file

@ -45,15 +45,3 @@
height: 166px;
margin-bottom: 20px;
}
.empty-state {
display: block;
margin: auto;
background-image: svg-load(empty-state.svg, fill=#CCC);
background-repeat: no-repeat;
background-size: contain;
background-position: center center;
width: 166px;
height: 166px;
margin-bottom: 20px;
}

View file

@ -137,13 +137,13 @@ export const reduceThenFetchResource =
const filter = getState().getIn(['search', 'instance']).toData();
const activeTab = getState().getIn(['search', 'activeTab']);
if (activeTab.type !== 'all' && activeTab.type !== 'bookmark') {
if (activeTab.type !== 'all' && activeTab.type !== 'bookmark' && activeTab.type !== 'vault') {
const tmpFilter = filtersMap[FilterKey.ISSUE];
tmpFilter.value = [activeTab.type];
filter.filters = filter.filters.concat(tmpFilter);
}
if (activeTab.type === 'bookmark') {
if (activeTab.type === 'bookmark' || activeTab.type === 'vault') {
filter.bookmarked = true;
}

View file

@ -11,4 +11,8 @@
input.no-focus:focus {
outline: none !important;
border: solid thin transparent !important;
}
.widget-wrapper {
@apply rounded border bg-white;
}

View file

@ -3,15 +3,18 @@ import { List } from 'immutable';
import Watchdog from 'Types/watchdog'
export const issues_types = List([
{ 'type': 'js_exception', 'visible': true, 'order': 0, 'name': 'Errors', 'icon': 'funnel/exclamation-circle' },
{ 'type': 'bad_request', 'visible': true, 'order': 1, 'name': 'Bad Requests', 'icon': 'funnel/file-medical-alt' },
{ 'type': 'missing_resource', 'visible': true, 'order': 2, 'name': 'Missing Images', 'icon': 'funnel/image' },
{ 'type': 'click_rage', 'visible': true, 'order': 3, 'name': 'Click Rage', 'icon': 'funnel/emoji-angry' },
{ 'type': 'dead_click', 'visible': true, 'order': 4, 'name': 'Dead Clicks', 'icon': 'funnel/dizzy' },
{ 'type': 'memory', 'visible': true, 'order': 5, 'name': 'High Memory', 'icon': 'funnel/sd-card' },
{ 'type': 'cpu', 'visible': true, 'order': 6, 'name': 'High CPU', 'icon': 'funnel/cpu' },
{ 'type': 'crash', 'visible': true, 'order': 7, 'name': 'Crashes', 'icon': 'funnel/file-earmark-break' },
{ 'type': 'custom', 'visible': false, 'order': 8, 'name': 'Custom', 'icon': 'funnel/exclamation-circle' }
{ 'type': 'all', 'visible': true, 'order': 0, 'name': 'All', 'icon': '' },
{ 'type': 'js_exception', 'visible': true, 'order': 1, 'name': 'Errors', 'icon': 'funnel/exclamation-circle' },
{ 'type': 'click_rage', 'visible': true, 'order': 2, 'name': 'Click Rage', 'icon': 'funnel/emoji-angry' },
{ 'type': 'crash', 'visible': true, 'order': 3, 'name': 'Crashes', 'icon': 'funnel/file-earmark-break' },
{ 'type': 'memory', 'visible': true, 'order': 4, 'name': 'High Memory', 'icon': 'funnel/sd-card' },
{ 'type': 'vault', 'visible': true, 'order': 5, 'name': 'Vault', 'icon': 'safe' },
{ 'type': 'bookmark', 'visible': true, 'order': 5, 'name': 'Bookmarks', 'icon': 'safe' },
// { 'type': 'bad_request', 'visible': true, 'order': 1, 'name': 'Bad Requests', 'icon': 'funnel/file-medical-alt' },
// { 'type': 'missing_resource', 'visible': true, 'order': 2, 'name': 'Missing Images', 'icon': 'funnel/image' },
// { 'type': 'dead_click', 'visible': true, 'order': 4, 'name': 'Dead Clicks', 'icon': 'funnel/dizzy' },
// { 'type': 'cpu', 'visible': true, 'order': 6, 'name': 'High CPU', 'icon': 'funnel/cpu' },
// { 'type': 'custom', 'visible': false, 'order': 8, 'name': 'Custom', 'icon': 'funnel/exclamation-circle' }
]).map(Watchdog)
export const issues_types_map = {}

View file

@ -1,29 +0,0 @@
require('dotenv').config()
// TODO: (the problem is during the build time the frontend is isolated)
//const trackerInfo = require('../tracker/tracker/package.json');
const oss = {
name: 'oss',
PRODUCTION: true,
SENTRY_ENABLED: false,
SENTRY_URL: "",
CAPTCHA_ENABLED: process.env.CAPTCHA_ENABLED === 'true',
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY,
ORIGIN: () => 'window.location.origin',
API_EDP: () => 'window.location.origin + "/api"',
ASSETS_HOST: () => 'window.location.origin + "/assets"',
VERSION: '1.7.0',
SOURCEMAP: true,
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
MINIO_PORT: process.env.MINIO_PORT,
MINIO_USE_SSL: process.env.MINIO_USE_SSL,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
ICE_SERVERS: process.env.ICE_SERVERS,
TRACKER_VERSION: '3.5.15' // trackerInfo.version,
}
module.exports = {
oss,
};