change(ui): add recordings list in assist section

This commit is contained in:
sylenien 2022-11-17 14:55:53 +01:00 committed by Delirium
parent 5127be6cfc
commit 4a20b287a7
17 changed files with 446 additions and 22 deletions

View file

@ -67,6 +67,7 @@ const DASHBOARD_METRIC_DETAILS_PATH = routes.dashboardMetricDetails();
// const WIDGET_PATAH = routes.dashboardMetric();
const SESSIONS_PATH = routes.sessions();
const ASSIST_PATH = routes.assist();
const RECORDINGS_PATH = routes.recordings();
const ERRORS_PATH = routes.errors();
const ERROR_PATH = routes.error();
const FUNNEL_PATH = routes.funnels();
@ -213,6 +214,7 @@ class Router extends React.Component {
<Route exact strict path={withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList)} component={Dashboard} />
<Route exact strict path={withSiteId(ASSIST_PATH, siteIdList)} component={Assist} />
<Route exact strict path={withSiteId(RECORDINGS_PATH, siteIdList)} component={Assist} />
<Route exact strict path={withSiteId(ERRORS_PATH, siteIdList)} component={Errors} />
<Route exact strict path={withSiteId(ERROR_PATH, siteIdList)} component={Errors} />
<Route exact strict path={withSiteId(FUNNEL_PATH, siteIdList)} component={FunnelPage} />

View file

@ -1,26 +1,51 @@
import React from 'react';
import LiveSessionList from 'Shared/LiveSessionList';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import cn from 'classnames'
import { withRouter, RouteComponentProps } from 'react-router-dom';
import withPageTitle from 'HOCs/withPageTitle';
import withPermissions from 'HOCs/withPermissions'
// import SessionSearch from '../shared/SessionSearch';
// import MainSearchBar from '../shared/MainSearchBar';
import AssistSearchField from './AssistSearchField';
import AssistRouter from './AssistRouter';
import { SideMenuitem } from 'UI';
import { withSiteId, assist, recordings } from 'App/routes';
function Assist() {
interface Props extends RouteComponentProps {
siteId: string;
history: any;
setShowAlerts: (show: boolean) => void;
}
function Assist(props: Props) {
const { history, siteId, setShowAlerts } = props;
const isAssist = history.location.pathname.includes('assist');
const isRecords = history.location.pathname.includes('recordings');
const redirect = (path: string) => {
history.push(path);
};
return (
<div className="page-margin container-90 flex relative">
<div className="flex-1 flex">
<div className={cn("w-full mx-auto")} style={{ maxWidth: '1300px'}}>
<AssistSearchField />
<LiveSessionSearch />
<div className="my-4" />
<LiveSessionList />
<div className="side-menu">
<SideMenuitem
active={isAssist}
id="menu-assist"
title="Live Sessions"
iconName="play-circle-light"
onClick={() => redirect(withSiteId(assist(), siteId))}
/>
<SideMenuitem
active={isRecords}
id="menu-rec"
title="Recordings"
iconName="record-circle"
onClick={() => redirect(withSiteId(recordings(), siteId))}
/>
</div>
<div className="side-menu-margined w-full">
<AssistRouter />
</div>
</div>
</div>
)
}
export default withPageTitle("Assist - OpenReplay")(withPermissions(['ASSIST_LIVE'])(Assist));
export default withPageTitle("Assist - OpenReplay")(withPermissions(['ASSIST_LIVE'])(withRouter(Assist)));

View file

@ -0,0 +1,39 @@
import React from 'react';
import { Switch, Route } from 'react-router';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import {
assist,
recordings,
withSiteId,
} from 'App/routes';
import AssistView from './AssistView'
import Recordings from './RecordingsList/Recordings'
interface Props extends RouteComponentProps {
match: any;
}
function AssistRouter(props: Props) {
const {
match: {
params: { siteId },
},
} = props;
return (
<div>
<Switch>
<Route exact strict path={withSiteId(assist(), siteId)}>
<AssistView />
</Route>
<Route exact strict path={withSiteId(recordings(), siteId)}>
<Recordings />
</Route>
</Switch>
</div>
);
}
export default withRouter(AssistRouter);

View file

@ -0,0 +1,18 @@
import React from 'react';
import LiveSessionList from 'Shared/LiveSessionList';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import cn from 'classnames'
import AssistSearchField from './AssistSearchField';
function AssistView() {
return (
<div className={cn("w-full mx-auto")} style={{ maxWidth: '1300px'}}>
<AssistSearchField />
<LiveSessionSearch />
<div className="my-4" />
<LiveSessionList />
</div>
)
}
export default AssistView;

View file

@ -0,0 +1,25 @@
import React from 'react'
import { PageTitle } from 'UI';
import RecordingsSearch from './RecordingsSearch';
import RecordingsList from './RecordingsList';
function Recordings() {
return (
<div style={{ maxWidth: '1300px', margin: 'auto'}} className="bg-white rounded py-4 border">
<div className="flex items-center mb-4 justify-between px-6">
<div className="flex items-baseline mr-3">
<PageTitle title="Recordings" />
</div>
<div className="ml-auto flex items-center">
<div className="ml-4 w-1/4" style={{ minWidth: 300 }}>
<RecordingsSearch />
</div>
</div>
</div>
<RecordingsList />
</div>
)
}
export default Recordings

View file

@ -0,0 +1,73 @@
import { observer } from 'mobx-react-lite';
import React from 'react';
import { NoContent, Pagination, Icon } from 'UI';
import { useStore } from 'App/mstore';
import { filterList } from 'App/utils';
import { sliceListPerPage } from 'App/utils';
import RecordsListItem from './RecordsListItem';
function RecordingsList() {
const { recordingsStore } = useStore();
const [shownRecordings, setRecordings] = React.useState([]);
const recordings = recordingsStore.recordings;
const recordsSearch = recordingsStore.search;
React.useEffect(() => {
recordingsStore.fetchRecordings()
}, [])
React.useEffect(() => {
setRecordings(filterList(recordings, recordsSearch, ['createdBy', 'name']));
}, [recordsSearch]);
const list = recordsSearch !== '' ? shownRecordings : recordings;
const lenth = list.length;
return (
<NoContent
show={lenth === 0}
title={
<div className="flex flex-col items-center justify-center">
<Icon name="no-dashboard" size={80} color="figmaColors-accent-secondary" />
<div className="text-center text-gray-600 my-4">
{recordsSearch !== ''
? 'No matching results'
: "You haven't created any recordings yet"}
</div>
</div>
}
>
<div className="mt-3 border-b">
<div className="grid grid-cols-12 py-2 font-medium px-6">
<div className="col-span-8">Title</div>
<div className="col-span-4">By</div>
</div>
{sliceListPerPage(list, recordingsStore.page - 1, recordingsStore.pageSize).map(
(record: any) => (
<React.Fragment key={record.recordId}>
<RecordsListItem record={record} />
</React.Fragment>
)
)}
</div>
<div className="w-full flex items-center justify-between pt-4 px-6">
<div className="text-disabled-text">
Showing{' '}
<span className="font-semibold">{Math.min(list.length, recordingsStore.pageSize)}</span>{' '}
out of <span className="font-semibold">{list.length}</span> Recording
</div>
<Pagination
page={recordingsStore.page}
totalPages={Math.ceil(lenth / recordingsStore.pageSize)}
onPageChange={(page) => recordingsStore.updatePage(page)}
limit={recordingsStore.pageSize}
debounceRequest={100}
/>
</div>
</NoContent>
);
}
export default observer(RecordingsList);

View file

@ -0,0 +1,36 @@
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { Icon } from 'UI';
import { debounce } from 'App/utils';
let debounceUpdate: any = () => {}
function RecordingsSearch() {
const { recordingsStore } = useStore();
const [query, setQuery] = useState(recordingsStore.search);
useEffect(() => {
debounceUpdate = debounce((value: any) => recordingsStore.updateSearch(value), 500);
}, [])
// @ts-ignore
const write = ({ target: { value } }) => {
setQuery(value);
debounceUpdate(value);
}
return (
<div className="relative">
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
<input
value={query}
name="recordsSearch"
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
placeholder="Filter by title or description"
onChange={write}
/>
</div>
);
}
export default observer(RecordingsSearch);

View file

@ -0,0 +1,71 @@
import React from 'react';
import { Icon, ItemMenu } from 'UI';
import { durationFromMs, checkForRecent, getDateFromMill } from 'App/date'
import { IRecord } from 'App/services/RecordingsService'
import { useStore } from 'App/mstore'
import { toast } from 'react-toastify'
interface Props {
record: IRecord;
}
function RecordsListItem(props: Props) {
const { record } = props;
const { recordingsStore } = useStore()
const onRecordClick = () => {
recordingsStore.fetchRecordingUrl(record.recordId).then(url => {
window.open(url, "_blank");
})
}
const onDelete = () => {
recordingsStore.deleteRecording(record.recordId).then(() => {
toast.success("Recording deleted")
})
}
const menuItems = [
{ icon: 'trash', text: 'Delete', onClick: onDelete },
]
return (
<div className="hover:bg-active-blue border-t px-6">
<div className="grid grid-cols-12 py-4 select-none items-center">
<div className="col-span-8 flex items-start">
<div className="flex items-center capitalize-first">
<div className="w-9 h-9 rounded-full bg-tealx-lightest flex items-center justify-center mr-2">
<Icon name="columns-gap" size="16" color="tealx" />
</div>
<div className="flex flex-col">
<div className="border-b border-dashed">{record.name}</div>
<div className="text-gray-medium text-sm">
{durationFromMs(record.duration)}
</div>
</div>
</div>
</div>
<div className="col-span-2">
<div className="flex flex-col">
<div>
{record.createdBy}
</div>
<div className="text-gray-medium text-sm">{checkForRecent(getDateFromMill(record.createdAt), 'LLL dd, yyyy, hh:mm a')}</div>
</div>
</div>
<div className="col-span-2 w-full justify-end flex items-center gap-2">
<div className="group flex items-center gap-1 cursor-pointer link" onClick={onRecordClick}>
<Icon name="play" size={18} color="teal" className="!block group-hover:!hidden"/>
<Icon name="play-fill-new" size={18} color="teal" className="!hidden group-hover:!block" />
<div>Play Video</div>
</div>
<ItemMenu
bold
items={menuItems}
/>
</div>
</div>
</div>
);
}
export default RecordsListItem;

View file

@ -44,6 +44,7 @@ function ScreenRecorder({
React.useEffect(() => {
return () => stopRecorderCb?.();
}, []);
const onSave = async (saveObj: { name: string; duration: number }, blob: Blob) => {
try {
const url = await recordingsService.reserveUrl(siteId, saveObj);

View file

@ -1,7 +1,6 @@
// @flow
import { DateTime, Duration } from 'luxon'; // TODO
import { toJS } from 'mobx';
import { Timezone } from 'MOBX/types/sessionSettings';
export const durationFormatted = (duration: Duration):string => {

View file

@ -14,6 +14,7 @@ import {
funnelService,
errorService,
notesService,
recordingsService,
} from 'App/services';
import SettingsStore from './settingsStore';
import AuditStore from './auditStore';
@ -22,6 +23,7 @@ import ErrorStore from './errorStore';
import SessionStore from './sessionStore';
import NotesStore from './notesStore';
import BugReportStore from './bugReportStore'
import RecordingsStore from './recordingsStore'
export class RootStore {
dashboardStore: DashboardStore;
@ -35,7 +37,8 @@ export class RootStore {
notificationStore: NotificationStore;
sessionStore: SessionStore;
notesStore: NotesStore;
bugReportStore: BugReportStore
bugReportStore: BugReportStore;
recordingsStore: RecordingsStore;
constructor() {
this.dashboardStore = new DashboardStore();
@ -50,6 +53,7 @@ export class RootStore {
this.sessionStore = new SessionStore();
this.notesStore = new NotesStore();
this.bugReportStore = new BugReportStore();
this.recordingsStore = new RecordingsStore();
}
initClient() {
@ -62,6 +66,7 @@ export class RootStore {
auditService.initClient(client);
errorService.initClient(client);
notesService.initClient(client)
recordingsService.initClient(client);
}
}

View file

@ -0,0 +1,74 @@
import { makeAutoObservable } from "mobx"
import { recordingsService } from "App/services"
import { IRecord } from 'App/services/RecordingsService'
export default class RecordingsStore {
recordings: IRecord[] = []
loading: boolean
page = 1
pageSize = 15
order: 'desc' | 'asc' = 'desc'
search = ''
// not later we will add search by user id
userId: number
constructor() {
makeAutoObservable(this)
}
updateSearch(val: string) {
this.search = val
}
updatePage(page: number) {
this.page = page
}
async fetchRecordings() {
const filter = {
page: this.page,
limit: this.pageSize,
order: this.order,
search: this.search,
}
this.loading = true
try {
const recordings = await recordingsService.fetchRecordings(filter)
this.recordings = recordings;
this.fetchRecordingUrl(recordings[0].recordId)
return recordings;
} catch (e) {
console.error(e)
} finally {
this.loading = false
}
}
async fetchRecordingUrl(id: number): Promise<string> {
this.loading = true
try {
const recording = await recordingsService.fetchRecording(id)
return recording.URL;
} catch (e) {
console.error(e)
} finally {
this.loading = false
}
}
async deleteRecording(id: number) {
this.loading = true
try {
const recording = await recordingsService.deleteRecording(id)
console.log(recording)
return recording
} catch (e) {
console.error(e)
} finally {
this.loading = false
}
}
}

View file

@ -283,7 +283,6 @@ export default class AssistManager {
const recordingState = getState().recordingState
if (!this.socket || recordingState === SessionRecordingStatus.Off) return;
console.log('stop rec')
this.socket.emit("stop_recording")
this.toggleRecording(false)
}
@ -292,7 +291,6 @@ export default class AssistManager {
this.md.toggleRecordingStatus(isAccepted)
update({ recordingState: isAccepted ? SessionRecordingStatus.Recording : SessionRecordingStatus.Off })
console.log('Im here', isAccepted, getState().recordingState)
}
/* ==== Remote Control ==== */

View file

@ -84,6 +84,7 @@ export const onboarding = (tab = routerOBTabString) => `/onboarding/${ tab }`;
export const sessions = params => queried('/sessions', params);
export const assist = params => queried('/assist', params);
export const recordings = params => queried("/recordings", params);
export const session = (sessionId = ':sessionId', hash) => hashed(`/session/${ sessionId }`, hash);
export const liveSession = (sessionId = ':sessionId', params, hash) => hashed(queried(`/assist/${ sessionId }`, params), hash);
@ -123,6 +124,7 @@ const REQUIRED_SITE_ID_ROUTES = [
session(''),
sessions(),
assist(),
recordings(),
metrics(),
metricDetails(''),
@ -172,6 +174,7 @@ const SITE_CHANGE_AVALIABLE_ROUTES = [
sessions(),
funnels(),
assist(),
recordings(),
dashboard(),
dashboardSelected(),
metrics(),

View file

@ -5,6 +5,24 @@ interface RecordingData {
duration: number;
}
interface FetchFilter {
page: number
limit: number
order: 'asc' | 'desc'
search: string
}
export interface IRecord {
createdAt: number
createdBy: string
duration: number
name: string
recordId: number
sessionId: number
userId: number
URL?: string
}
export default class RecordingsService {
private client: APIClient;
@ -38,4 +56,37 @@ export default class RecordingsService {
})
}
fetchRecordings(filters: FetchFilter): Promise<IRecord[]> {
return this.client.post(`/assist/records`, filters)
.then(r => {
if (r.ok) {
return r.json().then(j => j.data)
} else {
throw new Error("Can't get recordings: " + r.status);
}
})
}
fetchRecording(id: number): Promise<IRecord> {
return this.client.get(`/assist/records/${id}`)
.then(r => {
if (r.ok) {
return r.json().then(j => j.data)
} else {
throw new Error("Can't get recordings: " + r.status);
}
})
}
deleteRecording(id: number): Promise<any> {
return this.client.delete(`/assist/records/${id}`)
.then(r => {
if (r.ok) {
return r.json().then(j => j.data)
} else {
throw new Error("Can't get recordings: " + r.status);
}
})
}
}

View file

@ -95,7 +95,11 @@ export async function screenRecorder(recName: string, sessionId: string, saveCb:
const stream = await recordScreen();
const mediaRecorder = createFileRecorder(stream, FILE_TYPE, recName, sessionId, saveCb);
return () => mediaRecorder.stop();
return () => {
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
}
} catch (e) {
console.log(e);
}