Assist stats UI (#1489)

* feat(ui): start assist stats

* feat(ui): design mock up for stats

* feat(ui): naming...

* feat(ui): types, api service, some changes for recs and loaders

* feat(ui): csv export button/request

* feat(ui): csv export button/request

* feat(ui): format dates

* feat(ui): some fixes for stats requests

* fix(tracker): fix test

* fix(tracker): fix ci build

* fix(tracker): fix assist tests

* fix(tracker): bump test coverage, fix minor bug

* fix(ui): more cypress fixes

* fix(ui): add proj id to socket connection

* fix(ui): remove console log

* fix(ui): update filters

* feat(ui):fix some api keys for stats

* feat(ui): fix type

* feat(ui): remove unused

* feat(ui): some fixes

* feat(ui): some fixes 2

* fix(ui): some search fixes

* fix(ui): change api keys

* fix(ui): change api keys

* fix(ui): pdf button fix

* fix(ui): pdf button fix

* fix(ui): some ui fixes after review

* fix(ui): fix csv export

* fix(ui): change header for pds export for stats

* fix(ui): change header width for exports
This commit is contained in:
Delirium 2023-10-16 13:54:37 +02:00 committed by GitHub
parent fd51cf489e
commit a2fce7e291
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1222 additions and 97 deletions

View file

@ -30,7 +30,8 @@ const components = {
FunnelDetailsPure: lazy(() => import('Components/Funnels/FunnelDetails')),
FunnelIssueDetails: lazy(() => import('Components/Funnels/FunnelIssueDetails')),
FunnelPagePure: lazy(() => import('Components/Funnels/FunnelPage')),
MultiviewPure: lazy(() => import('Components/Session_/Multiview/Multiview'))
MultiviewPure: lazy(() => import('Components/Session_/Multiview/Multiview')),
AssistStatsPure: lazy(() => import('Components/AssistStats')),
};
@ -45,7 +46,8 @@ const enhancedComponents = {
FunnelPage: withSiteIdUpdater(components.FunnelPagePure),
FunnelsDetails: withSiteIdUpdater(components.FunnelDetailsPure),
FunnelIssue: withSiteIdUpdater(components.FunnelIssueDetails),
Multiview: withSiteIdUpdater(components.MultiviewPure)
Multiview: withSiteIdUpdater(components.MultiviewPure),
AssistStats: withSiteIdUpdater(components.AssistStatsPure)
};
const withSiteId = routes.withSiteId;
@ -70,19 +72,20 @@ const FFLAG_CREATE_PATH = routes.newFFlag();
const FFLAG_READ_PATH = routes.fflagRead();
const NOTES_PATH = routes.notes();
const BOOKMARKS_PATH = routes.bookmarks();
const ASSIST_PATH = routes.assist();
const RECORDINGS_PATH = routes.recordings();
const FUNNEL_PATH = routes.funnels();
const FUNNEL_CREATE_PATH = routes.funnelsCreate();
const FUNNEL_ISSUE_PATH = routes.funnelIssue();
const SESSION_PATH = routes.session();
const LIVE_SESSION_PATH = routes.liveSession();
const CLIENT_PATH = routes.client();
const ONBOARDING_PATH = routes.onboarding();
const ONBOARDING_REDIRECT_PATH = routes.onboarding(OB_DEFAULT_TAB);
const ASSIST_PATH = routes.assist();
const LIVE_SESSION_PATH = routes.liveSession();
const MULTIVIEW_PATH = routes.multiview();
const MULTIVIEW_INDEX_PATH = routes.multiviewIndex();
const ASSIST_STATS_PATH = routes.assistStats();
interface RouterProps extends RouteComponentProps, ConnectedProps<typeof connector> {
isLoggedIn: boolean;
@ -266,6 +269,8 @@ const Router: React.FC<RouterProps> = (props) => {
<Route exact strict path={withSiteId(ASSIST_PATH, siteIdList)} component={enhancedComponents.Assist} />
<Route exact strict path={withSiteId(RECORDINGS_PATH, siteIdList)}
component={enhancedComponents.Assist} />
<Route exact strict path={withSiteId(ASSIST_STATS_PATH, siteIdList)}
component={enhancedComponents.AssistStats} />
<Route exact strict path={withSiteId(FUNNEL_PATH, siteIdList)}
component={enhancedComponents.FunnelPage} />
<Route exact strict path={withSiteId(FUNNEL_CREATE_PATH, siteIdList)}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -1,10 +1,8 @@
import React from 'react';
import { Button } from 'UI';
import SessionSearchField from 'Shared/SessionSearchField';
// import { fetchFilterSearch } from 'Duck/search';
import { connect } from 'react-redux';
import { edit as editFilter, addFilterByKeyAndValue, clearSearch, fetchFilterSearch } from 'Duck/liveSearch';
// import { clearSearch } from 'Duck/search';
interface Props {
appliedFilter: any;

View file

@ -0,0 +1,295 @@
import React from 'react';
import { Button, Typography, Tooltip } from 'antd';
import { Loader } from 'UI';
import {
generateListData,
defaultGraphs,
Graphs,
Member,
SessionsResponse,
PeriodKeys,
} from 'App/services/AssistStatsService';
import { FilePdfOutlined, ArrowUpOutlined } from '@ant-design/icons';
import Period, { LAST_24_HOURS } from 'Types/app/period';
import SelectDateRange from 'Shared/SelectDateRange/SelectDateRange';
import TeamMembers from 'Components/AssistStats/components/TeamMembers';
import { durationFromMsFormatted, formatTimeOrDate } from 'App/date'
import withPageTitle from 'HOCs/withPageTitle';
import { exportCSVFile } from 'App/utils';
import { assistStatsService } from 'App/services';
import UserSearch from './components/UserSearch';
import Chart from './components/Charts';
import StatsTable from './components/Table';
import { getPdf2 } from "Components/AssistStats/pdfGenerator";
const chartNames = {
assistTotal: 'Total Live Duration',
assistAvg: 'Avg Live Duration',
callTotal: 'Total Call Duration',
callAvg: 'Avg Call Duration',
controlTotal: 'Total Remote Duration',
controlAvg: 'Avg Remote Duration',
};
function calculatePercentageDelta(currP: number, prevP: number) {
return ((currP - prevP) / prevP) * 100;
}
function AssistStats() {
const [selectedUser, setSelectedUser] = React.useState<any>(null);
const [period, setPeriod] = React.useState<any>(Period({ rangeName: LAST_24_HOURS }));
const [membersSort, setMembersSort] = React.useState('sessionsAssisted');
const [tableSort, setTableSort] = React.useState('timestamp');
const [topMembers, setTopMembers] = React.useState<{ list: Member[]; total: number }>({
list: [],
total: 0,
});
const [graphs, setGraphs] = React.useState<Graphs>(defaultGraphs);
const [sessions, setSessions] = React.useState<SessionsResponse>({
list: [],
total: 0,
page: 1,
});
const [isLoading, setIsLoading] = React.useState(false);
const [page, setPage] = React.useState(1);
React.useEffect(() => {
void updateData();
}, []);
const onChangePeriod = async (period: any) => {
setPeriod(period);
void updateData(period);
};
const updateData = async (customPeriod?: any) => {
const usedP = customPeriod || period;
setIsLoading(true);
const topMembersPr = assistStatsService.getTopMembers({
startTimestamp: usedP.start,
endTimestamp: usedP.end,
sort: membersSort,
order: 'desc',
});
const graphsPr = assistStatsService.getGraphs(usedP);
const sessionsPr = assistStatsService.getSessions({
startTimestamp: usedP.start,
endTimestamp: usedP.end,
sort: tableSort,
order: 'desc',
userId: selectedUser ? selectedUser : undefined,
page: 1,
limit: 10,
});
Promise.allSettled([topMembersPr, graphsPr, sessionsPr]).then(
([topMembers, graphs, sessions]) => {
topMembers.status === 'fulfilled' && setTopMembers(topMembers.value);
graphs.status === 'fulfilled' && setGraphs(graphs.value);
sessions.status === 'fulfilled' && setSessions(sessions.value);
}
);
setIsLoading(false);
};
const onPageChange = (page: number) => {
setPage(page);
assistStatsService
.getSessions({
startTimestamp: period.start,
endTimestamp: period.end,
sort: tableSort,
order: 'desc',
page,
limit: 10,
})
.then((sessions) => {
setSessions(sessions);
});
};
const onMembersSort = (sortBy: string) => {
setMembersSort(sortBy);
assistStatsService
.getTopMembers({
startTimestamp: period.start,
endTimestamp: period.end,
sort: sortBy,
order: 'desc',
})
.then((topMembers) => {
setTopMembers(topMembers);
});
};
const onTableSort = (sortBy: string) => {
setTableSort(sortBy);
assistStatsService
.getSessions({
startTimestamp: period.start,
endTimestamp: period.end,
sort: sortBy,
order: 'desc',
page: 1,
limit: 10,
})
.then((sessions) => {
setSessions(sessions);
});
};
const exportCSV = () => {
assistStatsService
.getSessions({
startTimestamp: period.start,
endTimestamp: period.end,
sort: tableSort,
order: 'desc',
page: 1,
limit: 10000,
}).then((sessions) => {
const data = sessions.list.map((s) => ({
...s,
members: `"${s.teamMembers.map((m) => m.name).join(', ')}"`,
dateStr: `"${formatTimeOrDate(s.timestamp, undefined, true)}"`,
assistDuration: `"${durationFromMsFormatted(s.assistDuration)}"`,
callDuration: `"${durationFromMsFormatted(s.callDuration)}"`,
controlDuration: `"${durationFromMsFormatted(s.controlDuration)}"`,
}));
const headers = [
{ label: 'Date', key: 'dateStr' },
{ label: 'Team Members', key: 'members' },
{ label: 'Live Duration', key: 'assistDuration' },
{ label: 'Call Duration', key: 'callDuration' },
{ label: 'Remote Duration', key: 'controlDuration' },
{ label: 'Session ID', key: 'sessionId' }
];
exportCSVFile(headers, data, `Assist_Stats_${new Date().toLocaleDateString()}`)
})
};
const onUserSelect = (id: any) => {
setSelectedUser(id);
setIsLoading(true);
const topMembersPr = assistStatsService.getTopMembers({
startTimestamp: period.start,
endTimestamp: period.end,
sort: membersSort,
userId: id,
order: 'desc',
});
const graphsPr = assistStatsService.getGraphs(period, id);
const sessionsPr = assistStatsService.getSessions({
startTimestamp: period.start,
endTimestamp: period.end,
sort: tableSort,
userId: id,
order: 'desc',
page: 1,
limit: 10,
})
Promise.allSettled([topMembersPr, graphsPr, sessionsPr]).then(
([topMembers, graphs, sessions]) => {
topMembers.status === 'fulfilled' && setTopMembers(topMembers.value);
graphs.status === 'fulfilled' && setGraphs(graphs.value);
sessions.status === 'fulfilled' && setSessions(sessions.value);
}
);
setIsLoading(false);
};
return (
<>
<div className={'w-full'} id={'pdf-anchor'}>
<div id={'pdf-ignore'} className={'w-full flex items-center mb-2'}>
<Typography.Title style={{ marginBottom: 0 }} level={4}>
Assist Stats
</Typography.Title>
<div className={'ml-auto flex items-center gap-2'}>
<UserSearch onUserSelect={onUserSelect} />
<SelectDateRange period={period} onChange={onChangePeriod} right={true} isAnt />
<Tooltip title={'Export PDF'}>
<Button
onClick={getPdf2}
shape={'default'}
size={'small'}
icon={<FilePdfOutlined rev={undefined} />}
/>
</Tooltip>
</div>
</div>
<div className={'w-full grid grid-cols-3 gap-2'}>
<div className={'grid grid-cols-3 gap-2 flex-2 col-span-2'}>
{Object.keys(graphs.currentPeriod).map((i: PeriodKeys) => (
<div className={'bg-white rounded border'}>
<div className={'pt-2 px-2'}>
<Typography.Text strong style={{ marginBottom: 0 }}>
{chartNames[i]}
</Typography.Text>
<div className={'flex gap-2 items-center'}>
<Typography.Title style={{ marginBottom: 0 }} level={5}>
{graphs.currentPeriod[i]
? durationFromMsFormatted(graphs.currentPeriod[i])
: 0}
</Typography.Title>
{graphs.previousPeriod[i] ? (
<div
className={
graphs.currentPeriod[i] > graphs.previousPeriod[i]
? 'flex items-center gap-1 text-green'
: 'flex items-center gap-2 text-red'
}
>
<ArrowUpOutlined
rev={undefined}
rotate={graphs.currentPeriod[i] > graphs.previousPeriod[i] ? 0 : 180}
/>
{`${Math.round(
calculatePercentageDelta(
graphs.currentPeriod[i],
graphs.previousPeriod[i]
)
)}%`}
</div>
) : null}
</div>
</div>
<Loader loading={isLoading} style={{ minHeight: 90, height: 90 }} size={36}>
<Chart data={generateListData(graphs.list, i)} label={chartNames[i]} />
</Loader>
</div>
))}
</div>
<div className={'flex-1 col-span-1'}>
<TeamMembers
isLoading={isLoading}
topMembers={topMembers}
onMembersSort={onMembersSort}
membersSort={membersSort}
/>
</div>
</div>
<div className={'w-full mt-2'}>
<StatsTable
exportCSV={exportCSV}
sessions={sessions}
isLoading={isLoading}
onSort={onTableSort}
onPageChange={onPageChange}
page={page}
/>
</div>
</div>
<div id={'stats-layer'} />
</>
);
}
export default withPageTitle('Assist Stats - Openreplay')(AssistStats);

View file

@ -0,0 +1,62 @@
import React from 'react';
import { NoContent } from 'UI';
import { Styles } from 'Components/Dashboard/Widgets/common';
import {
AreaChart,
Area,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
interface Props {
data: any;
label: string;
}
function Chart(props: Props) {
const { data, label } = props;
const gradientDef = Styles.gradientDef();
return (
<NoContent
size="small"
title="No data available"
show={data && data.length === 0}
style={{ height: '100px' }}
>
<ResponsiveContainer height={90} width="100%">
<AreaChart
data={data}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
>
{gradientDef}
<XAxis hide {...Styles.xaxis} dataKey="time" interval={7} />
<YAxis
hide
{...Styles.yaxis}
allowDecimals={false}
tickFormatter={(val) => Styles.tickFormatter(val)}
label={{ ...Styles.axisLabelLeft, value: label }}
/>
<Area
type="monotone"
dataKey="value"
stroke={Styles.strokeColor}
fillOpacity={1}
strokeWidth={2}
strokeOpacity={0.8}
fill={'url(#colorCount)'}
/>
</AreaChart>
</ResponsiveContainer>
</NoContent>
);
}
export default Chart;

View file

@ -0,0 +1,165 @@
import { DownOutlined } from '@ant-design/icons';
import { AssistStatsSession, SessionsResponse } from 'App/services/AssistStatsService';
import { numberWithCommas } from 'App/utils';
import React from 'react';
import { Button, Dropdown, Space, Typography, Tooltip } from 'antd';
import { CloudDownloadOutlined, TableOutlined } from '@ant-design/icons';
import { Loader, Pagination } from 'UI';
import PlayLink from 'Shared/SessionItem/PlayLink';
import { recordingsService } from 'App/services';
import { checkForRecent, durationFromMsFormatted, getDateFromMill } from 'App/date';
interface Props {
onSort: (v: string) => void;
isLoading: boolean;
onPageChange: (page: number) => void;
page: number;
sessions: SessionsResponse;
exportCSV: () => void;
}
const PER_PAGE = 10;
const sortItems = [
{
key: 'timestamp',
label: 'Newest First',
},
{
key: 'assist_duration',
label: 'Live Duration',
},
{
key: 'call_duration',
label: 'Call Duration',
},
{
key: 'control_duration',
label: 'Remote Duration',
},
// {
// key: '5',
// label: 'Team Member',
// },
];
function StatsTable({ onSort, isLoading, onPageChange, page, sessions, exportCSV }: Props) {
const [sortValue, setSort] = React.useState(sortItems[0].label);
const updateRange = ({ key }: { key: string }) => {
const item = sortItems.find((item) => item.key === key);
setSort(item?.label || sortItems[0].label);
item?.key && onSort(item.key);
};
return (
<div className={'rounded bg-white border'}>
<div className={'flex items-center p-4 gap-2'}>
<Typography.Title level={5} style={{ marginBottom: 0 }}>
Assisted Sessions
</Typography.Title>
<div className={'ml-auto'} />
<Dropdown menu={{ items: sortItems, onClick: updateRange }}>
<Button size={'small'}>
<Space>
<Typography.Text>{sortValue}</Typography.Text>
<DownOutlined rev={undefined} />
</Space>
</Button>
</Dropdown>
<Button size={'small'} icon={<TableOutlined rev={undefined} />} onClick={exportCSV}>
Export CSV
</Button>
</div>
<div className={'bg-gray-lightest grid grid-cols-8 items-center font-semibold p-4'}>
<Cell size={1}>Date</Cell>
<Cell size={2}>Team Members</Cell>
<Cell size={1}>Live Duration</Cell>
<Cell size={1}>Call Duration</Cell>
<Cell size={1}>Remote Duration</Cell>
<Cell size={1} />
{/* SPACER */}
<Cell size={1}>{/* BUTTONS */}</Cell>
</div>
<div className={'bg-white'}>
<Loader loading={isLoading} style={{ height: 300 }}>
{sessions.list.map((session) => (
<Row session={session} />
))}
</Loader>
</div>
<div className={'flex items-center justify-between p-4'}>
{sessions.total > 0 ? (
<div>
Showing <span className="font-medium">{(page - 1) * PER_PAGE + 1}</span> to{' '}
<span className="font-medium">{(page - 1) * PER_PAGE + sessions.list.length}</span> of{' '}
<span className="font-medium">{numberWithCommas(sessions.total)}</span> sessions.
</div>
) : (
<div>
Showing <span className="font-medium">0</span> to <span className="font-medium">0</span>{' '}
of <span className="font-medium">0</span> sessions.
</div>
)}
<Pagination
page={sessions.total > 0 ? page : 0}
totalPages={Math.ceil(sessions.total / PER_PAGE)}
onPageChange={onPageChange}
limit={10}
debounceRequest={200}
/>
</div>
</div>
);
}
function Row({ session }: { session: AssistStatsSession }) {
return (
<div className={'grid grid-cols-8 p-4 border-b hover:bg-active-blue'}>
<Cell size={1}>{checkForRecent(getDateFromMill(session.timestamp)!, 'LLL dd, hh:mm a')}</Cell>
<Cell size={2}>
<div className={'flex gap-2'}>
{session.teamMembers.map((member) => (
<div className={'p-1 rounded border bg-gray-lightest w-fit'}>{member.name}</div>
))}
</div>
</Cell>
<Cell size={1}>{durationFromMsFormatted(session.assistDuration)}</Cell>
<Cell size={1}>{durationFromMsFormatted(session.callDuration)}</Cell>
<Cell size={1}>{durationFromMsFormatted(session.controlDuration)}</Cell>
<Cell size={1} />
<Cell size={1}>
<div className={'w-full flex justify-end gap-4'}>
{session.recordings?.length > 0 ? (
session.recordings?.length > 1 ? (
<Dropdown
menu={{
items: session.recordings.map((recording) => ({
key: recording.recordId,
label: recording.name.slice(0, 20),
})),
onClick: (item) =>
recordingsService.fetchRecording(item.key as unknown as number),
}}
>
<CloudDownloadOutlined rev={undefined} style={{ fontSize: 22, color: '#8C8C8C' }} />
</Dropdown>
) : (
<div
className={'cursor-pointer'}
onClick={() => recordingsService.fetchRecording(session.recordings[0].recordId)}
>
<CloudDownloadOutlined rev={undefined} style={{ fontSize: 22, color: '#8C8C8C' }} />
</div>
)
) : null}
<PlayLink isAssist={false} viewed={false} sessionId={session.sessionId} />
</div>
</Cell>
</div>
);
}
function Cell({ size, children }: { size: number; children?: React.ReactNode }) {
return <div className={`col-span-${size} capitalize`}>{children}</div>;
}
export default StatsTable;

View file

@ -0,0 +1,117 @@
import { DownOutlined, TableOutlined } from '@ant-design/icons';
import { Button, Dropdown, Space, Typography, Tooltip } from 'antd';
import { durationFromMsFormatted } from 'App/date';
import { Member } from 'App/services/AssistStatsService';
import { getInitials } from 'App/utils';
import React from 'react';
import { Loader } from 'UI';
import { exportCSVFile } from 'App/utils';
const items = [
{
label: 'Sessions Assisted',
key: 'sessionsAssisted',
},
{
label: 'Live Duration',
key: 'assistDuration',
},
{
label: 'Call Duration',
key: 'callDuration',
},
{
label: 'Remote Duration',
key: 'controlDuration',
},
];
function TeamMembers({
isLoading,
topMembers,
onMembersSort,
membersSort,
}: {
isLoading: boolean;
topMembers: { list: Member[]; total: number };
onMembersSort: (v: string) => void;
membersSort: string;
}) {
const [dateRange, setDateRange] = React.useState(items[0].label);
const updateRange = ({ key }: { key: string }) => {
const item = items.find((item) => item.key === key);
setDateRange(item?.label || items[0].label);
onMembersSort(item?.key || items[0].key);
};
const onExport = () => {
const headers = [
{ label: 'Team Member', key: 'name' },
{ label: 'Sessions Assisted', key: 'sessionsAssisted' },
{ label: 'Live Duration', key: 'assistDuration' },
{ label: 'Call Duration', key: 'callDuration' },
{ label: 'Remote Duration', key: 'controlDuration' },
];
const data = topMembers.list.map((member) => ({
name: `"${member.name}"`,
sessionsAssisted: `"${member.assistCount}"`,
assistDuration: `"${durationFromMsFormatted(member.assistDuration)}"`,
callDuration: `"${durationFromMsFormatted(member.callDuration)}"`,
controlDuration: `"${durationFromMsFormatted(member.controlDuration)}"`,
}));
exportCSVFile(headers, data, `Team_Members_${new Date().toLocaleDateString()}`);
};
return (
<div className={'rounded bg-white border p-2 h-full w-full flex flex-col'}>
<div className={'flex items-center'}>
<Typography.Title style={{ marginBottom: 0 }} level={5}>
Team Members
</Typography.Title>
<div className={'ml-auto flex items-center gap-2'}>
<Dropdown menu={{ items, onClick: updateRange }}>
<Button size={'small'}>
<Space>
<Typography.Text>{dateRange}</Typography.Text>
<DownOutlined rev={undefined} />
</Space>
</Button>
</Dropdown>
<Tooltip title={'Export CSV'}>
<Button
onClick={onExport}
shape={'default'}
size={'small'}
icon={<TableOutlined rev={undefined} />}
/>
</Tooltip>
</div>
</div>
<Loader loading={isLoading} style={{ minHeight: 150, height: 300 }} size={48}>
{topMembers.list.map((member) => (
<div key={member.name} className={'w-full flex items-center gap-2 border-b pt-2 pb-1'}>
<div className="relative flex items-center justify-center w-10 h-10">
<div className="absolute left-0 right-0 top-0 bottom-0 mx-auto w-10 h-10 rounded-full opacity-30 bg-tealx" />
<div className="text-lg uppercase color-tealx">{getInitials(member.name)}</div>
</div>
<div>{member.name}</div>
<div className={'ml-auto'}>
{membersSort === 'sessionsAssisted'
? member.count
: durationFromMsFormatted(member.count)}
</div>
</div>
))}
</Loader>
<div className={'flex items-center justify-center text-disabled-text p-2 mt-auto'}>
{isLoading || topMembers.list.length === 0
? ''
: `Showing 1 to ${topMembers.total} of the total`}
</div>
</div>
);
}
export default TeamMembers;

View file

@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { AutoComplete, Input } from 'antd';
import type { SelectProps } from 'antd/es/select';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
const { userStore } = useStore();
const allUsers = userStore.list.map((user) => ({
value: user.userId,
label: user.name,
}));
const [options, setOptions] = useState<SelectProps<object>['options']>([]);
React.useEffect(() => {
if (userStore.list.length === 0) {
userStore.fetchUsers().then((r) => {
setOptions(
r.map((user: any) => ({
value: user.userId,
label: user.name,
}))
);
});
}
}, []);
const handleSearch = (value: string) => {
setOptions(
value ? allUsers.filter((u) => u.label.toLowerCase().includes(value.toLocaleLowerCase())) : []
);
};
const onSelect = (value?: string) => {
onUserSelect(value)
setSelectedValue(allUsers.find((u) => u.value === value)?.label || '');
};
return (
<AutoComplete
popupMatchSelectWidth={200}
style={{ width: 200 }}
options={options}
onSelect={onSelect}
onSearch={handleSearch}
value={selectedValue}
onChange={(e) => {
setSelectedValue(e)
if (!e) onUserSelect(undefined)
}}
onClear={() => onSelect(undefined)}
onDeselect={() => onSelect(undefined)}
size="small"
>
<Input.Search
allowClear
placeholder="Filter by team member name"
size={'small'}
classNames={{ input: '!border-0 focus:!border-0' }}
style={{ width: 200 }}
/>
</AutoComplete>
);
};
export default observer(UserSearch);

View file

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

View file

@ -0,0 +1,74 @@
import { fileNameFormat } from 'App/utils';
export const getPdf2 = async () => {
// @ts-ignore
import('html2canvas').then(({ default: html2canvas }) => {
// @ts-ignore
window.html2canvas = html2canvas;
// @ts-ignore
import('jspdf').then(({ jsPDF }) => {
const doc = new jsPDF('l', 'mm', 'a4');
const now = new Date().toISOString();
doc.addMetadata('Author', 'OpenReplay');
doc.addMetadata('Title', 'OpenReplay Assist Stats');
doc.addMetadata('Subject', 'OpenReplay Assist Stats');
doc.addMetadata('Keywords', 'OpenReplay Assist Stats');
doc.addMetadata('Creator', 'OpenReplay');
doc.addMetadata('Producer', 'OpenReplay');
doc.addMetadata('CreationDate', now);
const el = document.getElementById('pdf-anchor') as HTMLElement;
function buildPng() {
html2canvas(el, {
scale: 2,
ignoreElements: (e) => e.id.includes('pdf-ignore'),
}).then((canvas) => {
const imgData = canvas.toDataURL('img/png');
let imgWidth = 290;
let pageHeight = 200;
let imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight - pageHeight;
let position = 0;
const A4Height = 295;
const headerW = 40;
const logoWidth = 55;
doc.addImage(imgData, 'PNG', 3, 10, imgWidth, imgHeight);
doc.addImage('/assets/img/cobrowising-report-head.png', 'png', A4Height / 2 - headerW / 2, 2, 45, 5);
if (position === 0 && heightLeft === 0)
doc.addImage(
'/assets/img/report-head.png',
'png',
imgWidth / 2 - headerW / 2,
pageHeight - 5,
logoWidth,
5
);
while (heightLeft >= 0) {
position = heightLeft - imgHeight;
doc.addPage();
doc.addImage(imgData, 'PNG', 5, position, imgWidth, imgHeight);
doc.addImage(
'/assets/img/report-head.png',
'png',
A4Height / 2 - headerW / 2,
pageHeight - 5,
logoWidth,
5
);
heightLeft -= pageHeight;
}
doc.save(fileNameFormat('Assist_Stats_' + Date.now(), '.pdf'));
});
}
buildPng();
});
});
};

View file

@ -26,6 +26,7 @@ interface Props {
query?: Record<string, (key: string) => any>;
request: () => void;
userId: number;
siteId: number;
}
let playerInst: ILivePlayerContext['player'] | undefined;
@ -39,6 +40,7 @@ function LivePlayer({
query,
isEnterprise,
userId,
siteId,
}: Props) {
// @ts-ignore
const [contextValue, setContextValue] = useState<ILivePlayerContext>(defaultContextValue);
@ -67,6 +69,7 @@ function LivePlayer({
sessionWithAgentData,
data,
userId,
siteId,
(state) => makeAutoObservable(state),
toast
);
@ -78,6 +81,7 @@ function LivePlayer({
sessionWithAgentData,
null,
userId,
siteId,
(state) => makeAutoObservable(state),
toast
);
@ -140,6 +144,7 @@ export default withPermissions(
)(
connect((state: any) => {
return {
siteId: state.getIn([ 'site', 'siteId' ]),
session: state.getIn(['sessions', 'current']),
showAssist: state.getIn(['sessions', 'showChatWindow']),
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',

View file

@ -1,4 +1,6 @@
import React from 'react';
import { Button, Dropdown, Space, Typography, Input } from 'antd';
import { FilePdfOutlined, DownOutlined, TableOutlined } from '@ant-design/icons';
import { DATE_RANGE_OPTIONS, CUSTOM_RANGE } from 'App/dateRange';
import Select from 'Shared/Select';
import Period from 'Types/app/period';
@ -9,81 +11,149 @@ import cn from 'classnames';
import { observer } from 'mobx-react-lite';
interface Props {
period: any;
onChange: (data: any) => void;
disableCustom?: boolean;
right?: boolean;
timezone?: string;
[x: string]: any;
period: any;
onChange: (data: any) => void;
disableCustom?: boolean;
right?: boolean;
timezone?: string;
isAnt?: boolean;
[x: string]: any;
}
function SelectDateRange(props: Props) {
const [isCustom, setIsCustom] = React.useState(false);
const { right = false, period, disableCustom = false, timezone, ...rest } = props;
let selectedValue = DATE_RANGE_OPTIONS.find((obj: any) => obj.value === period.rangeName);
const options = DATE_RANGE_OPTIONS.filter((obj: any) => (disableCustom ? obj.value !== CUSTOM_RANGE : true));
const [isCustom, setIsCustom] = React.useState(false);
const { right = false, period, disableCustom = false, timezone, ...rest } = props;
let selectedValue = DATE_RANGE_OPTIONS.find((obj: any) => obj.value === period.rangeName);
const options = DATE_RANGE_OPTIONS.filter((obj: any) =>
disableCustom ? obj.value !== CUSTOM_RANGE : true
);
const onChange = (value: any) => {
if (value === CUSTOM_RANGE) {
setIsCustom(true);
} else {
// @ts-ignore
props.onChange(new Period({ rangeName: value }));
}
const onChange = (value: any) => {
if (value === CUSTOM_RANGE) {
setIsCustom(true);
} else {
// @ts-ignore
props.onChange(new Period({ rangeName: value }));
}
};
const onApplyDateRange = (value: any) => {
// @ts-ignore
const range = new Period({ rangeName: CUSTOM_RANGE, start: value.start, end: value.end });
props.onChange(range);
setIsCustom(false);
};
const isCustomRange = period.rangeName === CUSTOM_RANGE;
const customRange = isCustomRange ? period.rangeFormatted() : '';
if (props.isAnt) {
const onAntUpdate = ({ key }: { key: string }) => {
onChange(key);
};
const onApplyDateRange = (value: any) => {
// @ts-ignore
const range = new Period({ rangeName: CUSTOM_RANGE, start: value.start, end: value.end })
props.onChange(range);
setIsCustom(false);
};
const isCustomRange = period.rangeName === CUSTOM_RANGE;
const customRange = isCustomRange ? period.rangeFormatted() : '';
return (
<div className="relative">
<Select
plain
value={selectedValue}
options={options}
onChange={({ value }: any) => onChange(value.value)}
components={{
SingleValue: ({ children, ...props }: any) => {
return (
<components.SingleValue {...props}>
{isCustomRange ? customRange : children}
</components.SingleValue>
);
},
}}
period={period}
right={true}
style={{ width: '100%' }}
/>
{isCustom && (
<OutsideClickDetectingDiv
onClickOutside={(e: any) => {
if (e.target.parentElement.parentElement.classList.contains('rc-time-picker-panel-select') || e.target.parentElement.parentElement.classList[0]?.includes('-menu')) {
return false;
}
setIsCustom(false);
}}
>
<div
className={cn('absolute top-0 mt-10 z-40', { 'right-0': right })}
style={{
width: '770px',
fontSize: '14px',
textAlign: 'left',
}}
>
<DateRangePopup timezone={timezone} onApply={onApplyDateRange} onCancel={() => setIsCustom(false)} selectedDateRange={period.range} />
</div>
</OutsideClickDetectingDiv>
)}
</div>
<div className={'relative'}>
<Dropdown
menu={{
items: options.map((o) => ({ key: o.value, label: o.label })),
onClick: onAntUpdate,
}}
>
<Button size={'small'}>
<Space>
<Typography.Text>{selectedValue?.label || 'Select Range'}</Typography.Text>
<DownOutlined rev={undefined} />
</Space>
</Button>
</Dropdown>
{isCustom && (
<OutsideClickDetectingDiv
onClickOutside={(e: any) => {
if (
e.target.parentElement.parentElement.classList.contains(
'rc-time-picker-panel-select'
) ||
e.target.parentElement.parentElement.classList[0]?.includes('-menu')
) {
return false;
}
setIsCustom(false);
}}
>
<div
className={cn('absolute top-0 mt-10 z-40', { 'right-0': right })}
style={{
width: '770px',
fontSize: '14px',
textAlign: 'left',
}}
>
<DateRangePopup
timezone={timezone}
onApply={onApplyDateRange}
onCancel={() => setIsCustom(false)}
selectedDateRange={period.range}
/>
</div>
</OutsideClickDetectingDiv>
)}
</div>
);
}
return (
<div className="relative">
<Select
plain
value={selectedValue}
options={options}
onChange={({ value }: any) => onChange(value.value)}
components={{
SingleValue: ({ children, ...props }: any) => {
return (
<components.SingleValue {...props}>
{isCustomRange ? customRange : children}
</components.SingleValue>
);
},
}}
period={period}
right={true}
style={{ width: '100%' }}
/>
{isCustom && (
<OutsideClickDetectingDiv
onClickOutside={(e: any) => {
if (
e.target.parentElement.parentElement.classList.contains(
'rc-time-picker-panel-select'
) ||
e.target.parentElement.parentElement.classList[0]?.includes('-menu')
) {
return false;
}
setIsCustom(false);
}}
>
<div
className={cn('absolute top-0 mt-10 z-40', { 'right-0': right })}
style={{
width: '770px',
fontSize: '14px',
textAlign: 'left',
}}
>
<DateRangePopup
timezone={timezone}
onApply={onApplyDateRange}
onCancel={() => setIsCustom(false)}
selectedDateRange={period.range}
/>
</div>
</OutsideClickDetectingDiv>
)}
</div>
);
}
export default observer(SelectDateRange);

View file

@ -24,7 +24,7 @@ export default function Pagination(props: Props) {
}
};
const isFirstPage = currentPage === 1;
const isFirstPage = currentPage <= 1;
const isLastPage = currentPage === totalPages;
return (
<div className="flex items-center">

File diff suppressed because one or more lines are too long

View file

@ -47,14 +47,14 @@ function SideMenu(props: Props) {
const updatedItems = category.items.map(item => {
if (item.hidden) return item;
const isHidden = [
(item.key === MENU.NOTES && modules.includes(MODULES.NOTES)),
(item.key === MENU.LIVE_SESSIONS || item.key === MENU.RECORDINGS) && modules.includes(MODULES.ASSIST),
(item.key === MENU.SESSIONS && modules.includes(MODULES.OFFLINE_RECORDINGS)),
(item.key === MENU.ALERTS && modules.includes(MODULES.ALERTS)),
(item.isAdmin && !isAdmin),
(item.isEnterprise && !isEnterprise)
].some(cond => cond);
const isHidden = [
(item.key === MENU.NOTES && modules.includes(MODULES.NOTES)),
(item.key === MENU.LIVE_SESSIONS || item.key === MENU.RECORDINGS || item.key === MENU.STATS) && modules.includes(MODULES.ASSIST),
(item.key === MENU.SESSIONS && modules.includes(MODULES.OFFLINE_RECORDINGS)),
(item.key === MENU.ALERTS && modules.includes(MODULES.ALERTS)),
(item.isAdmin && !isAdmin),
(item.isEnterprise && !isEnterprise)
].some(cond => cond);
return { ...item, hidden: isHidden };
});
@ -86,6 +86,7 @@ function SideMenu(props: Props) {
[MENU.BOOKMARKS]: () => withSiteId(routes.bookmarks(), siteId),
[MENU.NOTES]: () => withSiteId(routes.notes(), siteId),
[MENU.LIVE_SESSIONS]: () => withSiteId(routes.assist(), siteId),
[MENU.STATS]: () => withSiteId(routes.assistStats(), siteId),
[MENU.RECORDINGS]: () => withSiteId(routes.recordings(), siteId),
[MENU.DASHBOARDS]: () => withSiteId(routes.dashboard(), siteId),
[MENU.CARDS]: () => withSiteId(routes.metrics(), siteId),

View file

@ -44,6 +44,7 @@ export const enum MENU {
NOTES = 'notes',
LIVE_SESSIONS = 'live-sessions',
RECORDINGS = 'recordings',
STATS = 'stats',
DASHBOARDS = 'dashboards',
CARDS = 'cards',
FUNNELS = 'funnels',
@ -72,7 +73,8 @@ export const categories: Category[] = [
key: 'assist',
items: [
{ label: 'Cobrowse', key: MENU.LIVE_SESSIONS, icon: 'broadcast' },
{ label: 'Recordings', key: MENU.RECORDINGS, icon: 'record-btn', isEnterprise: true }
{ label: 'Recordings', key: MENU.RECORDINGS, icon: 'record-btn', isEnterprise: true },
{ label: 'Stats', key: MENU.STATS, icon: 'file-bar-graph' },
]
},
{

View file

@ -51,6 +51,7 @@ export function createLiveWebPlayer(
session: SessionFilesInfo,
config: RTCIceServer[] | null,
agentId: number,
projectId: number,
wrapStore?: (s:IWebLivePlayerStore) => IWebLivePlayerStore,
uiErrorHandler?: { error: (msg: string) => void }
): [IWebLivePlayer, IWebLivePlayerStore] {
@ -61,6 +62,6 @@ export function createLiveWebPlayer(
store = wrapStore(store)
}
const player = new WebLivePlayer(store, session, config, agentId, uiErrorHandler)
const player = new WebLivePlayer(store, session, config, agentId, projectId, uiErrorHandler)
return [player, store]
}

View file

@ -24,6 +24,7 @@ export default class WebLivePlayer extends WebPlayer {
private session: SessionFilesInfo,
config: RTCIceServer[] | null,
agentId: number,
projectId: number,
uiErrorHandler?: { error: (msg: string) => void },
) {
super(wpState, session, true, false, uiErrorHandler)
@ -43,7 +44,7 @@ export default class WebLivePlayer extends WebPlayer {
wpState,
uiErrorHandler,
)
this.assistManager.connect(session.agentToken!, agentId)
this.assistManager.connect(session.agentToken!, agentId, projectId)
}
toggleTimetravel = async () => {

View file

@ -143,7 +143,7 @@ export default class AssistManager {
this.inactiveTimeout && clearTimeout(this.inactiveTimeout)
this.inactiveTimeout = undefined
}
connect(agentToken: string, agentId: number) {
connect(agentToken: string, agentId: number, projectId: number) {
const jmr = new JSONRawMessageReader()
const reader = new MStreamReader(jmr, this.session.startedAt)
let waitingForMessages = true
@ -165,6 +165,7 @@ export default class AssistManager {
},
query: {
peerId: this.peerID,
projectId,
identity: "agent",
agentInfo: JSON.stringify({
...this.session.agentInfo,

View file

@ -95,6 +95,7 @@ export const fflagRead = (id = ':fflagId', hash?: string | number): string => ha
export const notes = (params?: Record<string, any>): string => queried('/notes', params);
export const bookmarks = (params?: Record<string, any>): string => queried('/bookmarks', params);
export const assist = (params?: Record<string, any>): string => queried('/assist', params);
export const assistStats = (params?: Record<string, any>): string => queried('/cobrowse-stats', params);
export const recordings = (params?: Record<string, any>): string => queried('/recordings', params);
export const multiviewIndex = (params?: Record<string, any>): string => queried('/multiview', params);
export const multiview = (sessionsQuery = ':sessionsquery', hash?: string | number): string =>
@ -144,10 +145,12 @@ const REQUIRED_SITE_ID_ROUTES = [
notes(),
bookmarks(),
fflags(),
assist(),
recordings(),
multiview(),
multiviewIndex(),
assistStats(),
metrics(),
metricDetails(''),

View file

@ -0,0 +1,145 @@
import APIClient from 'App/api_client';
export interface Member {
name: string;
count: number;
assistDuration: number;
callDuration: number;
controlDuration: number;
assistCount: number;
}
export interface AssistStatsSession {
callDuration: number;
assistDuration: number;
controlDuration: number;
sessionId: string;
teamMembers: { name: string; id: string }[];
timestamp: number;
recordings: {
recordId: number;
name: string;
duration: number;
}[];
}
export type PeriodKeys =
| 'assistTotal'
| 'assistAvg'
| 'callTotal'
| 'callAvg'
| 'controlTotal'
| 'controlAvg';
export interface Graphs {
currentPeriod: {
assistTotal: number;
assistAvg: number;
callTotal: number;
callAvg: number;
controlTotal: number;
controlAvg: number;
};
previousPeriod: {
assistTotal: number;
assistAvg: number;
callTotal: number;
callAvg: number;
controlTotal: number;
controlAvg: number;
};
list: {
time: number;
assistAvg: number;
callAvg: number;
controlAvg: number;
assistTotal: number;
callTotal: number;
controlTotal: number;
}[];
}
export const generateListData = (list: any[], key: PeriodKeys) => {
return list.map((item) => {
return {
timestamp: item.timestamp,
value: item[key],
};
});
};
export const defaultGraphs = {
currentPeriod: {
assistTotal: 0,
assistAvg: 0,
callTotal: 0,
callAvg: 0,
controlTotal: 0,
controlAvg: 0,
},
previousPeriod: {
assistTotal: 0,
assistAvg: 0,
callTotal: 0,
callAvg: 0,
controlTotal: 0,
controlAvg: 0,
},
list: [],
};
export interface SessionsResponse {
total: number;
page: number;
list: AssistStatsSession[];
}
export default class AssistStatsService {
private client: APIClient;
constructor(client?: APIClient) {
this.client = client ? client : new APIClient();
}
initClient(client?: APIClient) {
this.client = client || new APIClient();
}
fetch(path: string, body: Record<string, any>, method: 'get' | 'post') {
return this.client[method]('/assist-stats/' + path, body).then((r) => r.json());
}
getGraphs(range: { start: number; end: number }, userId?: number): Promise<Graphs> {
return this.fetch(
'avg',
{ startTimestamp: range.start, endTimestamp: range.end, userId },
'get'
);
}
getTopMembers(filters: {
startTimestamp: number;
endTimestamp: number;
sort: string;
order: 'asc' | 'desc';
userId?: number;
}): Promise<{ list: Member[]; total: number }> {
return this.fetch('top-members', filters, 'get');
}
getSessions(filters: {
startTimestamp: number;
endTimestamp: number;
sort: string;
userId?: number;
order: 'asc' | 'desc';
page: number;
limit: number;
}): Promise<SessionsResponse> {
return this.fetch('sessions', filters, 'post');
}
exportCSV(filters: { start: number; end: number; sort: string; order: 'asc' | 'desc' }) {
return this.fetch('export-csv', filters, 'get');
}
}

View file

@ -49,7 +49,7 @@ export default class RecordingsService {
method: 'PUT',
headers: { 'Content-Type': 'video/webm' },
body: file,
}).then((r) => {
}).then(() => {
return true;
});
}
@ -66,7 +66,7 @@ export default class RecordingsService {
});
}
fetchRecording(id: number): Promise<IRecord> {
fetchRecording(id: number | string): Promise<IRecord> {
return this.client.get(`/assist/records/${id}`).then((r) => {
return r.json().then((j) => j.data);
});

View file

@ -12,6 +12,7 @@ import AlertsService from './AlertsService'
import WebhookService from './WebhookService'
import HealthService from "./HealthService";
import FFlagsService from "App/services/FFlagsService";
import AssistStatsService from './AssistStatsService'
export const dashboardService = new DashboardService();
export const metricService = new MetricService();
@ -30,6 +31,8 @@ export const healthService = new HealthService();
export const fflagsService = new FFlagsService();
export const assistStatsService = new AssistStatsService();
export const services = [
dashboardService,
metricService,
@ -44,5 +47,6 @@ export const services = [
alertsService,
webhookService,
healthService,
fflagsService
fflagsService,
assistStatsService
]

View file

@ -0,0 +1,11 @@
<svg viewBox="0 0 14 15" xmlns="http://www.w3.org/2000/svg">
<g id="123" clip-path="url(#clip0_27_1406)">
<path id="Vector" d="M3.9375 10.6117C3.82147 10.6117 3.71019 10.5656 3.62814 10.4836C3.54609 10.4015 3.5 10.2903 3.5 10.1742V8.42422C3.5 8.30819 3.54609 8.19691 3.62814 8.11487C3.71019 8.03282 3.82147 7.98672 3.9375 7.98672H4.8125C4.92853 7.98672 5.03981 8.03282 5.12186 8.11487C5.20391 8.19691 5.25 8.30819 5.25 8.42422V10.1742C5.25 10.2903 5.20391 10.4015 5.12186 10.4836C5.03981 10.5656 4.92853 10.6117 4.8125 10.6117H3.9375ZM6.5625 10.6117C6.44647 10.6117 6.33519 10.5656 6.25314 10.4836C6.17109 10.4015 6.125 10.2903 6.125 10.1742V6.67422C6.125 6.55819 6.17109 6.44691 6.25314 6.36487C6.33519 6.28282 6.44647 6.23672 6.5625 6.23672H7.4375C7.55353 6.23672 7.66481 6.28282 7.74686 6.36487C7.82891 6.44691 7.875 6.55819 7.875 6.67422V10.1742C7.875 10.2903 7.82891 10.4015 7.74686 10.4836C7.66481 10.5656 7.55353 10.6117 7.4375 10.6117H6.5625ZM9.1875 10.6117C9.07147 10.6117 8.96019 10.5656 8.87814 10.4836C8.79609 10.4015 8.75 10.2903 8.75 10.1742V4.92422C8.75 4.80819 8.79609 4.69691 8.87814 4.61487C8.96019 4.53282 9.07147 4.48672 9.1875 4.48672H10.0625C10.1785 4.48672 10.2898 4.53282 10.3719 4.61487C10.4539 4.69691 10.5 4.80819 10.5 4.92422V10.1742C10.5 10.2903 10.4539 10.4015 10.3719 10.4836C10.2898 10.5656 10.1785 10.6117 10.0625 10.6117H9.1875Z" />
<path id="Vector_2" d="M3.5 0.111725C3.03587 0.111725 2.59075 0.296099 2.26256 0.624288C1.93437 0.952477 1.75 1.3976 1.75 1.86172V12.3617C1.75 12.8259 1.93437 13.271 2.26256 13.5992C2.59075 13.9274 3.03587 14.1117 3.5 14.1117H10.5C10.9641 14.1117 11.4092 13.9274 11.7374 13.5992C12.0656 13.271 12.25 12.8259 12.25 12.3617V1.86172C12.25 1.3976 12.0656 0.952477 11.7374 0.624288C11.4092 0.296099 10.9641 0.111725 10.5 0.111725L3.5 0.111725ZM3.5 0.986725H10.5C10.7321 0.986725 10.9546 1.07891 11.1187 1.24301C11.2828 1.4071 11.375 1.62966 11.375 1.86172V12.3617C11.375 12.5938 11.2828 12.8163 11.1187 12.9804C10.9546 13.1445 10.7321 13.2367 10.5 13.2367H3.5C3.26794 13.2367 3.04538 13.1445 2.88128 12.9804C2.71719 12.8163 2.625 12.5938 2.625 12.3617V1.86172C2.625 1.62966 2.71719 1.4071 2.88128 1.24301C3.04538 1.07891 3.26794 0.986725 3.5 0.986725Z" />
</g>
<defs>
<clipPath id="clip0_27_1406">
<rect width="14" height="14" fill="white" transform="translate(0 0.111725)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -16,6 +16,9 @@ describe(
window.localStorage.setItem('notesFeatureViewed', 'true');
},
});
Cypress.on('uncaught:exception', (err, runnable) => {
return false
})
cy.origin('http://localhost:3000/', { args: { SECOND } }, ({ SECOND }) => {
cy.visit('/');

View file

@ -6,6 +6,7 @@ describe('RemoteControl', () => {
let remoteControl
let options
let onGrand
let onBusy
let onRelease
let confirmWindowMountMock
let confirmWindowRemoveMock
@ -16,6 +17,7 @@ describe('RemoteControl', () => {
}
onGrand = jest.fn()
onRelease = jest.fn()
onBusy = jest.fn()
confirmWindowMountMock = jest.fn(() => Promise.resolve(true))
confirmWindowRemoveMock = jest.fn()
@ -36,7 +38,7 @@ describe('RemoteControl', () => {
.spyOn(ConfirmWindow.prototype, 'remove')
.mockImplementation(confirmWindowRemoveMock)
remoteControl = new RemoteControl(options, onGrand, onRelease)
remoteControl = new RemoteControl(options, onGrand, onRelease, onBusy)
})
afterEach(() => {

View file

@ -90,7 +90,7 @@ export default class Nodes {
clear(): void {
for (let id = 0; id < this.nodes.length; id++) {
const node = this.nodes[id]
if (node === undefined) {
if (!node) {
continue
}
this.unregisterNode(node)

View file

@ -0,0 +1,92 @@
import Nodes from './nodes'
import { describe, beforeEach, expect, it, jest } from '@jest/globals'
describe('Nodes', () => {
let nodes: Nodes
const nodeId = 'test_id'
const mockCallback = jest.fn()
beforeEach(() => {
nodes = new Nodes(nodeId)
mockCallback.mockClear()
})
it('attachNodeCallback', () => {
nodes.attachNodeCallback(mockCallback)
nodes.callNodeCallbacks(document.createElement('div'), true)
expect(mockCallback).toHaveBeenCalled()
})
it('attachNodeListener is listening to events', () => {
const node = document.createElement('div')
const mockListener = jest.fn()
document.body.appendChild(node)
nodes.registerNode(node)
nodes.attachNodeListener(node, 'click', mockListener, false)
node.dispatchEvent(new Event('click'))
expect(mockListener).toHaveBeenCalled()
})
it('attachNodeListener is calling native method', () => {
const node = document.createElement('div')
const mockListener = jest.fn()
const addEventListenerSpy = jest.spyOn(node, 'addEventListener')
nodes.registerNode(node)
nodes.attachNodeListener(node, 'click', mockListener)
expect(addEventListenerSpy).toHaveBeenCalledWith('click', mockListener, true)
})
it('registerNode', () => {
const node = document.createElement('div')
const [id, isNew] = nodes.registerNode(node)
expect(id).toBeDefined()
expect(isNew).toBe(true)
})
it('unregisterNode', () => {
const node = document.createElement('div')
const [id] = nodes.registerNode(node)
const unregisteredId = nodes.unregisterNode(node)
expect(unregisteredId).toBe(id)
})
it('cleanTree', () => {
const node = document.createElement('div')
nodes.registerNode(node)
nodes.cleanTree()
expect(nodes.getNodeCount()).toBe(0)
})
it('callNodeCallbacks', () => {
nodes.attachNodeCallback(mockCallback)
const node = document.createElement('div')
nodes.callNodeCallbacks(node, true)
expect(mockCallback).toHaveBeenCalledWith(node, true)
})
it('getID', () => {
const node = document.createElement('div')
const [id] = nodes.registerNode(node)
const fetchedId = nodes.getID(node)
expect(fetchedId).toBe(id)
})
it('getNode', () => {
const node = document.createElement('div')
const [id] = nodes.registerNode(node)
const fetchedNode = nodes.getNode(id)
expect(fetchedNode).toBe(node)
})
it('getNodeCount', () => {
expect(nodes.getNodeCount()).toBe(0)
nodes.registerNode(document.createElement('div'))
expect(nodes.getNodeCount()).toBe(1)
})
it('clear', () => {
nodes.registerNode(document.createElement('div'))
nodes.clear()
expect(nodes.getNodeCount()).toBe(0)
})
})

View file

@ -168,7 +168,7 @@ export default function (app: App, options?: MouseHandlerOptions): void {
mouseTarget = null
selectorMap = {}
if (checkIntervalId) {
clearInterval(checkIntervalId)
clearInterval(checkIntervalId as unknown as number)
}
})

View file

@ -72,7 +72,6 @@ describe('FeatureFlags', () => {
userID: sessionInfo.userID,
metadata: sessionInfo.metadata,
referrer: '',
featureFlags: featureFlags.flags,
os: 'test',
device: 'test',
country: 'test',