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:
parent
fd51cf489e
commit
a2fce7e291
30 changed files with 1222 additions and 97 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
BIN
frontend/app/assets/img/cobrowising-report-head.png
Normal file
BIN
frontend/app/assets/img/cobrowising-report-head.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
295
frontend/app/components/AssistStats/AssistStats.tsx
Normal file
295
frontend/app/components/AssistStats/AssistStats.tsx
Normal 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);
|
||||
62
frontend/app/components/AssistStats/components/Charts.tsx
Normal file
62
frontend/app/components/AssistStats/components/Charts.tsx
Normal 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;
|
||||
165
frontend/app/components/AssistStats/components/Table.tsx
Normal file
165
frontend/app/components/AssistStats/components/Table.tsx
Normal 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;
|
||||
117
frontend/app/components/AssistStats/components/TeamMembers.tsx
Normal file
117
frontend/app/components/AssistStats/components/TeamMembers.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
1
frontend/app/components/AssistStats/index.ts
Normal file
1
frontend/app/components/AssistStats/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AssistStats'
|
||||
74
frontend/app/components/AssistStats/pdfGenerator.ts
Normal file
74
frontend/app/components/AssistStats/pdfGenerator.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(''),
|
||||
|
|
|
|||
145
frontend/app/services/AssistStatsService.ts
Normal file
145
frontend/app/services/AssistStatsService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
11
frontend/app/svg/icons/file-bar-graph.svg
Normal file
11
frontend/app/svg/icons/file-bar-graph.svg
Normal 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 |
|
|
@ -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('/');
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
92
tracker/tracker/src/main/app/nodes.unit.test.ts
Normal file
92
tracker/tracker/src/main/app/nodes.unit.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ describe('FeatureFlags', () => {
|
|||
userID: sessionInfo.userID,
|
||||
metadata: sessionInfo.metadata,
|
||||
referrer: '',
|
||||
featureFlags: featureFlags.flags,
|
||||
os: 'test',
|
||||
device: 'test',
|
||||
country: 'test',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue