[wip] user testing ui/tracker (#1520)
* feat(ui): some design mocks * fix(ui): some fixes for stuff * feat(ui): test overview page layout * feat(ui): fix placeholder * feat(ui): answers table modal * feat(tracker): user testing module in tracker * fix(tracker): add "thank you" section, refactor file to make it readable * fix(tracker): naming * fix(tracker): naming * fix(tracker): some refactorings for user testing modd * feat(tracker): export assist vers to window obj, add recorder manager for user testing * feat(tracker): refactor UT file * feat(tracker): add recording delay for UT module * feat(tracker): dnd for UT widget * fix(tracker): changelog for assist * fix(tracker): cover ut with tests * fix(tracker): update package scripts to include testing before releasing packages * fix(UI): fix uxt routes * feat(ui): uxt store * feat(ui): uxt store connection * feat(ui): some api connections for utx * feat(ui): some api connections for utx * feat(ui): some api connections for utx * feat(ui): api connections * feat(ui): api connections * feat(ui): api connections * feat(ui): api connections * feat(ui): utx components for replay * feat(ui): utx components for replay * feat(ui): make events shared * feat(ui): final fixes
This commit is contained in:
parent
4f055dbfa7
commit
cc34356294
48 changed files with 3240 additions and 320 deletions
|
|
@ -23,10 +23,12 @@ const components: any = {
|
|||
FunnelIssueDetails: lazy(() => import('Components/Funnels/FunnelIssueDetails')),
|
||||
FunnelPagePure: lazy(() => import('Components/Funnels/FunnelPage')),
|
||||
MultiviewPure: lazy(() => import('Components/Session_/Multiview/Multiview')),
|
||||
AssistStatsPure: lazy(() => import('Components/AssistStats'))
|
||||
AssistStatsPure: lazy(() => import('Components/AssistStats')),
|
||||
UsabilityTestingPure: lazy(() => import('Components/UsabilityTesting/UsabilityTesting')),
|
||||
UsabilityTestEditPure: lazy(() => import('Components/UsabilityTesting/TestEdit')),
|
||||
UsabilityTestOverviewPure: lazy(() => import('Components/UsabilityTesting/TestOverview')),
|
||||
};
|
||||
|
||||
|
||||
const enhancedComponents: any = {
|
||||
SessionsOverview: withSiteIdUpdater(components.SessionsOverviewPure),
|
||||
Dashboard: withSiteIdUpdater(components.DashboardPure),
|
||||
|
|
@ -39,7 +41,10 @@ const enhancedComponents: any = {
|
|||
FunnelsDetails: withSiteIdUpdater(components.FunnelDetailsPure),
|
||||
FunnelIssue: withSiteIdUpdater(components.FunnelIssueDetails),
|
||||
Multiview: withSiteIdUpdater(components.MultiviewPure),
|
||||
AssistStats: withSiteIdUpdater(components.AssistStatsPure)
|
||||
AssistStats: withSiteIdUpdater(components.AssistStatsPure),
|
||||
UsabilityTesting: withSiteIdUpdater(components.UsabilityTestingPure),
|
||||
UsabilityTestEdit: withSiteIdUpdater(components.UsabilityTestEditPure),
|
||||
UsabilityTestOverview: withSiteIdUpdater(components.UsabilityTestOverviewPure),
|
||||
};
|
||||
|
||||
const withSiteId = routes.withSiteId;
|
||||
|
|
@ -79,6 +84,9 @@ const MULTIVIEW_PATH = routes.multiview();
|
|||
const MULTIVIEW_INDEX_PATH = routes.multiviewIndex();
|
||||
const ASSIST_STATS_PATH = routes.assistStats();
|
||||
|
||||
const USABILITY_TESTING_PATH = routes.usabilityTesting();
|
||||
const USABILITY_TESTING_EDIT_PATH = routes.usabilityTestingEdit();
|
||||
const USABILITY_TESTING_VIEW_PATH = routes.usabilityTestingView();
|
||||
|
||||
interface Props {
|
||||
isEnterprise: boolean;
|
||||
|
|
@ -91,29 +99,32 @@ interface Props {
|
|||
|
||||
function PrivateRoutes(props: Props) {
|
||||
const { onboarding, sites, siteId, jwt } = props;
|
||||
const redirectToOnboarding = !onboarding && localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true';
|
||||
const redirectToOnboarding =
|
||||
!onboarding && localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true';
|
||||
const siteIdList: any = sites.map(({ id }) => id).toJS();
|
||||
return (
|
||||
<Suspense fallback={<Loader loading={true} className='flex-1' />}>
|
||||
<Switch key='content'>
|
||||
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
|
||||
<Switch key="content">
|
||||
<Route path={CLIENT_PATH} component={enhancedComponents.Client} />
|
||||
<Route path={withSiteId(ONBOARDING_PATH, siteIdList)}
|
||||
component={enhancedComponents.Onboarding} />
|
||||
<Route
|
||||
path='/integrations/'
|
||||
path={withSiteId(ONBOARDING_PATH, siteIdList)}
|
||||
component={enhancedComponents.Onboarding}
|
||||
/>
|
||||
<Route
|
||||
path="/integrations/"
|
||||
render={({ location }) => {
|
||||
const client = new APIClient();
|
||||
switch (location.pathname) {
|
||||
case '/integrations/slack':
|
||||
client.post('integrations/slack/add', {
|
||||
code: location.search.split('=')[1],
|
||||
state: props.tenantId
|
||||
state: props.tenantId,
|
||||
});
|
||||
break;
|
||||
case '/integrations/msteams':
|
||||
client.post('integrations/msteams/add', {
|
||||
code: location.search.split('=')[1],
|
||||
state: props.tenantId
|
||||
state: props.tenantId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -123,35 +134,88 @@ function PrivateRoutes(props: Props) {
|
|||
{redirectToOnboarding && <Redirect to={withSiteId(ONBOARDING_REDIRECT_PATH, siteId)} />}
|
||||
|
||||
{/* DASHBOARD and Metrics */}
|
||||
<Route exact strict path={[
|
||||
withSiteId(ALERTS_PATH, siteIdList),
|
||||
withSiteId(ALERT_EDIT_PATH, siteIdList),
|
||||
withSiteId(ALERT_CREATE_PATH, siteIdList),
|
||||
withSiteId(METRICS_PATH, siteIdList),
|
||||
withSiteId(METRICS_DETAILS, siteIdList),
|
||||
withSiteId(METRICS_DETAILS_SUB, siteIdList),
|
||||
withSiteId(DASHBOARD_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_SELECT_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_METRIC_CREATE_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList)
|
||||
]} component={enhancedComponents.Dashboard} />
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={[
|
||||
withSiteId(ALERTS_PATH, siteIdList),
|
||||
withSiteId(ALERT_EDIT_PATH, siteIdList),
|
||||
withSiteId(ALERT_CREATE_PATH, siteIdList),
|
||||
withSiteId(METRICS_PATH, siteIdList),
|
||||
withSiteId(METRICS_DETAILS, siteIdList),
|
||||
withSiteId(METRICS_DETAILS_SUB, siteIdList),
|
||||
withSiteId(DASHBOARD_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_SELECT_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_METRIC_CREATE_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList),
|
||||
]}
|
||||
component={enhancedComponents.Dashboard}
|
||||
/>
|
||||
|
||||
<Route exact path={withSiteId(MULTIVIEW_INDEX_PATH, siteIdList)}
|
||||
component={enhancedComponents.Multiview} />
|
||||
<Route path={withSiteId(MULTIVIEW_PATH, siteIdList)}
|
||||
component={enhancedComponents.Multiview} />
|
||||
<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)}
|
||||
component={enhancedComponents.FunnelsDetails} />
|
||||
<Route exact strict path={withSiteId(FUNNEL_ISSUE_PATH, siteIdList)}
|
||||
component={enhancedComponents.FunnelIssue} />
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(USABILITY_TESTING_PATH, siteIdList)}
|
||||
component={enhancedComponents.UsabilityTesting}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(USABILITY_TESTING_EDIT_PATH, siteIdList)}
|
||||
component={enhancedComponents.UsabilityTestEdit}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(USABILITY_TESTING_VIEW_PATH, siteIdList)}
|
||||
component={enhancedComponents.UsabilityTestOverview}
|
||||
/>
|
||||
|
||||
<Route
|
||||
exact
|
||||
path={withSiteId(MULTIVIEW_INDEX_PATH, siteIdList)}
|
||||
component={enhancedComponents.Multiview}
|
||||
/>
|
||||
<Route
|
||||
path={withSiteId(MULTIVIEW_PATH, siteIdList)}
|
||||
component={enhancedComponents.Multiview}
|
||||
/>
|
||||
<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)}
|
||||
component={enhancedComponents.FunnelsDetails}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(FUNNEL_ISSUE_PATH, siteIdList)}
|
||||
component={enhancedComponents.FunnelIssue}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
|
|
@ -162,14 +226,22 @@ function PrivateRoutes(props: Props) {
|
|||
withSiteId(FFLAG_READ_PATH, siteIdList),
|
||||
withSiteId(FFLAG_CREATE_PATH, siteIdList),
|
||||
withSiteId(NOTES_PATH, siteIdList),
|
||||
withSiteId(BOOKMARKS_PATH, siteIdList)
|
||||
withSiteId(BOOKMARKS_PATH, siteIdList),
|
||||
]}
|
||||
component={enhancedComponents.SessionsOverview}
|
||||
/>
|
||||
<Route exact strict path={withSiteId(SESSION_PATH, siteIdList)}
|
||||
component={enhancedComponents.Session} />
|
||||
<Route exact strict path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
|
||||
component={enhancedComponents.LiveSession} />
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(SESSION_PATH, siteIdList)}
|
||||
component={enhancedComponents.Session}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
|
||||
component={enhancedComponents.LiveSession}
|
||||
/>
|
||||
|
||||
{Object.entries(routes.redirects).map(([fr, to]) => (
|
||||
<Redirect key={fr} exact strict from={fr} to={to} />
|
||||
|
|
@ -180,7 +252,6 @@ function PrivateRoutes(props: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
export default connect((state: any) => ({
|
||||
changePassword: state.getIn(['user', 'account', 'changePassword']),
|
||||
onboarding: state.getIn(['user', 'onboarding']),
|
||||
|
|
@ -190,5 +261,5 @@ export default connect((state: any) => ({
|
|||
tenantId: state.getIn(['user', 'account', 'tenantId']),
|
||||
isEnterprise:
|
||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee'
|
||||
}))(PrivateRoutes);
|
||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee',
|
||||
}))(PrivateRoutes);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ const siteIdRequiredPaths: string[] = [
|
|||
'/unprocessed',
|
||||
'/notes',
|
||||
'/feature-flags',
|
||||
'/check-recording-status'
|
||||
'/check-recording-status',
|
||||
'/usability-tests'
|
||||
];
|
||||
|
||||
export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { durationFormatted } from 'App/date';
|
||||
import React from 'react';
|
||||
import FunnelStepText from './FunnelStepText';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
filter: any;
|
||||
isFirst: boolean
|
||||
}
|
||||
|
||||
function FunnelBar(props: Props) {
|
||||
const { filter, isFirst = false } = props;
|
||||
const { filter } = props;
|
||||
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
|
|
@ -37,32 +38,6 @@ function FunnelBar(props: Props) {
|
|||
{filter.completedPercentageTotal}%
|
||||
</div>
|
||||
</div>
|
||||
{/* {filter.dropDueToIssues > 0 && (
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: `${filter.completedPercentage}%`,
|
||||
opacity: 0.5,
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="stripes relative"
|
||||
style={{
|
||||
width: `${filter.dropDueToIssuesPercentage}%`,
|
||||
height: '16px',
|
||||
}}
|
||||
>
|
||||
<Tooltip title={`${filter.dropDueToIssues} (${filter.dropDueToIssuesPercentage}%) Dropped due to issues`} position="top-start">
|
||||
<div className="w-full h-8 absolute"/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
{/* @ts-ignore */}
|
||||
|
|
@ -84,6 +59,62 @@ function FunnelBar(props: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
export function UxTFunnelBar(props: Props) {
|
||||
const { filter } = props;
|
||||
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
<div className={'font-semibold'}>{filter.title}</div>
|
||||
<div
|
||||
style={{
|
||||
height: '25px',
|
||||
width: '100%',
|
||||
backgroundColor: '#f5f5f5',
|
||||
position: 'relative',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{
|
||||
width: `${(filter.completed/(filter.completed+filter.skipped))*100}%`,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#00b5ad',
|
||||
}}
|
||||
>
|
||||
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
|
||||
{(filter.completed/(filter.completed+filter.skipped))*100}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
{/* @ts-ignore */}
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className="flex items-center">
|
||||
<Icon name="arrow-right-short" size="20" color="green" />
|
||||
<span className="mx-1 font-medium">{filter.completed} completed this step</span>
|
||||
</div>
|
||||
<div className={'flex items-center'}>
|
||||
<Icon name="clock" size="20" color="green" />
|
||||
<span className="mx-1 font-medium">
|
||||
{durationFormatted(filter.avgCompletionTime)} Avg. completion time
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* @ts-ignore */}
|
||||
<div className="flex items-center">
|
||||
<Icon name="caret-down-fill" color="red" size={16} />
|
||||
<span className="font-medium mx-1 color-red">{filter.skipped} skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelBar;
|
||||
|
||||
const calculatePercentage = (completed: number, dropped: number) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import Funnelbar from './FunnelBar';
|
||||
import Funnelbar, { UxTFunnelBar } from "./FunnelBar";
|
||||
import cn from 'classnames';
|
||||
import stl from './FunnelWidget.module.css';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
|
|
@ -97,19 +97,23 @@ function EmptyStage({ total }: any) {
|
|||
))
|
||||
}
|
||||
|
||||
function Stage({ stage, index, isWidget }: any) {
|
||||
return useObserver(() => stage ? (
|
||||
<div className={cn("flex items-start", stl.step, { [stl['step-disabled']] : !stage.isActive })}>
|
||||
<IndexNumber index={index } />
|
||||
<Funnelbar filter={stage} isFirst={index === 1}/>
|
||||
{!isWidget && (
|
||||
<BarActions bar={stage} />
|
||||
)}
|
||||
export function Stage({ stage, index, isWidget, uxt }: any) {
|
||||
return useObserver(() =>
|
||||
stage ? (
|
||||
<div
|
||||
className={cn('flex items-start', stl.step, { [stl['step-disabled']]: !stage.isActive })}
|
||||
>
|
||||
<IndexNumber index={index} />
|
||||
{!uxt ? <Funnelbar filter={stage} /> : <UxTFunnelBar filter={stage} />}
|
||||
{!isWidget && !uxt && <BarActions bar={stage} />}
|
||||
</div>
|
||||
) : <></>)
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function IndexNumber({ index }: any) {
|
||||
export function IndexNumber({ index }: any) {
|
||||
return (
|
||||
<div className="z-10 w-6 h-6 border shrink-0 mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
|
||||
{index === 0 ? <Icon size="14" color="gray-dark" name="list" /> : index}
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ function PlayerBlockHeader(props: any) {
|
|||
return { label: key, value };
|
||||
});
|
||||
|
||||
const TABS = [props.tabs.EVENTS, props.tabs.CLICKMAP].map((tab) => ({
|
||||
text: tab,
|
||||
const TABS = Object.keys(props.tabs).map((tab) => ({
|
||||
text: props.tabs[tab],
|
||||
key: tab,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import stl from './rightblock.module.css';
|
|||
function RightBlock(props: any) {
|
||||
const { activeTab } = props;
|
||||
|
||||
if (activeTab === props.tabs.EVENTS) {
|
||||
if (activeTab === 'EVENTS') {
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-white border-l", stl.panel)}>
|
||||
<EventsBlock
|
||||
|
|
@ -17,7 +17,7 @@ function RightBlock(props: any) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
if (activeTab === props.tabs.HEATMAPS) {
|
||||
if (activeTab === 'HEATMAPS') {
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-white border-l", stl.panel)}>
|
||||
<PageInsightsPanel setActiveTab={props.setActiveTab} />
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ const TABS = {
|
|||
EVENTS: 'User Events',
|
||||
CLICKMAP: 'Click Map',
|
||||
};
|
||||
const UTXTABS = {
|
||||
EVENTS: TABS.EVENTS
|
||||
}
|
||||
|
||||
let playerInst: IPlayerContext['player'] | undefined;
|
||||
|
||||
function WebPlayer(props: any) {
|
||||
const { session, toggleFullscreen, closeBottomBlock, fullscreen, fetchList, startedAt } = props;
|
||||
const { notesStore, sessionStore } = useStore();
|
||||
const { notesStore, sessionStore, uxtestingStore } = useStore();
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [noteItem, setNoteItem] = useState<Note | undefined>(undefined);
|
||||
const [visuallyAdjusted, setAdjusted] = useState(false);
|
||||
|
|
@ -134,7 +137,7 @@ function WebPlayer(props: any) {
|
|||
// @ts-ignore TODO?
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
tabs={TABS}
|
||||
tabs={uxtestingStore.isUxt() ? UTXTABS : TABS}
|
||||
fullscreen={fullscreen}
|
||||
/>
|
||||
{/* @ts-ignore */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import UtxEvent from "Components/Session_/EventsBlock/UtxEvent";
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { durationFromMsFormatted } from "App/date";
|
||||
import { connect } from 'react-redux';
|
||||
import { TextEllipsis, Icon } from 'UI';
|
||||
import withToggle from 'HOCs/withToggle';
|
||||
|
|
@ -61,25 +62,70 @@ class EventGroupWrapper extends React.Component {
|
|||
filterOutNote
|
||||
} = this.props;
|
||||
const isLocation = event.type === TYPES.LOCATION;
|
||||
const isUtxEvent = event.type === TYPES.UTX_EVENT;
|
||||
|
||||
const whiteBg =
|
||||
(isLastInGroup && event.type !== TYPES.LOCATION) ||
|
||||
(!isLastEvent && event.type !== TYPES.LOCATION);
|
||||
const safeRef = String(event.referrer || '');
|
||||
|
||||
const returnEvt = () => {
|
||||
if (isUtxEvent) {
|
||||
return (
|
||||
<UtxEvent event={event} />
|
||||
)
|
||||
}
|
||||
if (isNote) {
|
||||
return (
|
||||
<NoteEvent
|
||||
note={event}
|
||||
filterOutNote={filterOutNote}
|
||||
onEdit={this.props.setEditNoteTooltip}
|
||||
noEdit={this.props.currentUserId !== event.userId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (isLocation) {
|
||||
return (
|
||||
<Event
|
||||
extended={isFirst}
|
||||
key={event.key}
|
||||
event={event}
|
||||
onClick={this.onEventClick}
|
||||
selected={isSelected}
|
||||
showLoadInfo={showLoadInfo}
|
||||
toggleLoadInfo={this.toggleLoadInfo}
|
||||
isCurrent={isCurrent}
|
||||
presentInSearch={presentInSearch}
|
||||
isLastInGroup={isLastInGroup}
|
||||
whiteBg={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (isTabChange) {
|
||||
return (
|
||||
<TabChange onClick={this.onEventClick} from={event.fromTab} to={event.toTab} activeUrl={event.activeUrl} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Event
|
||||
key={event.key}
|
||||
event={event}
|
||||
onClick={this.onEventClick}
|
||||
onCheckboxClick={this.onCheckboxClick}
|
||||
selected={isSelected}
|
||||
isCurrent={isCurrent}
|
||||
showSelection={showSelection}
|
||||
overlayed={isEditing}
|
||||
presentInSearch={presentInSearch}
|
||||
isLastInGroup={isLastInGroup}
|
||||
whiteBg={whiteBg}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
{
|
||||
// [stl.last]: isLastInGroup,
|
||||
// [stl.first]: event.type === TYPES.LOCATION,
|
||||
// [stl.dashAfter]: isLastInGroup && !isLastEvent
|
||||
},
|
||||
// isLastInGroup && '!pb-2',
|
||||
// event.type === TYPES.LOCATION && '!pb-2'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{isFirst && isLocation && event.referrer && (
|
||||
<TextEllipsis>
|
||||
<div className={stl.referrer}>
|
||||
|
|
@ -87,42 +133,7 @@ class EventGroupWrapper extends React.Component {
|
|||
</div>
|
||||
</TextEllipsis>
|
||||
)}
|
||||
{isNote ? (
|
||||
<NoteEvent
|
||||
note={event}
|
||||
filterOutNote={filterOutNote}
|
||||
onEdit={this.props.setEditNoteTooltip}
|
||||
noEdit={this.props.currentUserId !== event.userId}
|
||||
/>
|
||||
) : isLocation ? (
|
||||
<Event
|
||||
extended={isFirst}
|
||||
key={event.key}
|
||||
event={event}
|
||||
onClick={this.onEventClick}
|
||||
selected={isSelected}
|
||||
showLoadInfo={showLoadInfo}
|
||||
toggleLoadInfo={this.toggleLoadInfo}
|
||||
isCurrent={isCurrent}
|
||||
presentInSearch={presentInSearch}
|
||||
isLastInGroup={isLastInGroup}
|
||||
whiteBg={true}
|
||||
/>
|
||||
) : isTabChange ? (<TabChange onClick={this.onEventClick} from={event.fromTab} to={event.toTab} activeUrl={event.activeUrl} />) : (
|
||||
<Event
|
||||
key={event.key}
|
||||
event={event}
|
||||
onClick={this.onEventClick}
|
||||
onCheckboxClick={this.onCheckboxClick}
|
||||
selected={isSelected}
|
||||
isCurrent={isCurrent}
|
||||
showSelection={showSelection}
|
||||
overlayed={isEditing}
|
||||
presentInSearch={presentInSearch}
|
||||
isLastInGroup={isLastInGroup}
|
||||
whiteBg={whiteBg}
|
||||
/>
|
||||
)}
|
||||
{returnEvt()}
|
||||
</div>
|
||||
{(isLastInGroup && !isTabChange) && <div className='border-color-gray-light-shade' />}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ interface IProps {
|
|||
notesWithEvents: Session['notesWithEvents'];
|
||||
filterOutNote: (id: string) => void;
|
||||
eventsIndex: number[];
|
||||
utxVideo: string;
|
||||
}
|
||||
|
||||
function EventsBlock(props: IProps) {
|
||||
const { notesStore } = useStore();
|
||||
const { notesStore, uxtestingStore } = useStore();
|
||||
const [mouseOver, setMouseOver] = React.useState(true);
|
||||
const scroller = React.useRef<List>(null);
|
||||
const cache = useCellMeasurerCache({
|
||||
|
|
@ -53,33 +54,36 @@ function EventsBlock(props: IProps) {
|
|||
const filteredLength = filteredEvents?.length || 0;
|
||||
const notesWithEvtsLength = notesWithEvents?.length || 0;
|
||||
const notesLength = notes.length;
|
||||
const eventListNow: any[] = []
|
||||
const eventListNow: any[] = [];
|
||||
if (tabStates !== undefined) {
|
||||
eventListNow.concat(Object.values(tabStates)[0]?.eventListNow || [])
|
||||
eventListNow.concat(Object.values(tabStates)[0]?.eventListNow || []);
|
||||
} else {
|
||||
eventListNow.concat(store.get().eventListNow)
|
||||
eventListNow.concat(store.get().eventListNow);
|
||||
}
|
||||
|
||||
const currentTimeEventIndex = eventListNow.length > 0 ? eventListNow.length - 1 : 0;
|
||||
const usedEvents = React.useMemo(() => {
|
||||
if (tabStates !== undefined) {
|
||||
tabChangeEvents.forEach(ev => {
|
||||
tabChangeEvents.forEach((ev) => {
|
||||
const urlsList = tabStates[ev.tabId].urlsList;
|
||||
let found = false;
|
||||
let i = urlsList.length - 1;
|
||||
while (!found && i >= 0) {
|
||||
const item = urlsList[i]
|
||||
const item = urlsList[i];
|
||||
if (item.url && item.time <= ev.time) {
|
||||
found = true;
|
||||
ev.activeUrl = item.url.replace(/.*\/\/[^\/]*/, '');
|
||||
}
|
||||
i--;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
const eventsWithMobxNotes = [...notesWithEvents, ...notes].sort(sortEvents);
|
||||
return mergeEventLists(filteredLength > 0 ? filteredEvents : eventsWithMobxNotes, tabChangeEvents);
|
||||
}, [filteredLength, notesWithEvtsLength, notesLength])
|
||||
return mergeEventLists(
|
||||
filteredLength > 0 ? filteredEvents : eventsWithMobxNotes,
|
||||
tabChangeEvents
|
||||
);
|
||||
}, [filteredLength, notesWithEvtsLength, notesLength]);
|
||||
|
||||
const write = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.setEventFilter({ query: value });
|
||||
|
|
@ -170,9 +174,16 @@ function EventsBlock(props: IProps) {
|
|||
|
||||
const isEmptySearch = query && (usedEvents.length === 0 || !usedEvents);
|
||||
const eventsText = `${query ? 'Filtered' : ''} ${usedEvents.length} Events`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(styles.header, 'p-4')}>
|
||||
{uxtestingStore.isUxt() ? (
|
||||
<div style={{ width: 240, height: 130 }} className={'relative'}>
|
||||
<video className={'z-20 fixed'} autoPlay controls src={props.utxVideo} width={240} />
|
||||
<div style={{ top: '40%', left: '50%', transform: 'translate(-50%, -50%)' }} className={'absolute z-10'}>No video</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn(styles.hAndProgress, 'mt-3')}>
|
||||
<EventSearch onChange={write} setActiveTab={setActiveTab} value={query} />
|
||||
</div>
|
||||
|
|
@ -218,6 +229,7 @@ export default connect(
|
|||
session: state.getIn(['sessions', 'current']),
|
||||
notesWithEvents: state.getIn(['sessions', 'current']).notesWithEvents,
|
||||
events: state.getIn(['sessions', 'current']).events,
|
||||
utxVideo: state.getIn(['sessions', 'current']).utxVideo,
|
||||
filteredEvents: state.getIn(['sessions', 'filteredEvents']),
|
||||
query: state.getIn(['sessions', 'eventsQuery']),
|
||||
eventsIndex: state.getIn(['sessions', 'eventsIndex']),
|
||||
|
|
|
|||
26
frontend/app/components/Session_/EventsBlock/UtxEvent.tsx
Normal file
26
frontend/app/components/Session_/EventsBlock/UtxEvent.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import { durationFromMsFormatted } from "App/date";
|
||||
|
||||
function UtxEvent({ event }: any) {
|
||||
return (
|
||||
<div className={'flex flex-col'}>
|
||||
<div className={'border border-gray-light rounded bg-teal-light py-2 px-4 m-4 shadow'}>
|
||||
<div className={'w-full flex items-center justify-between'}>
|
||||
<div className={'bg-white rounded border border-gray-light px-2'}>{event.title}</div>
|
||||
<div className={'color-gray-medium'}>{durationFromMsFormatted(event.duration)}</div>
|
||||
</div>
|
||||
{event.description && <div className="font-semibold">{event.description}</div>}
|
||||
</div>
|
||||
{event.comment ? (
|
||||
<div className={'border border-gray-light rounded bg-cyan py-2 px-4 mx-4 mb-4 shadow'}>
|
||||
<div className={'bg-white rounded border border-gray-light px-2 w-fit'}>
|
||||
Participant Response
|
||||
</div>
|
||||
<div>{event.comment}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UtxEvent
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
|
|
@ -61,7 +62,8 @@ function getStorageName(type: any) {
|
|||
|
||||
function Controls(props: any) {
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
|
||||
const { uxtestingStore } = useStore();
|
||||
|
||||
const {
|
||||
playing,
|
||||
completed,
|
||||
|
|
@ -170,12 +172,14 @@ function Controls(props: any) {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center h-full">
|
||||
<DevtoolsButtons
|
||||
showStorageRedux={showStorageRedux}
|
||||
toggleBottomTools={toggleBottomTools}
|
||||
bottomBlock={bottomBlock}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{uxtestingStore.hideDevtools && uxtestingStore.isUxt() ? null :
|
||||
<DevtoolsButtons
|
||||
showStorageRedux={showStorageRedux}
|
||||
toggleBottomTools={toggleBottomTools}
|
||||
bottomBlock={bottomBlock}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
<Tooltip title="Fullscreen" delay={0} placement="top-start" className="mx-4">
|
||||
<FullScreenButton
|
||||
size={16}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React, { useMemo } from 'react';
|
||||
import { Icon, Tooltip, Button } from 'UI';
|
||||
import QueueControls from './QueueControls';
|
||||
|
|
@ -12,157 +13,177 @@ import { PlayerContext } from 'App/components/Session/playerContext';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import AutoplayToggle from 'Shared/AutoplayToggle';
|
||||
import { connect } from 'react-redux';
|
||||
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs'
|
||||
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
|
||||
import { IFRAME } from 'App/constants/storageKeys';
|
||||
import cn from 'classnames';
|
||||
import { Switch } from 'antd';
|
||||
|
||||
const localhostWarn = (project) => project + '_localhost_warn';
|
||||
const disableDevtools = 'or_devtools_utx_toggle';
|
||||
|
||||
function SubHeader(props) {
|
||||
const localhostWarnKey = localhostWarn(props.siteId);
|
||||
const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1';
|
||||
const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn);
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
const { width, height, endTime, location: currentLocation = 'loading...', } = store.get();
|
||||
const hasIframe = localStorage.getItem(IFRAME) === "true";
|
||||
const localhostWarnKey = localhostWarn(props.siteId);
|
||||
const defaultLocalhostWarn = localStorage.getItem(localhostWarnKey) !== '1';
|
||||
const [showWarningModal, setWarning] = React.useState(defaultLocalhostWarn);
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
const { width, height, endTime, location: currentLocation = 'loading...' } = store.get();
|
||||
const hasIframe = localStorage.getItem(IFRAME) === 'true';
|
||||
const { uxtestingStore } = useStore();
|
||||
|
||||
const enabledIntegration = useMemo(() => {
|
||||
const { integrations } = props;
|
||||
if (!integrations || !integrations.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enabledIntegration = useMemo(() => {
|
||||
const { integrations } = props;
|
||||
if (!integrations || !integrations.size) {
|
||||
return false;
|
||||
}
|
||||
return integrations.some((i) => i.token);
|
||||
}, [props.integrations]);
|
||||
|
||||
return integrations.some((i) => i.token);
|
||||
}, [props.integrations]);
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
const { showModal, hideModal } = useModal();
|
||||
const location =
|
||||
currentLocation && currentLocation.length > 70
|
||||
? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}`
|
||||
: currentLocation;
|
||||
|
||||
const location =
|
||||
currentLocation && currentLocation.length > 70
|
||||
? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}`
|
||||
: currentLocation;
|
||||
const showReportModal = () => {
|
||||
const { uxtestingStore } = useStore();
|
||||
const { tabStates, currentTab } = store.get();
|
||||
const resourceList = tabStates[currentTab]?.resourceList || [];
|
||||
const exceptionsList = tabStates[currentTab]?.exceptionsList || [];
|
||||
const eventsList = tabStates[currentTab]?.eventList || [];
|
||||
const graphqlList = tabStates[currentTab]?.graphqlList || [];
|
||||
const fetchList = tabStates[currentTab]?.fetchList || [];
|
||||
|
||||
const showReportModal = () => {
|
||||
const { tabStates, currentTab } = store.get();
|
||||
const resourceList = tabStates[currentTab]?.resourceList || [];
|
||||
const exceptionsList = tabStates[currentTab]?.exceptionsList || [];
|
||||
const eventsList = tabStates[currentTab]?.eventList || [];
|
||||
const graphqlList = tabStates[currentTab]?.graphqlList || [];
|
||||
const fetchList = tabStates[currentTab]?.fetchList || [];
|
||||
const mappedResourceList = resourceList
|
||||
.filter((r) => r.isRed || r.isYellow)
|
||||
.concat(fetchList.filter((i) => parseInt(i.status) >= 400))
|
||||
.concat(graphqlList.filter((i) => parseInt(i.status) >= 400));
|
||||
|
||||
const mappedResourceList = resourceList
|
||||
.filter((r) => r.isRed || r.isYellow)
|
||||
.concat(fetchList.filter((i) => parseInt(i.status) >= 400))
|
||||
.concat(graphqlList.filter((i) => parseInt(i.status) >= 400));
|
||||
|
||||
player.pause();
|
||||
const xrayProps = {
|
||||
currentLocation: currentLocation,
|
||||
resourceList: mappedResourceList,
|
||||
exceptionsList: exceptionsList,
|
||||
eventsList: eventsList,
|
||||
endTime: endTime,
|
||||
};
|
||||
showModal(
|
||||
<BugReportModal width={width} height={height} xrayProps={xrayProps} hideModal={hideModal} />,
|
||||
{ right: true, width: 620 }
|
||||
);
|
||||
player.pause();
|
||||
const xrayProps = {
|
||||
currentLocation: currentLocation,
|
||||
resourceList: mappedResourceList,
|
||||
exceptionsList: exceptionsList,
|
||||
eventsList: eventsList,
|
||||
endTime: endTime,
|
||||
};
|
||||
|
||||
const showWarning =
|
||||
location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal;
|
||||
const closeWarning = () => {
|
||||
localStorage.setItem(localhostWarnKey, '1');
|
||||
setWarning(false);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="w-full px-4 flex items-center border-b relative">
|
||||
{showWarning ? (
|
||||
<div
|
||||
className="px-3 py-1 border border-gray-light drop-shadow-md rounded bg-active-blue flex items-center justify-between"
|
||||
style={{
|
||||
zIndex: 999,
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '-24px',
|
||||
transform: 'translate(-50%, 0)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Some assets may load incorrectly on localhost.
|
||||
<a
|
||||
href="https://docs.openreplay.com/en/troubleshooting/session-recordings/#testing-in-localhost"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link ml-1"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
<div className="py-1 ml-3 cursor-pointer" onClick={closeWarning}>
|
||||
<Icon name="close" size={16} color="black" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SessionTabs />
|
||||
<div
|
||||
className={cn("ml-auto text-sm flex items-center color-gray-medium gap-2", { 'opacity-50 pointer-events-none' : hasIframe })}
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
<Button icon="file-pdf" variant="text" onClick={showReportModal}>
|
||||
Create Bug Report
|
||||
</Button>
|
||||
<NotePopup />
|
||||
{enabledIntegration && <Issues sessionId={props.sessionId} />}
|
||||
<SharePopup
|
||||
entity="sessions"
|
||||
id={props.sessionId}
|
||||
showCopyLink={true}
|
||||
trigger={
|
||||
<div className="relative">
|
||||
<Button icon="share-alt" variant="text" className="relative">
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ItemMenu
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
component: <AutoplayToggle />,
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
component: <Bookmark noMargin sessionId={props.sessionId} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<QueueControls />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{location && (
|
||||
<div className={'w-full bg-white border-b border-gray-light'}>
|
||||
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
|
||||
<Icon size="20" name="event/link" className="mr-1" />
|
||||
<Tooltip title="Open in new tab" delay={0}>
|
||||
<a href={currentLocation} target="_blank">
|
||||
{location}
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
showModal(
|
||||
<BugReportModal width={width} height={height} xrayProps={xrayProps} hideModal={hideModal} />,
|
||||
{ right: true, width: 620 }
|
||||
);
|
||||
};
|
||||
|
||||
const showWarning =
|
||||
location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal;
|
||||
const closeWarning = () => {
|
||||
localStorage.setItem(localhostWarnKey, '1');
|
||||
setWarning(false);
|
||||
};
|
||||
|
||||
const toggleDevtools = (enabled) => {
|
||||
localStorage.setItem(disableDevtools, enabled ? '0' : '1');
|
||||
uxtestingStore.setHideDevtools(!enabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full px-4 flex items-center border-b relative">
|
||||
{showWarning ? (
|
||||
<div
|
||||
className="px-3 py-1 border border-gray-light drop-shadow-md rounded bg-active-blue flex items-center justify-between"
|
||||
style={{
|
||||
zIndex: 999,
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '-24px',
|
||||
transform: 'translate(-50%, 0)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Some assets may load incorrectly on localhost.
|
||||
<a
|
||||
href="https://docs.openreplay.com/en/troubleshooting/session-recordings/#testing-in-localhost"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link ml-1"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
<div className="py-1 ml-3 cursor-pointer" onClick={closeWarning}>
|
||||
<Icon name="close" size={16} color="black" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SessionTabs />
|
||||
<div
|
||||
className={cn('ml-auto text-sm flex items-center color-gray-medium gap-2', {
|
||||
'opacity-50 pointer-events-none': hasIframe,
|
||||
})}
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
<Button icon="file-pdf" variant="text" onClick={showReportModal}>
|
||||
Create Bug Report
|
||||
</Button>
|
||||
<NotePopup />
|
||||
{enabledIntegration && <Issues sessionId={props.sessionId} />}
|
||||
<SharePopup
|
||||
entity="sessions"
|
||||
id={props.sessionId}
|
||||
showCopyLink={true}
|
||||
trigger={
|
||||
<div className="relative">
|
||||
<Button icon="share-alt" variant="text" className="relative">
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ItemMenu
|
||||
items={[
|
||||
{
|
||||
key: 1,
|
||||
component: <AutoplayToggle />,
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
component: <Bookmark noMargin sessionId={props.sessionId} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{uxtestingStore.isUxt() ? (
|
||||
<Switch
|
||||
checkedChildren={'DevTools'}
|
||||
unCheckedChildren={'DevTools'}
|
||||
onChange={toggleDevtools}
|
||||
defaultChecked={!uxtestingStore.hideDevtools}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<QueueControls />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{location && (
|
||||
<div className={'w-full bg-white border-b border-gray-light'}>
|
||||
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
|
||||
<Icon size="20" name="event/link" className="mr-1" />
|
||||
<Tooltip title="Open in new tab" delay={0}>
|
||||
<a href={currentLocation} target="_blank">
|
||||
{location}
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state) => ({
|
||||
siteId: state.getIn(['site', 'siteId']),
|
||||
integrations: state.getIn(['issues', 'list']),
|
||||
modules: state.getIn(['user', 'account', 'modules']) || [],
|
||||
siteId: state.getIn(['site', 'siteId']),
|
||||
integrations: state.getIn(['issues', 'list']),
|
||||
modules: state.getIn(['user', 'account', 'modules']) || [],
|
||||
}))(observer(SubHeader));
|
||||
|
|
|
|||
107
frontend/app/components/UsabilityTesting/ResponsesOverview.tsx
Normal file
107
frontend/app/components/UsabilityTesting/ResponsesOverview.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
import { Step } from 'Components/UsabilityTesting/TestEdit';
|
||||
import { Loader, NoContent, Pagination } from 'UI';
|
||||
import { Button, Typography } from 'antd';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
|
||||
const ResponsesOverview = observer(() => {
|
||||
const { uxtestingStore } = useStore();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [showAll, setShowAll] = React.useState(false);
|
||||
const [taskId, setTaskId] = React.useState(uxtestingStore.instance?.tasks[0].taskId);
|
||||
|
||||
React.useEffect(() => {
|
||||
// @ts-ignore
|
||||
uxtestingStore.fetchResponses(uxtestingStore.instance?.testId, taskId, page);
|
||||
}, [page, taskId]);
|
||||
|
||||
const selectedIndex = uxtestingStore.instance?.tasks.findIndex((task) => task.taskId === taskId)!;
|
||||
const task = uxtestingStore.instance?.tasks.find((task) => task.taskId === taskId);
|
||||
return (
|
||||
<div style={{ width: 900 }} className={'h-screen p-4 bg-white flex flex-col gap-4'}>
|
||||
<Typography.Title style={{ marginBottom: 0 }} level={4}>
|
||||
Open-ended task responses
|
||||
</Typography.Title>
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
<Typography.Text strong>Select Task / Question</Typography.Text>
|
||||
<Step
|
||||
ind={selectedIndex}
|
||||
title={task!.title}
|
||||
description={task!.description}
|
||||
buttons={
|
||||
<div className={'self-center'}>
|
||||
<Button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
icon={<DownOutlined rotate={showAll ? 180 : 0} rev={undefined} />}
|
||||
size={'small'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{showAll
|
||||
? uxtestingStore.instance?.tasks
|
||||
.filter((t) => t.taskId !== taskId && t.allowTyping)
|
||||
.map((task) => (
|
||||
<div className="cursor-pointer" onClick={() => setTaskId(task.taskId)}>
|
||||
<Step
|
||||
ind={uxtestingStore.instance?.tasks.findIndex((t) => t.taskId === task.taskId)!}
|
||||
title={task.title}
|
||||
description={task.description}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<div className={'grid grid-cols-9 border-b'}>
|
||||
<div className={'col-span-1'}>
|
||||
<Typography.Text strong># Response</Typography.Text>
|
||||
</div>
|
||||
<div className={'col-span-2'}>
|
||||
<Typography.Text strong>Participant</Typography.Text>
|
||||
</div>
|
||||
<div className={'col-span-6'}>
|
||||
<Typography.Text strong>Response (add search text)</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Loader loading={uxtestingStore.isLoading}>
|
||||
<NoContent
|
||||
show={!uxtestingStore.responses[taskId!]?.list?.length}
|
||||
title={<div className={'col-span-9'}>No data yet</div>}
|
||||
>
|
||||
<div className={'grid grid-cols-9 border-b'}>
|
||||
{uxtestingStore.responses[taskId!]?.list.map((r, i) => (
|
||||
<>
|
||||
<div className={'col-span-1'}>{i + 10 * (page - 1) + 1}</div>
|
||||
<div className={'col-span-2'}>{r.user_id || 'Anonymous User'}</div>
|
||||
<div className={'col-span-6'}>{r.comment}</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div className={'p-2 flex items-center justify-between'}>
|
||||
<div className={'text-disabled-text'}>
|
||||
Showing <span className="font-medium">{(page - 1) * 10 + 1}</span> to{' '}
|
||||
<span className="font-medium">
|
||||
{(page - 1) * 10 + uxtestingStore.responses[taskId!]?.list.length}
|
||||
</span>{' '}
|
||||
of{' '}
|
||||
<span className="font-medium">
|
||||
{numberWithCommas(uxtestingStore.responses[taskId!]?.total)}
|
||||
</span>{' '}
|
||||
replies.
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(uxtestingStore.responses[taskId!]?.total / 10)}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
/>
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ResponsesOverview;
|
||||
47
frontend/app/components/UsabilityTesting/SidePanel.tsx
Normal file
47
frontend/app/components/UsabilityTesting/SidePanel.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Typography, Switch, Button, Space } from "antd";
|
||||
import { ExportOutlined } from "@ant-design/icons";
|
||||
|
||||
const SidePanel = observer(({ onSave, onPreview }: any) => {
|
||||
const { uxtestingStore } = useStore();
|
||||
return (
|
||||
<div className={'flex flex-col gap-2 col-span-1'}>
|
||||
<div className={'p-4 bg-white rounded border flex flex-col gap-2'}>
|
||||
<Typography.Text strong>Participant Requirements</Typography.Text>
|
||||
<div className={'flex justify-between'}>
|
||||
<Typography.Text>Mic</Typography.Text>
|
||||
<Switch
|
||||
checked={uxtestingStore.instance!.requireMic}
|
||||
defaultChecked={uxtestingStore.instance!.requireMic}
|
||||
onChange={(checked) => uxtestingStore.instance!.setProperty('requireMic', checked)}
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex justify-between'}>
|
||||
<Typography.Text>Camera</Typography.Text>
|
||||
<Switch
|
||||
checked={uxtestingStore.instance!.requireCamera}
|
||||
defaultChecked={uxtestingStore.instance!.requireCamera}
|
||||
onChange={(checked) => uxtestingStore.instance!.setProperty('requireCamera', checked)}
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={onPreview}>
|
||||
<Space align={'center'}>
|
||||
Preview <ExportOutlined rev={undefined} />
|
||||
</Space>
|
||||
</Button>
|
||||
<Button type={'primary'} onClick={onSave}>
|
||||
Publish Test
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default SidePanel
|
||||
63
frontend/app/components/UsabilityTesting/StepsModal.tsx
Normal file
63
frontend/app/components/UsabilityTesting/StepsModal.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { UxTask } from "App/services/UxtestingService";
|
||||
import React from 'react'
|
||||
import { Button, Input, Switch, Typography } from 'antd'
|
||||
|
||||
function StepsModal({ onAdd, onHide }: { onAdd: (step: UxTask) => void; onHide: () => void }) {
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [isAnswerEnabled, setIsAnswerEnabled] = React.useState(false);
|
||||
|
||||
const save = () => {
|
||||
onAdd({
|
||||
title: title,
|
||||
description: description || '',
|
||||
allowTyping: isAnswerEnabled,
|
||||
});
|
||||
onHide();
|
||||
};
|
||||
return (
|
||||
<div className={'h-screen p-4 bg-white flex flex-col gap-4'}>
|
||||
<Typography.Title style={{ marginBottom: 0 }} level={4}>
|
||||
Add a task or question
|
||||
</Typography.Title>
|
||||
<div className={'flex flex-col gap-1 items-start'}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 4 }}>
|
||||
Title/Question
|
||||
</Typography.Title>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={'Task title'}
|
||||
/>
|
||||
<Typography.Title level={5} style={{ marginBottom: 4 }}>
|
||||
Instructions
|
||||
</Typography.Title>
|
||||
<Input.TextArea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={'Task instructions'}
|
||||
/>
|
||||
<Typography.Title level={5} style={{ marginBottom: 4 }}>
|
||||
Allow participants to type an answer
|
||||
</Typography.Title>
|
||||
<Switch
|
||||
checked={isAnswerEnabled}
|
||||
onChange={(checked) => setIsAnswerEnabled(checked)}
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
/>
|
||||
<div className={'text-disabled-text'}>
|
||||
Enabling this option will show a text field for participants to type their answer.
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex gap-2'}>
|
||||
<Button type={'primary'} onClick={save}>
|
||||
Add
|
||||
</Button>
|
||||
<Button onClick={onHide}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepsModal;
|
||||
331
frontend/app/components/UsabilityTesting/TestEdit.tsx
Normal file
331
frontend/app/components/UsabilityTesting/TestEdit.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import {
|
||||
Button,
|
||||
Input,
|
||||
Typography,
|
||||
Dropdown,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import React from 'react';
|
||||
import {
|
||||
withSiteId,
|
||||
usabilityTesting,
|
||||
usabilityTestingView,
|
||||
usabilityTestingEdit,
|
||||
} from 'App/routes';
|
||||
import { useParams, useHistory } from 'react-router-dom';
|
||||
import Breadcrumb from 'Shared/Breadcrumb';
|
||||
import { EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { confirm } from 'UI';
|
||||
import StepsModal from './StepsModal';
|
||||
import SidePanel from './SidePanel';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '1',
|
||||
label: 'Change title/description',
|
||||
icon: <EditOutlined rev={undefined} />,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'Delete',
|
||||
icon: <DeleteOutlined rev={undefined} />,
|
||||
},
|
||||
];
|
||||
|
||||
function TestEdit() {
|
||||
const [newTestTitle, setNewTestTitle] = React.useState('');
|
||||
const [newTestDescription, setNewTestDescription] = React.useState('');
|
||||
const [isModalVisible, setIsModalVisible] = React.useState(false);
|
||||
const { uxtestingStore } = useStore();
|
||||
const [isConclusionEditing, setIsConclusionEditing] = React.useState(false);
|
||||
const [isOverviewEditing, setIsOverviewEditing] = React.useState(false);
|
||||
// @ts-ignore
|
||||
const { siteId, testId } = useParams();
|
||||
const { showModal, hideModal } = useModal();
|
||||
const history = useHistory();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (testId && testId !== 'new') {
|
||||
uxtestingStore.getTestData(testId);
|
||||
}
|
||||
}, []);
|
||||
if (!uxtestingStore.instance) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const onSave = (isPreview?: boolean) => {
|
||||
if (testId && testId !== 'new') {
|
||||
uxtestingStore.updateTest(uxtestingStore.instance!).then((testId) => {
|
||||
history.push(withSiteId(usabilityTestingView(testId!.toString()), siteId));
|
||||
});
|
||||
} else {
|
||||
uxtestingStore.createNewTest(isPreview).then((test) => {
|
||||
console.log(test);
|
||||
if (isPreview) {
|
||||
window.open(`${test.startingPath}?oruxt=${test.testId}`, '_blank', 'noopener,noreferrer');
|
||||
history.push(withSiteId(usabilityTestingEdit(test.testId), siteId));
|
||||
} else {
|
||||
history.push(withSiteId(usabilityTestingView(test.testId), siteId));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = (confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
uxtestingStore.instance!.setProperty('title', newTestTitle);
|
||||
uxtestingStore.instance!.setProperty('description', newTestDescription);
|
||||
}
|
||||
setNewTestDescription('');
|
||||
setNewTestTitle('');
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const onMenuClick = async ({ key }: { key: string }) => {
|
||||
if (key === '1') {
|
||||
setNewTestTitle(uxtestingStore.instance!.title);
|
||||
setNewTestDescription(uxtestingStore.instance!.description);
|
||||
setIsModalVisible(true);
|
||||
}
|
||||
if (key === '2') {
|
||||
if (
|
||||
await confirm({
|
||||
confirmation:
|
||||
'Are you sure you want to delete this usability test? This action cannot be undone.',
|
||||
})
|
||||
) {
|
||||
uxtestingStore.deleteTest(testId).then(() => {
|
||||
history.push(withSiteId(usabilityTesting(), siteId));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
label: 'Usability Testing',
|
||||
to: withSiteId(usabilityTesting(), siteId),
|
||||
},
|
||||
{
|
||||
label: uxtestingStore.instance.title,
|
||||
to: withSiteId(usabilityTestingView(testId), siteId),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title="Edit Test"
|
||||
open={isModalVisible}
|
||||
onOk={() => onClose(true)}
|
||||
onCancel={() => onClose(false)}
|
||||
footer={
|
||||
<Button type={'primary'} onClick={() => onClose(true)}>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography.Text strong>Title</Typography.Text>
|
||||
<Input
|
||||
placeholder="E.g. Checkout user journey evaluation"
|
||||
style={{ marginBottom: '2em' }}
|
||||
value={newTestTitle}
|
||||
onChange={(e) => setNewTestTitle(e.target.value)}
|
||||
/>
|
||||
<Typography.Text strong>Test Objective (optional)</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={newTestDescription}
|
||||
onChange={(e) => setNewTestDescription(e.target.value)}
|
||||
placeholder="Share a brief statement about what you aim to discover through this study."
|
||||
/>
|
||||
</Modal>
|
||||
<div className={'grid grid-cols-4 gap-2'}>
|
||||
<div className={'flex w-full flex-col gap-2 col-span-3'}>
|
||||
<div className={'flex items-start p-4 rounded bg-white border justify-between'}>
|
||||
<div>
|
||||
<Typography.Title level={4}>{uxtestingStore.instance.title}</Typography.Title>
|
||||
<Typography.Text>{uxtestingStore.instance.description}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown menu={{ items: menuItems, onClick: onMenuClick }}>
|
||||
<Button icon={<MoreOutlined rev={undefined} />}></Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'p-4 rounded bg-white border flex flex-col gap-2'}>
|
||||
<Typography.Text strong>Starting point</Typography.Text>
|
||||
<Input
|
||||
style={{ width: 400 }}
|
||||
placeholder={'https://mywebsite.com/example-page'}
|
||||
value={uxtestingStore.instance!.startingPath}
|
||||
onChange={(e) => {
|
||||
uxtestingStore.instance!.setProperty('startingPath', e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Typography.Text>Test will begin on this page</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={'p-4 rounded bg-white border flex flex-col gap-2'}>
|
||||
<Typography.Text strong>Introduction & Guidelines</Typography.Text>
|
||||
<Typography.Text></Typography.Text>
|
||||
{isOverviewEditing ? (
|
||||
<Input.TextArea
|
||||
placeholder={'Task overview'}
|
||||
value={uxtestingStore.instance.guidelines}
|
||||
onChange={(e) => uxtestingStore.instance!.setProperty('guidelines', e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text>
|
||||
{uxtestingStore.instance?.guidelines?.length
|
||||
? uxtestingStore.instance.guidelines
|
||||
: 'Provide an overview of this user test to and input guidelines that can be of assistance to users at any point during the test.'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={'flex gap-2'}>
|
||||
{isOverviewEditing ? (
|
||||
<>
|
||||
<Button type={'primary'} onClick={() => setIsOverviewEditing(false)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
uxtestingStore.instance!.setProperty('guidelines', '');
|
||||
setIsOverviewEditing(false);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setIsOverviewEditing(true)}>Add</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'p-4 rounded bg-white border flex flex-col gap-2'}>
|
||||
<Typography.Text strong>Task List</Typography.Text>
|
||||
{uxtestingStore.instance!.tasks.map((task, index) => (
|
||||
<Step
|
||||
ind={index}
|
||||
title={task.title}
|
||||
description={task.description}
|
||||
buttons={
|
||||
<>
|
||||
<Button size={'small'} icon={<EditOutlined rev={undefined} />} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
uxtestingStore.instance!.setProperty(
|
||||
'tasks',
|
||||
uxtestingStore.instance!.tasks.filter(
|
||||
(t) => t.title !== task.title && t.description !== task.description
|
||||
)
|
||||
);
|
||||
}}
|
||||
size={'small'}
|
||||
icon={<DeleteOutlined rev={undefined} />}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
showModal(
|
||||
<StepsModal
|
||||
onHide={hideModal}
|
||||
onAdd={(task) => {
|
||||
uxtestingStore.instance!.setProperty('tasks', [
|
||||
...uxtestingStore.instance!.tasks,
|
||||
task,
|
||||
]);
|
||||
}}
|
||||
/>,
|
||||
{ right: true }
|
||||
)
|
||||
}
|
||||
>
|
||||
Add a task or question
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'p-4 rounded bg-white border flex flex-col gap-2'}>
|
||||
<Typography.Text strong>Conclusion Message</Typography.Text>
|
||||
<div>
|
||||
{isConclusionEditing ? (
|
||||
<Input.TextArea
|
||||
placeholder={'Thanks for participation!..'}
|
||||
value={uxtestingStore.instance!.conclusionMessage}
|
||||
onChange={(e) =>
|
||||
uxtestingStore.instance!.setProperty('conclusionMessage', e.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text>{uxtestingStore.instance!.conclusionMessage}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex gap-2'}>
|
||||
{isConclusionEditing ? (
|
||||
<>
|
||||
<Button type={'primary'} onClick={() => setIsConclusionEditing(false)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
uxtestingStore.instance!.setProperty('conclusionMessage', '');
|
||||
setIsConclusionEditing(false);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setIsConclusionEditing(true)}>Edit</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SidePanel onSave={() => onSave(false)} onPreview={() => onSave(true)} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Step({
|
||||
buttons,
|
||||
ind,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
buttons?: React.ReactNode;
|
||||
ind: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className={'p-4 rounded border bg-active-blue flex items-start gap-2'}>
|
||||
<div className={'w-6 h-6 bg-white rounded-full border flex items-center justify-center'}>
|
||||
{ind + 1}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text>{title}</Typography.Text>
|
||||
<div className={'text-disabled-text'}>{description}</div>
|
||||
</div>
|
||||
|
||||
<div className={'ml-auto'} />
|
||||
{buttons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TestEdit);
|
||||
394
frontend/app/components/UsabilityTesting/TestOverview.tsx
Normal file
394
frontend/app/components/UsabilityTesting/TestOverview.tsx
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import { durationFormatted } from 'App/date';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
import { getPdf2 } from 'Components/AssistStats/pdfGenerator';
|
||||
import { useModal } from 'Components/Modal';
|
||||
import React from 'react';
|
||||
import { Button, Typography, Select, Space, Popover, Dropdown } from 'antd';
|
||||
import { withSiteId, usabilityTesting, usabilityTestingEdit } from 'App/routes';
|
||||
import { useParams, useHistory } from 'react-router-dom';
|
||||
import Breadcrumb from 'Shared/Breadcrumb';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import {
|
||||
EditOutlined,
|
||||
ShareAltOutlined,
|
||||
ArrowRightOutlined,
|
||||
MoreOutlined,
|
||||
UserOutlined,
|
||||
UserDeleteOutlined,
|
||||
CheckCircleOutlined,
|
||||
FastForwardOutlined,
|
||||
PauseCircleOutlined,
|
||||
StopOutlined,
|
||||
HourglassOutlined,
|
||||
FilePdfOutlined,
|
||||
DeleteOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
import { Loader, NoContent, Pagination } from 'UI';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Stage } from 'Components/Funnels/FunnelWidget/FunnelWidget';
|
||||
import { confirm } from 'UI';
|
||||
import ResponsesOverview from './ResponsesOverview';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const statusItems = [
|
||||
{ value: 'preview', label: 'Preview', icon: <HourglassOutlined rev={undefined} /> },
|
||||
{ value: 'in-progress', label: 'In Progress', icon: <HourglassOutlined rev={undefined} /> },
|
||||
{ value: 'paused', label: 'Pause', icon: <PauseCircleOutlined rev={undefined} /> },
|
||||
{ value: 'closed', label: 'End Testing', icon: <StopOutlined rev={undefined} /> },
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '1',
|
||||
label: 'Download Results',
|
||||
icon: <FilePdfOutlined rev={undefined} />,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: 'Edit',
|
||||
icon: <EditOutlined rev={undefined} />,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: 'Delete',
|
||||
icon: <DeleteOutlined rev={undefined} />,
|
||||
},
|
||||
];
|
||||
|
||||
function TestOverview() {
|
||||
// @ts-ignore
|
||||
const { siteId, testId } = useParams();
|
||||
const { showModal } = useModal();
|
||||
const { uxtestingStore } = useStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
uxtestingStore.getTest(testId);
|
||||
}, [testId]);
|
||||
|
||||
if (!uxtestingStore.instance) {
|
||||
return <Loader loading={uxtestingStore.isLoading}>No data.</Loader>;
|
||||
}
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
uxtestingStore.setSessionsPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
label: 'Usability Testing',
|
||||
to: withSiteId(usabilityTesting(), siteId),
|
||||
},
|
||||
{
|
||||
label: uxtestingStore.instance.title,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className={'rounded border bg-white'} id={'pdf-anchor'}>
|
||||
<Title testId={testId} siteId={siteId} />
|
||||
{uxtestingStore.instance.liveCount ? (
|
||||
<div className={'p-4 flex items-center gap-2'}>
|
||||
<div className={'relative h-4 w-4'}>
|
||||
<div className={'absolute w-4 h-4 animate-ping bg-red rounded-full opacity-75'} />
|
||||
<div className={'absolute w-4 h-4 bg-red rounded-full'} />
|
||||
</div>
|
||||
<Typography.Text>
|
||||
{uxtestingStore.instance.liveCount} participants are engaged in this usability test at
|
||||
the moment.
|
||||
</Typography.Text>
|
||||
<Button>
|
||||
<Space align={'center'}>
|
||||
Moderate Real-Time
|
||||
<ArrowRightOutlined rev={undefined} />
|
||||
</Space>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ParticipantOverview />
|
||||
<TaskSummary />
|
||||
|
||||
|
||||
<div className={'mt-2 rounded border p-4 bg-white flex gap-2 items-center'}>
|
||||
<Typography.Title style={{ marginBottom: 0 }} level={5}>
|
||||
Open-ended task responses
|
||||
</Typography.Title>
|
||||
{uxtestingStore.instance.responsesCount ? (
|
||||
<Button onClick={() => showModal(<ResponsesOverview />, { right: true, width: 900 })}>
|
||||
<Space align={'center'}>
|
||||
Review All {uxtestingStore.instance.responsesCount} Responses
|
||||
<ArrowRightOutlined rev={undefined} />
|
||||
</Space>
|
||||
</Button>
|
||||
) : (
|
||||
<Typography.Text>0 at the moment.</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'mt-2 rounded border p-4 bg-white flex gap-1 items-start flex-col'}>
|
||||
<Typography.Title style={{ marginBottom: 0 }} level={5}>
|
||||
Sessions
|
||||
</Typography.Title>
|
||||
{/*<Typography.Text>in your selection</Typography.Text>*/}
|
||||
{/*<div className={'flex gap-1 link'}>clear selection</div>*/}
|
||||
<div className={'flex flex-col w-full'}>
|
||||
<Loader loading={uxtestingStore.isLoading}>
|
||||
<NoContent show={uxtestingStore.testSessions.list.length == 0} title="No data">
|
||||
{uxtestingStore.testSessions.list.map((session) => (
|
||||
// @ts-ignore
|
||||
<SessionItem session={session} query={'?utx=true'} />
|
||||
))}
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<div>
|
||||
Showing{' '}
|
||||
<span className="font-medium">
|
||||
{(uxtestingStore.testSessions.page - 1) * 10 + 1}
|
||||
</span>{' '}
|
||||
to{' '}
|
||||
<span className="font-medium">
|
||||
{(uxtestingStore.page - 1) * 10 + uxtestingStore.testSessions.list.length}
|
||||
</span>{' '}
|
||||
of{' '}
|
||||
<span className="font-medium">
|
||||
{numberWithCommas(uxtestingStore.testSessions.total)}
|
||||
</span>{' '}
|
||||
tests.
|
||||
</div>
|
||||
<Pagination
|
||||
page={uxtestingStore.testSessions.page}
|
||||
totalPages={Math.ceil(uxtestingStore.testSessions.total / 10)}
|
||||
onPageChange={onPageChange}
|
||||
limit={10}
|
||||
debounceRequest={200}
|
||||
/>
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ParticipantOverview = observer(() => {
|
||||
const { uxtestingStore } = useStore();
|
||||
|
||||
return (
|
||||
<div className={'p-4 rounded border bg-white mt-2'}>
|
||||
<Typography.Title level={5}>Participant Overview</Typography.Title>
|
||||
{uxtestingStore.testStats ? (
|
||||
<div className={'flex gap-2'}>
|
||||
<div className={'rounded border p-2 flex-1'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<UserOutlined style={{ fontSize: 18, color: '#394EFF' }} rev={undefined} />
|
||||
<Typography.Text strong>Total Participants</Typography.Text>
|
||||
</div>
|
||||
<Typography.Title level={5}>
|
||||
{uxtestingStore.testStats.tests_attempts}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<div className={'rounded border p-2 flex-1'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<CheckCircleOutlined style={{ fontSize: 18, color: '#389E0D' }} rev={undefined} />
|
||||
<Typography.Text strong>Completed all tasks</Typography.Text>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
{uxtestingStore.testStats.tests_attempts > 0 ? (
|
||||
<Typography.Title level={5}>
|
||||
{Math.round(
|
||||
(uxtestingStore.testStats.completed_all_tasks /
|
||||
uxtestingStore.testStats.tests_attempts) *
|
||||
100
|
||||
)}
|
||||
%
|
||||
</Typography.Title>
|
||||
) : null}
|
||||
<Typography.Text>{uxtestingStore.testStats.completed_all_tasks}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'rounded border p-2 flex-1'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<FastForwardOutlined style={{ fontSize: 18, color: '#874D00' }} rev={undefined} />
|
||||
<Typography.Text strong>Skipped tasks</Typography.Text>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
{uxtestingStore.testStats.tests_attempts > 0 ? (
|
||||
<Typography.Title level={5}>
|
||||
{Math.round(
|
||||
(uxtestingStore.testStats.tasks_skipped /
|
||||
uxtestingStore.testStats.tests_attempts) *
|
||||
100
|
||||
)}
|
||||
%
|
||||
</Typography.Title>
|
||||
) : null}
|
||||
<Typography.Text>{uxtestingStore.testStats.tasks_skipped}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'rounded border p-2 flex-1'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<UserDeleteOutlined style={{ fontSize: 18, color: '#CC0000' }} rev={undefined} />
|
||||
<Typography.Text strong>Aborted the test</Typography.Text>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
{uxtestingStore.testStats.tests_attempts > 0 ? (
|
||||
<Typography.Title level={5}>
|
||||
{Math.round(
|
||||
(uxtestingStore.testStats.tests_skipped /
|
||||
uxtestingStore.testStats.tests_attempts) *
|
||||
100
|
||||
)}
|
||||
%
|
||||
</Typography.Title>
|
||||
) : null}
|
||||
<Typography.Text>{uxtestingStore.testStats.tests_skipped}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex-1'} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const TaskSummary = observer(() => {
|
||||
const { uxtestingStore } = useStore();
|
||||
return (
|
||||
<div className={'mt-2 rounded border p-4 bg-white'}>
|
||||
<div className={'flex justify-between items-center'}>
|
||||
<Typography.Title level={5}>Task Summary</Typography.Title>
|
||||
|
||||
{uxtestingStore.taskStats.length ? (
|
||||
<div className={'p-2 rounded bg-teal-light flex items-center gap-1'}>
|
||||
<Typography.Text>Average completion time for all tasks:</Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{uxtestingStore.taskStats
|
||||
? durationFormatted(
|
||||
uxtestingStore.taskStats.reduce(
|
||||
(stats, task) => stats + task.avgCompletionTime,
|
||||
0
|
||||
) / uxtestingStore.taskStats.length
|
||||
)
|
||||
: null}
|
||||
</Typography.Text>
|
||||
<ClockCircleOutlined rev={undefined} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!uxtestingStore.taskStats.length ? <NoContent show title={'No data'} /> : null}
|
||||
{uxtestingStore.taskStats.map((tst, index) => (
|
||||
<Stage stage={tst} uxt index={index + 1} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const Title = observer(({ testId, siteId }: any) => {
|
||||
const { uxtestingStore } = useStore();
|
||||
const history = useHistory();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
uxtestingStore.updateTestStatus(value);
|
||||
};
|
||||
|
||||
const onMenuClick = async ({ key }: any) => {
|
||||
if (key === '1') {
|
||||
void getPdf2();
|
||||
}
|
||||
if (key === '2') {
|
||||
await redirectToEdit();
|
||||
}
|
||||
if (key === '3') {
|
||||
if (
|
||||
await confirm({
|
||||
confirmation:
|
||||
'Are you sure you want to delete this usability test? This action cannot be undone.',
|
||||
})
|
||||
) {
|
||||
uxtestingStore.deleteTest(testId).then(() => {
|
||||
history.push(withSiteId(usabilityTesting(), siteId));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const redirectToEdit = async () => {
|
||||
if (
|
||||
await confirm({
|
||||
confirmation:
|
||||
'This test already has responses, making edits at this stage may result in confusing outcomes.',
|
||||
confirmButton: 'Edit',
|
||||
})
|
||||
) {
|
||||
history.push(withSiteId(usabilityTestingEdit(testId), siteId));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'p-4 flex items-center gap-2 border-b'}>
|
||||
<div>
|
||||
<Typography.Title level={4}>{uxtestingStore.instance!.title}</Typography.Title>
|
||||
<div className={'text-disabled-text'}>{uxtestingStore.instance!.description}</div>
|
||||
</div>
|
||||
<div className={'ml-auto'} />
|
||||
<Select
|
||||
value={uxtestingStore.instance!.status}
|
||||
style={{ width: 150 }}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{statusItems.map((item) => (
|
||||
<Option key={item.value} value={item.value} label={item.label}>
|
||||
<Space align={'center'}>
|
||||
{item.icon} {item.label}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button type={'primary'} onClick={redirectToEdit}>
|
||||
<Space align={'center'}>
|
||||
{uxtestingStore.instance!.tasks.length} Tasks <EditOutlined rev={undefined} />{' '}
|
||||
</Space>
|
||||
</Button>
|
||||
<Popover
|
||||
trigger={'click'}
|
||||
title={'Participants Link'}
|
||||
content={
|
||||
<div style={{ width: '220px' }}>
|
||||
<div className={'p-2 bg-white rounded border break-all mb-2'}>
|
||||
{`${uxtestingStore.instance!.startingPath}?oruxt=${
|
||||
uxtestingStore.instance!.testId
|
||||
}`}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
copy(
|
||||
`${uxtestingStore.instance!.startingPath}?oruxt=${
|
||||
uxtestingStore.instance!.testId
|
||||
}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button>
|
||||
<Space align={'center'}>
|
||||
Distribute
|
||||
<ShareAltOutlined rev={undefined} />
|
||||
</Space>
|
||||
</Button>
|
||||
</Popover>
|
||||
<Dropdown menu={{ items: menuItems, onClick: onMenuClick }}>
|
||||
<Button icon={<MoreOutlined rev={undefined} />}></Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
export default observer(TestOverview);
|
||||
205
frontend/app/components/UsabilityTesting/UsabilityTesting.tsx
Normal file
205
frontend/app/components/UsabilityTesting/UsabilityTesting.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { UxTest, UxTListEntry } from "App/services/UxtestingService";
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
import { Button, Input, Typography, Tag, Avatar, Modal, Space } from 'antd';
|
||||
import AnimatedSVG from 'Shared/AnimatedSVG';
|
||||
import { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { Loader, NoContent, Pagination, Link } from 'UI';
|
||||
import { checkForRecent, getDateFromMill } from 'App/date';
|
||||
import { UnorderedListOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { withSiteId, usabilityTestingEdit, usabilityTestingView } from 'App/routes';
|
||||
import { debounce } from 'App/utils';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
let debouncedSearch: any = () => null
|
||||
|
||||
function TestsTable() {
|
||||
const [newTestTitle, setNewTestTitle] = React.useState('');
|
||||
const [newTestDescription, setNewTestDescription] = React.useState('');
|
||||
const [isModalVisible, setIsModalVisible] = React.useState(false);
|
||||
const { uxtestingStore } = useStore();
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
uxtestingStore.setQuery(value);
|
||||
debouncedSearch()
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
uxtestingStore.getList();
|
||||
debouncedSearch = debounce(uxtestingStore.getList, 500)
|
||||
}, []);
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
uxtestingStore.setPage(page);
|
||||
uxtestingStore.getList();
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const { siteId } = useParams();
|
||||
const history = useHistory();
|
||||
|
||||
const onClose = (confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
uxtestingStore.initNewTest(newTestTitle, newTestDescription);
|
||||
setNewTestDescription('');
|
||||
setNewTestTitle('');
|
||||
redirect('new');
|
||||
}
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const redirect = (path: string) => {
|
||||
history.push(withSiteId(usabilityTestingEdit(path), siteId));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="Create Usability Test"
|
||||
open={isModalVisible}
|
||||
onOk={() => onClose(true)}
|
||||
onCancel={() => onClose(false)}
|
||||
footer={
|
||||
<Button type={'primary'} onClick={() => onClose(true)}>
|
||||
<Space align={'center'}>
|
||||
Continue
|
||||
<ArrowRightOutlined rev={undefined} />
|
||||
</Space>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography.Text strong>Name this user test</Typography.Text>
|
||||
<Input
|
||||
placeholder="E.g. Checkout user journey evaluation"
|
||||
style={{ marginBottom: '2em' }}
|
||||
value={newTestTitle}
|
||||
onChange={(e) => setNewTestTitle(e.target.value)}
|
||||
/>
|
||||
<Typography.Text strong>Test Objective (optional)</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={newTestDescription}
|
||||
onChange={(e) => setNewTestDescription(e.target.value)}
|
||||
placeholder="Share a brief statement about what you aim to discover through this study."
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<div className={'rounded bg-white border'}>
|
||||
<div className={'flex items-center p-4 gap-2'}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }}>
|
||||
Usability Tests
|
||||
</Typography.Title>
|
||||
<div className={'ml-auto'} />
|
||||
<Button type="primary" onClick={openModal}>
|
||||
Create Usability Test
|
||||
</Button>
|
||||
<Search
|
||||
placeholder="Filter by title"
|
||||
allowClear
|
||||
classNames={{ input: '!border-0 focus:!border-0' }}
|
||||
onChange={(v) => onSearch(v.target.value)}
|
||||
onSearch={onSearch}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</div>
|
||||
<div className={'bg-gray-lightest grid grid-cols-8 items-center font-semibold p-4'}>
|
||||
<div className="col-span-4">Test Title</div>
|
||||
<div className="col-span-1">Created by</div>
|
||||
<div className="col-span-2">Updated at</div>
|
||||
<div className="col-span-1">Status</div>
|
||||
</div>
|
||||
<div className={'bg-white'}>
|
||||
<Loader loading={uxtestingStore.isLoading} style={{ height: 300 }}>
|
||||
<NoContent
|
||||
show={uxtestingStore.total === 0}
|
||||
title={
|
||||
<div className={'flex flex-col items-center justify-center'}>
|
||||
<AnimatedSVG name={ICONS.NO_FFLAGS} size={285} />
|
||||
<div className="text-center text-gray-600 mt-4">
|
||||
{uxtestingStore.searchQuery === ''
|
||||
? "You haven't created any user tests yet"
|
||||
: 'No matching results'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{uxtestingStore.tests.map((test) => (
|
||||
<Row test={test} />
|
||||
))}
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
<div className={'flex items-center justify-between p-4'}>
|
||||
{uxtestingStore.isLoading || uxtestingStore.tests?.length === 0 ? null : (
|
||||
<>
|
||||
<div>
|
||||
Showing{' '}
|
||||
<span className="font-medium">{(uxtestingStore.page - 1) * PER_PAGE + 1}</span> to{' '}
|
||||
<span className="font-medium">
|
||||
{(uxtestingStore.page - 1) * PER_PAGE + uxtestingStore.tests.length}
|
||||
</span>{' '}
|
||||
of <span className="font-medium">{numberWithCommas(uxtestingStore.total)}</span>{' '}
|
||||
tests.
|
||||
</div>
|
||||
<Pagination
|
||||
page={uxtestingStore.page}
|
||||
totalPages={Math.ceil(uxtestingStore.total / 10)}
|
||||
onPageChange={onPageChange}
|
||||
limit={10}
|
||||
debounceRequest={200}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
preview: "Preview",
|
||||
'in-progress': "In progress",
|
||||
paused: "Paused",
|
||||
completed: "Completed",
|
||||
}
|
||||
|
||||
function Row({ test }: { test: UxTListEntry }) {
|
||||
const link = usabilityTestingView(test.testId.toString())
|
||||
return (
|
||||
<div className={'grid grid-cols-8 p-4 border-b hover:bg-active-blue'}>
|
||||
<Cell size={4}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Avatar size={'large'} icon={<UnorderedListOutlined rev={undefined} />} />
|
||||
<div>
|
||||
<Link className='link' to={link}>
|
||||
{test.title}
|
||||
</Link>
|
||||
<div className={'text-disabled-text'}>
|
||||
{test.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell size={1}>{test.createdBy.name}</Cell>
|
||||
<Cell size={2}>{checkForRecent(getDateFromMill(test.updatedAt)!, 'LLL dd, yyyy, hh:mm a')}</Cell>
|
||||
<Cell size={1}>
|
||||
<Tag color={test.status === 'in-progress' ? "orange" : ''}>{statusMap[test.status]}</Tag>
|
||||
</Cell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ size, children }: { size: number; children?: React.ReactNode }) {
|
||||
return <div className={`col-span-${size}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export default observer(TestsTable);
|
||||
|
|
@ -17,6 +17,7 @@ interface Props {
|
|||
onClick?: () => void;
|
||||
queryParams?: any;
|
||||
newTab?: boolean;
|
||||
query?: string
|
||||
}
|
||||
export default function PlayLink(props: Props) {
|
||||
const { isAssist, viewed, sessionId, onClick = null, queryParams } = props;
|
||||
|
|
@ -30,10 +31,11 @@ export default function PlayLink(props: Props) {
|
|||
else setIconName(getDefaultIconName(viewed));
|
||||
}, [isHovered, viewed]);
|
||||
|
||||
const link = isAssist ? liveSessionRoute(sessionId, queryParams) : sessionRoute(sessionId);
|
||||
return (
|
||||
<Link
|
||||
onClick={onClick ? onClick : () => {}}
|
||||
to={isAssist ? liveSessionRoute(sessionId, queryParams) : sessionRoute(sessionId)}
|
||||
to={link + (props.query ? props.query : '')}
|
||||
onMouseEnter={() => toggleHover(true)}
|
||||
onMouseLeave={() => toggleHover(false)}
|
||||
target={props.newTab ? "_blank" : undefined} rel={props.newTab ? "noopener noreferrer" : undefined}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ interface Props {
|
|||
ignoreAssist?: boolean;
|
||||
bookmarked?: boolean;
|
||||
toggleFavorite?: (sessionId: string) => void;
|
||||
query?: string
|
||||
}
|
||||
|
||||
function SessionItem(props: RouteComponentProps & Props) {
|
||||
|
|
@ -85,7 +86,8 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
onClick = null,
|
||||
compact = false,
|
||||
ignoreAssist = false,
|
||||
bookmarked = false
|
||||
bookmarked = false,
|
||||
query,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
|
|
@ -340,6 +342,7 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
viewed={viewed}
|
||||
onClick={onClick}
|
||||
queryParams={queryParams}
|
||||
query={query}
|
||||
/>
|
||||
{bookmarked && (
|
||||
<div className='ml-2 cursor-pointer'>
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ const SVG = (props: Props) => {
|
|||
case 'envelope': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M464 64H48C21.5 64 0 85.5 0 112v288c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zM48 96h416c8.8 0 16 7.2 16 16v41.4c-21.9 18.5-53.2 44-150.6 121.3-16.9 13.4-50.2 45.7-73.4 45.3-23.2.4-56.6-31.9-73.4-45.3C85.2 197.4 53.9 171.9 32 153.4V112c0-8.8 7.2-16 16-16zm416 320H48c-8.8 0-16-7.2-16-16V195c22.8 18.7 58.8 47.6 130.7 104.7 20.5 16.4 56.7 52.5 93.3 52.3 36.4.3 72.3-35.5 93.3-52.3 71.9-57.1 107.9-86 130.7-104.7v205c0 8.8-7.2 16-16 16z"/></svg>;
|
||||
case 'errors-icon': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/></svg>;
|
||||
case 'event/click': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6.75 1a.75.75 0 0 1 .75.75V8a.5.5 0 0 0 1 0V5.467l.086-.004c.317-.012.637-.008.816.027.134.027.294.096.448.182.077.042.15.147.15.314V8a.5.5 0 1 0 1 0V6.435a4.9 4.9 0 0 1 .106-.01c.316-.024.584-.01.708.04.118.046.3.207.486.43.081.096.15.19.2.259V8.5a.5.5 0 0 0 1 0v-1h.342a1 1 0 0 1 .995 1.1l-.271 2.715a2.5 2.5 0 0 1-.317.991l-1.395 2.442a.5.5 0 0 1-.434.252H6.035a.5.5 0 0 1-.416-.223l-1.433-2.15a1.5 1.5 0 0 1-.243-.666l-.345-3.105a.5.5 0 0 1 .399-.546L5 8.11V9a.5.5 0 0 0 1 0V1.75A.75.75 0 0 1 6.75 1zM8.5 4.466V1.75a1.75 1.75 0 1 0-3.5 0v5.34l-1.2.24a1.5 1.5 0 0 0-1.196 1.636l.345 3.106a2.5 2.5 0 0 0 .405 1.11l1.433 2.15A1.5 1.5 0 0 0 6.035 16h6.385a1.5 1.5 0 0 0 1.302-.756l1.395-2.441a3.5 3.5 0 0 0 .444-1.389l.271-2.715a2 2 0 0 0-1.99-2.199h-.581a5.114 5.114 0 0 0-.195-.248c-.191-.229-.51-.568-.88-.716-.364-.146-.846-.132-1.158-.108l-.132.012a1.26 1.26 0 0 0-.56-.642 2.632 2.632 0 0 0-.738-.288c-.31-.062-.739-.058-1.05-.046l-.048.002zm2.094 2.025z"/></svg>;
|
||||
case 'event/click_hesitation': return <svg viewBox="0 0 33 34" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><path fill="transparent" d="M1 1.5h32v32H1z"/><path fill="transparent" d="M.64 5.683h27.635v27.634H.64z"/><path d="M12.297 7.41a1.295 1.295 0 0 1 1.296 1.296V19.5a.863.863 0 1 0 1.727 0v-4.375l.148-.007c.548-.02 1.1-.014 1.41.047.231.046.507.166.773.314.133.073.26.254.26.543V19.5a.864.864 0 0 0 1.727 0v-2.703a8.64 8.64 0 0 1 .183-.017c.545-.042 1.008-.018 1.222.069.204.08.519.357.84.742.14.166.259.329.345.448v2.324a.863.863 0 1 0 1.727 0v-1.727h.591a1.727 1.727 0 0 1 1.718 1.9l-.468 4.69a4.318 4.318 0 0 1-.547 1.71l-2.41 4.218a.863.863 0 0 1-.749.436H11.063a.864.864 0 0 1-.719-.386L7.87 27.491a2.591 2.591 0 0 1-.42-1.15l-.595-5.363a.864.864 0 0 1 .689-.943l1.732-.345v1.537a.864.864 0 0 0 1.727 0V8.706a1.295 1.295 0 0 1 1.295-1.296Zm3.023 5.986v-4.69a3.022 3.022 0 1 0-6.045 0v9.222l-2.073.415a2.591 2.591 0 0 0-2.065 2.825l.596 5.365c.076.685.316 1.343.7 1.917l2.474 3.713a2.59 2.59 0 0 0 2.155 1.154H22.09a2.591 2.591 0 0 0 2.249-1.306l2.409-4.216a6.046 6.046 0 0 0 .767-2.399l.468-4.689a3.454 3.454 0 0 0-3.437-3.798h-1.003a8.85 8.85 0 0 0-.337-.428c-.33-.396-.881-.981-1.52-1.237-.629-.252-1.461-.228-2-.186l-.228.02a2.176 2.176 0 0 0-.967-1.108 4.55 4.55 0 0 0-1.275-.498c-.535-.107-1.276-.1-1.813-.08l-.083.004Z"/><path fill="transparent" d="M15 .683h14v14H15z"/><path d="m23.717 6.894-.003.004-.524.535c-.218.22-.432.458-.583.777-.103.22-.17.461-.202.743h-.31c.036-.462.24-.881.551-1.197l.721-.733c.297-.291.473-.694.473-1.132 0-.883-.721-1.604-1.604-1.604-.732 0-1.352.495-1.544 1.166h-.302a1.896 1.896 0 0 1 3.741.438c0 .393-.16.75-.414 1.003ZM22.236 1.37a6.273 6.273 0 0 0-6.271 6.271c0 3.462 2.81 6.27 6.27 6.27 3.462 0 6.271-2.808 6.271-6.27 0-3.462-2.809-6.27-6.27-6.27Zm-.146 9.917v-.292h.291v.292h-.291Z" stroke="#fff" strokeWidth=".875"/></svg>;
|
||||
case 'event/click_hesitation': return <svg viewBox="0 0 33 34" width={ `${ width }px` } height={ `${ height }px` } ><path fill="transparent" d="M1 1.5h32v32H1z"/><path fill="transparent" d="M.64 5.683h27.635v27.634H.64z"/><path d="M12.297 7.41a1.295 1.295 0 0 1 1.296 1.296V19.5a.863.863 0 1 0 1.727 0v-4.375l.148-.007c.548-.02 1.1-.014 1.41.047.231.046.507.166.773.314.133.073.26.254.26.543V19.5a.864.864 0 0 0 1.727 0v-2.703a8.64 8.64 0 0 1 .183-.017c.545-.042 1.008-.018 1.222.069.204.08.519.357.84.742.14.166.259.329.345.448v2.324a.863.863 0 1 0 1.727 0v-1.727h.591a1.727 1.727 0 0 1 1.718 1.9l-.468 4.69a4.318 4.318 0 0 1-.547 1.71l-2.41 4.218a.863.863 0 0 1-.749.436H11.063a.864.864 0 0 1-.719-.386L7.87 27.491a2.591 2.591 0 0 1-.42-1.15l-.595-5.363a.864.864 0 0 1 .689-.943l1.732-.345v1.537a.864.864 0 0 0 1.727 0V8.706a1.295 1.295 0 0 1 1.295-1.296Zm3.023 5.986v-4.69a3.022 3.022 0 1 0-6.045 0v9.222l-2.073.415a2.591 2.591 0 0 0-2.065 2.825l.596 5.365c.076.685.316 1.343.7 1.917l2.474 3.713a2.59 2.59 0 0 0 2.155 1.154H22.09a2.591 2.591 0 0 0 2.249-1.306l2.409-4.216a6.046 6.046 0 0 0 .767-2.399l.468-4.689a3.454 3.454 0 0 0-3.437-3.798h-1.003a8.85 8.85 0 0 0-.337-.428c-.33-.396-.881-.981-1.52-1.237-.629-.252-1.461-.228-2-.186l-.228.02a2.176 2.176 0 0 0-.967-1.108 4.55 4.55 0 0 0-1.275-.498c-.535-.107-1.276-.1-1.813-.08l-.083.004Z" fillOpacity=".54"/><path fill="transparent" d="M15 .683h14v14H15z"/><path d="m23.717 6.894-.003.004-.524.535c-.218.22-.432.458-.583.777-.103.22-.17.461-.202.743h-.31c.036-.462.24-.881.551-1.197l.721-.733c.297-.291.473-.694.473-1.132 0-.883-.721-1.604-1.604-1.604-.732 0-1.352.495-1.544 1.166h-.302a1.896 1.896 0 0 1 3.741.438c0 .393-.16.75-.414 1.003ZM22.236 1.37a6.273 6.273 0 0 0-6.271 6.271c0 3.462 2.81 6.27 6.27 6.27 3.462 0 6.271-2.808 6.271-6.27 0-3.462-2.809-6.27-6.27-6.27Zm-.146 9.917v-.292h.291v.292h-.291Z" fillOpacity=".87" stroke="#fff" strokeWidth=".875"/></svg>;
|
||||
case 'event/clickrage': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M4.285 12.433a.5.5 0 0 0 .683-.183A3.498 3.498 0 0 1 8 10.5c1.295 0 2.426.703 3.032 1.75a.5.5 0 0 0 .866-.5A4.498 4.498 0 0 0 8 9.5a4.5 4.5 0 0 0-3.898 2.25.5.5 0 0 0 .183.683zm6.991-8.38a.5.5 0 1 1 .448.894l-1.009.504c.176.27.285.64.285 1.049 0 .828-.448 1.5-1 1.5s-1-.672-1-1.5c0-.247.04-.48.11-.686a.502.502 0 0 1 .166-.761l2-1zm-6.552 0a.5.5 0 0 0-.448.894l1.009.504A1.94 1.94 0 0 0 5 6.5C5 7.328 5.448 8 6 8s1-.672 1-1.5c0-.247-.04-.48-.11-.686a.502.502 0 0 0-.166-.761l-2-1z"/></svg>;
|
||||
case 'event/code': return <svg viewBox="0 0 576 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="m228.5 511.8-25-7.1c-3.2-.9-5-4.2-4.1-7.4L340.1 4.4c.9-3.2 4.2-5 7.4-4.1l25 7.1c3.2.9 5 4.2 4.1 7.4L235.9 507.6c-.9 3.2-4.3 5.1-7.4 4.2zm-75.6-125.3 18.5-20.9c1.9-2.1 1.6-5.3-.5-7.1L49.9 256l121-102.5c2.1-1.8 2.4-5 .5-7.1l-18.5-20.9c-1.8-2.1-5-2.3-7.1-.4L1.7 252.3c-2.3 2-2.3 5.5 0 7.5L145.8 387c2.1 1.8 5.3 1.6 7.1-.5zm277.3.4 144.1-127.2c2.3-2 2.3-5.5 0-7.5L430.2 125.1c-2.1-1.8-5.2-1.6-7.1.4l-18.5 20.9c-1.9 2.1-1.6 5.3.5 7.1l121 102.5-121 102.5c-2.1 1.8-2.4 5-.5 7.1l18.5 20.9c1.8 2.1 5 2.3 7.1.4z"/></svg>;
|
||||
case 'event/i-cursor': return <svg viewBox="0 0 192 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M96 38.223C75.091 13.528 39.824 1.336 6.191.005 2.805-.129 0 2.617 0 6.006v20.013c0 3.191 2.498 5.847 5.686 5.989C46.519 33.825 80 55.127 80 80v160H38a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h42v160c0 24.873-33.481 46.175-74.314 47.992-3.188.141-5.686 2.797-5.686 5.989v20.013c0 3.389 2.806 6.135 6.192 6.002C40.03 510.658 75.193 498.351 96 473.777c20.909 24.695 56.176 36.887 89.809 38.218 3.386.134 6.191-2.612 6.191-6.001v-20.013c0-3.191-2.498-5.847-5.686-5.989C145.481 478.175 112 456.873 112 432V272h42a6 6 0 0 0 6-6v-20a6 6 0 0 0-6-6h-42V80c0-24.873 33.481-46.175 74.314-47.992 3.188-.142 5.686-2.798 5.686-5.989V6.006c0-3.389-2.806-6.135-6.192-6.002C151.97 1.342 116.807 13.648 96 38.223z"/></svg>;
|
||||
|
|
|
|||
|
|
@ -194,8 +194,9 @@ const reducer = (state = initialState, action: IAction) => {
|
|||
crashes,
|
||||
resources,
|
||||
stackEvents,
|
||||
userEvents
|
||||
} = action.data as { errors: any[], crashes: any[], events: any[], issues: any[], resources: any[], stackEvents: any[], userEvents: EventData[] };
|
||||
userEvents,
|
||||
userTesting
|
||||
} = action.data as { errors: any[], crashes: any[], events: any[], issues: any[], resources: any[], stackEvents: any[], userEvents: EventData[], userTesting: any[] };
|
||||
const filterEvents = action.filter.events as Record<string, any>[];
|
||||
const session = state.get('current') as Session;
|
||||
const matching: number[] = [];
|
||||
|
|
@ -234,7 +235,8 @@ const reducer = (state = initialState, action: IAction) => {
|
|||
issues,
|
||||
resources,
|
||||
stackEvents,
|
||||
userEvents
|
||||
userEvents,
|
||||
userTesting
|
||||
);
|
||||
|
||||
const forceUpdate = state.set('current', {})
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ function SideMenu(props: Props) {
|
|||
[MENU.ALERTS]: () => withSiteId(routes.alerts(), siteId),
|
||||
[MENU.FEATURE_FLAGS]: () => withSiteId(routes.fflags(), siteId),
|
||||
[MENU.PREFERENCES]: () => client(CLIENT_DEFAULT_TAB),
|
||||
[MENU.USABILITY_TESTS]: () => withSiteId(routes.usabilityTesting(), siteId),
|
||||
[PREFERENCES_MENU.ACCOUNT]: () => client(CLIENT_TABS.PROFILE),
|
||||
[PREFERENCES_MENU.SESSION_LISTING]: () => client(CLIENT_TABS.SESSIONS_LISTING),
|
||||
[PREFERENCES_MENU.INTEGRATIONS]: () => client(CLIENT_TABS.INTEGRATIONS),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import PreferencesMenu from 'Components/Client/PreferencesMenu';
|
||||
|
||||
export interface MenuItem {
|
||||
label: React.ReactNode;
|
||||
|
|
@ -52,6 +51,7 @@ export const enum MENU {
|
|||
RESOURCE_MONITORING = 'resource-monitoring',
|
||||
ALERTS = 'alerts',
|
||||
FEATURE_FLAGS = 'feature-flags',
|
||||
USABILITY_TESTS = 'usability-tests',
|
||||
PREFERENCES = 'preferences',
|
||||
SUPPORT = 'support',
|
||||
EXIT = 'exit',
|
||||
|
|
@ -95,11 +95,18 @@ export const categories: Category[] = [
|
|||
{ label: 'Alerts', key: MENU.ALERTS, icon: 'bell' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Product Optimization',
|
||||
key: 'product-optimization',
|
||||
items: [
|
||||
{ label: 'Feature Flags', key: MENU.FEATURE_FLAGS, icon: 'toggles' },
|
||||
{ label: 'Usability Tests', key: MENU.USABILITY_TESTS, icon: 'columns-gap' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'other',
|
||||
items: [
|
||||
{ label: 'Feature Flags', key: MENU.FEATURE_FLAGS, icon: 'toggles' },
|
||||
{ label: 'Preferences', key: MENU.PREFERENCES, icon: 'sliders', leading: 'chevron-right' },
|
||||
{ label: 'Support', key: MENU.SUPPORT, icon: 'question-circle' }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import AssistMultiviewStore from './assistMultiviewStore';
|
|||
import WeeklyReportStore from './weeklyReportConfigStore'
|
||||
import AlertStore from './alertsStore'
|
||||
import FeatureFlagsStore from "./featureFlagsStore";
|
||||
import UxtestingStore from './uxtestingStore';
|
||||
|
||||
export class RootStore {
|
||||
dashboardStore: DashboardStore;
|
||||
|
|
@ -39,6 +40,7 @@ export class RootStore {
|
|||
weeklyReportStore: WeeklyReportStore
|
||||
alertsStore: AlertStore
|
||||
featureFlagsStore: FeatureFlagsStore
|
||||
uxtestingStore: UxtestingStore
|
||||
|
||||
constructor() {
|
||||
this.dashboardStore = new DashboardStore();
|
||||
|
|
@ -58,6 +60,7 @@ export class RootStore {
|
|||
this.weeklyReportStore = new WeeklyReportStore();
|
||||
this.alertsStore = new AlertStore();
|
||||
this.featureFlagsStore = new FeatureFlagsStore();
|
||||
this.uxtestingStore = new UxtestingStore();
|
||||
}
|
||||
|
||||
initClient() {
|
||||
|
|
|
|||
284
frontend/app/mstore/uxtestingStore.ts
Normal file
284
frontend/app/mstore/uxtestingStore.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { uxtestingService } from 'App/services';
|
||||
import { UxTask, UxTSearchFilters, UxTListEntry, UxTest } from 'App/services/UxtestingService';
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import Session from 'Types/session';
|
||||
|
||||
interface Stats {
|
||||
completed_all_tasks: number;
|
||||
tasks_completed: number;
|
||||
tasks_skipped: number;
|
||||
tests_attempts: number;
|
||||
tests_skipped: number;
|
||||
}
|
||||
|
||||
interface TaskStats {
|
||||
taskId: number;
|
||||
title: string;
|
||||
completed: number;
|
||||
avgCompletionTime: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface Response {
|
||||
user_id: string | null;
|
||||
status: string;
|
||||
comment: string;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export default class UxtestingStore {
|
||||
client = uxtestingService;
|
||||
tests: UxTListEntry[] = [];
|
||||
instance: UxTestInst | null = null;
|
||||
page: number = 1;
|
||||
total: number = 0;
|
||||
pageSize: number = 10;
|
||||
searchQuery: string = '';
|
||||
testStats: Stats | null = null;
|
||||
testSessions: { list: Session[]; total: number; page: number } = { list: [], total: 0, page: 1 };
|
||||
taskStats: TaskStats[] = [];
|
||||
isLoading: boolean = false;
|
||||
responses: Record<number, { list: Response[]; total: number }> = {};
|
||||
hideDevtools: boolean = localStorage.getItem('or_devtools_utx_toggle') === '1';
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
isUxt() {
|
||||
const queryParams = new URLSearchParams(document.location.search);
|
||||
return queryParams.has('utx');
|
||||
}
|
||||
|
||||
setHideDevtools(hide: boolean) {
|
||||
this.hideDevtools = hide;
|
||||
}
|
||||
|
||||
setLoading(loading: boolean) {
|
||||
this.isLoading = loading;
|
||||
}
|
||||
|
||||
setList(tests: UxTListEntry[]) {
|
||||
this.tests = tests;
|
||||
}
|
||||
|
||||
setTotal(total: number) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
setPage(page: number) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
updateTestStatus = async (status: string) => {
|
||||
if (!this.instance) return;
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const test: UxTest = {
|
||||
...this.instance!,
|
||||
status,
|
||||
};
|
||||
console.log(test);
|
||||
this.updateInstStatus(status);
|
||||
await this.client.updateTest(this.instance.testId!, test);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
updateTest = async (test: UxTestInst) => {
|
||||
if (!this.instance) return;
|
||||
this.setLoading(true);
|
||||
try {
|
||||
await this.client.updateTest(this.instance.testId!, test);
|
||||
return test.testId;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
updateInstStatus = (status: string) => {
|
||||
if (!this.instance) return;
|
||||
this.instance.setProperty('status', status);
|
||||
};
|
||||
|
||||
fetchResponses = async (testId: number, taskId: number, page: number) => {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
this.responses[taskId] = await this.client.fetchTaskResponses(testId, taskId, page, 10);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initNewTest(title: string, description: string) {
|
||||
const initialData = {
|
||||
title: title,
|
||||
startingPath: '',
|
||||
requireMic: false,
|
||||
requireCamera: false,
|
||||
description: description,
|
||||
guidelines: '',
|
||||
conclusionMessage: '',
|
||||
visibility: true,
|
||||
tasks: [],
|
||||
};
|
||||
this.setInstance(new UxTestInst(initialData));
|
||||
}
|
||||
|
||||
deleteTest = async (testId: number) => {
|
||||
return this.client.deleteTest(testId);
|
||||
};
|
||||
|
||||
setInstance(instance: UxTestInst) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
setQuery(query: string) {
|
||||
this.searchQuery = query;
|
||||
}
|
||||
|
||||
getList = async () => {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const filters: Partial<UxTSearchFilters> = {
|
||||
query: this.searchQuery,
|
||||
page: this.page,
|
||||
limit: this.pageSize,
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc',
|
||||
};
|
||||
const { list, total } = await this.client.fetchTestsList(filters);
|
||||
this.setList(list);
|
||||
this.setTotal(total);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
createNewTest = async (isPreview?: boolean) => {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
// @ts-ignore
|
||||
return await this.client.createTest({
|
||||
...this.instance,
|
||||
status: isPreview ? 'preview' : 'in-progress',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getTestData = async (testId: string) => {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const test = await this.client.fetchTest(testId);
|
||||
this.setInstance(new UxTestInst(test));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setTestStats(stats: Stats) {
|
||||
this.testStats = stats;
|
||||
}
|
||||
|
||||
setTaskStats(stats: TaskStats[]) {
|
||||
this.taskStats = stats;
|
||||
}
|
||||
|
||||
setTestSessions(sessions: { list: Session[]; total: number; page: number }) {
|
||||
this.testSessions = sessions;
|
||||
}
|
||||
|
||||
setSessionsPage(page: number) {
|
||||
this.testSessions.page = page;
|
||||
this.client.fetchTestSessions(this.instance!.testId!.toString(), this.testSessions.page, 10)
|
||||
.then((result) => {
|
||||
this.setTestSessions(result)
|
||||
})
|
||||
}
|
||||
|
||||
getTest = async (testId: string) => {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const testPr = this.client.fetchTest(testId);
|
||||
const statsPr = this.client.fetchTestStats(testId);
|
||||
const taskStatsPr = this.client.fetchTestTaskStats(testId);
|
||||
const sessionsPr = this.client.fetchTestSessions(testId, this.testSessions.page, 10);
|
||||
Promise.allSettled([testPr, statsPr, taskStatsPr, sessionsPr]).then((results) => {
|
||||
if (results[0].status === 'fulfilled') {
|
||||
const test = results[0].value;
|
||||
if (test) {
|
||||
this.setInstance(new UxTestInst(test));
|
||||
}
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
const stats = results[1].value;
|
||||
if (stats) {
|
||||
this.setTestStats(stats);
|
||||
}
|
||||
}
|
||||
if (results[2].status === 'fulfilled') {
|
||||
const taskStats = results[2].value;
|
||||
if (taskStats) {
|
||||
this.setTaskStats(taskStats.sort((a: any, b: any) => a.taskId - b.taskId));
|
||||
}
|
||||
}
|
||||
if (results[3].status === 'fulfilled') {
|
||||
const { total, page, sessions } = results[3].value;
|
||||
if (sessions) {
|
||||
const result = {
|
||||
list: sessions.map((s: any) => new Session({ ...s, metadata: {} })),
|
||||
total,
|
||||
page,
|
||||
};
|
||||
this.setTestSessions(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class UxTestInst {
|
||||
title: string = '';
|
||||
requireMic: boolean = false;
|
||||
requireCamera: boolean = false;
|
||||
description: string = '';
|
||||
guidelines: string = '';
|
||||
visibility: boolean = false;
|
||||
tasks: UxTask[] = [];
|
||||
status: string;
|
||||
startingPath: string;
|
||||
testId?: number;
|
||||
responsesCount?: number;
|
||||
liveCount?: number;
|
||||
conclusionMessage: string;
|
||||
|
||||
constructor(initialData: Partial<UxTestInst> = {}) {
|
||||
makeAutoObservable(this);
|
||||
Object.assign(this, initialData);
|
||||
}
|
||||
|
||||
setProperty<T extends keyof UxTestInst>(key: T, value: UxTestInst[T]) {
|
||||
(this[key] as UxTestInst[T]) = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { audioContextManager } from 'App/utils/screenRecorder';
|
||||
|
||||
declare global {
|
||||
interface HTMLCanvasElement {
|
||||
captureStream(frameRate?: number): MediaStream;
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ export const alerts = (): string => '/alerts';
|
|||
export const alertCreate = (): string => '/alert/create';
|
||||
export const alertEdit = (id = ':alertId', hash?: string | number): string => hashed(`/alert/${id}`, hash);
|
||||
|
||||
export const usabilityTesting = () => '/usability-testing';
|
||||
export const usabilityTestingCreate = () => usabilityTesting() + '/create';
|
||||
export const usabilityTestingEdit = (id = ':testId', hash?: string | number): string => hashed(`/usability-testing/edit/${id}`, hash);
|
||||
export const usabilityTestingView = (id = ':testId', hash?: string | number): string => hashed(`/usability-testing/view/${id}`, hash);
|
||||
|
||||
const REQUIRED_SITE_ID_ROUTES = [
|
||||
liveSession(''),
|
||||
session(''),
|
||||
|
|
@ -172,7 +177,12 @@ const REQUIRED_SITE_ID_ROUTES = [
|
|||
funnels(),
|
||||
funnelsCreate(),
|
||||
funnel(''),
|
||||
funnelIssue()
|
||||
funnelIssue(),
|
||||
|
||||
usabilityTesting(),
|
||||
usabilityTestingCreate(),
|
||||
usabilityTestingEdit(''),
|
||||
usabilityTestingView(''),
|
||||
];
|
||||
const routeNeedsSiteId = (path: string): boolean => REQUIRED_SITE_ID_ROUTES.some(r => path.startsWith(r));
|
||||
const siteIdToUrl = (siteId = ':siteId'): string => {
|
||||
|
|
@ -211,7 +221,8 @@ const SITE_CHANGE_AVAILABLE_ROUTES = [
|
|||
metrics(),
|
||||
alerts(),
|
||||
errors(),
|
||||
onboarding('')
|
||||
onboarding(''),
|
||||
usabilityTesting(),
|
||||
];
|
||||
|
||||
export const siteChangeAvailable = (path: string): boolean =>
|
||||
|
|
|
|||
110
frontend/app/services/UxtestingService.ts
Normal file
110
frontend/app/services/UxtestingService.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import BaseService from "./BaseService";
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
export interface UxTSearchFilters {
|
||||
query: Nullable<string>;
|
||||
page: Nullable<number>;
|
||||
limit: Nullable<number>;
|
||||
sortBy: Nullable<string>;
|
||||
sortOrder: Nullable<"asc" | "desc">;
|
||||
isActive: Nullable<boolean>;
|
||||
userId: Nullable<number>;
|
||||
}
|
||||
|
||||
export interface UxTask {
|
||||
title: string;
|
||||
description: Nullable<string>;
|
||||
allowTyping: boolean;
|
||||
taskId?: number;
|
||||
}
|
||||
|
||||
export interface UxTest {
|
||||
title: string;
|
||||
startingPath: string;
|
||||
requireMic: boolean;
|
||||
requireCamera: boolean;
|
||||
description: Nullable<string>;
|
||||
guidelines: Nullable<string>;
|
||||
conclusionMessage: Nullable<string>;
|
||||
visibility: boolean;
|
||||
tasks: UxTask[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
|
||||
export interface UxTListEntry {
|
||||
createdAt: string;
|
||||
status: 'preview' | 'in-progress' | 'paused' | 'completed';
|
||||
createdBy: {
|
||||
userId: number;
|
||||
name: string;
|
||||
};
|
||||
description: string;
|
||||
testId: number;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default class UxtestingService extends BaseService {
|
||||
private readonly prefix = '/usability-tests';
|
||||
|
||||
async fetchTestsList(
|
||||
filters: Partial<UxTSearchFilters>
|
||||
): Promise<{ list: UxTListEntry[]; total: number }> {
|
||||
const r = await this.client.post(this.prefix + '/search', filters);
|
||||
const j = await r.json();
|
||||
return j.data || [];
|
||||
}
|
||||
|
||||
async createTest(test: UxTest) {
|
||||
const r = await this.client.post(this.prefix, test);
|
||||
const j = await r.json();
|
||||
return j.data || [];
|
||||
}
|
||||
|
||||
async deleteTest(id: number) {
|
||||
const r = await this.client.delete(`${this.prefix}/${id}`);
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
updateTest(id: number, test: UxTest) {
|
||||
return this.client
|
||||
.put(`${this.prefix}/${id}`, test)
|
||||
.then((r) => r.json())
|
||||
.then((j) => j.data || []);
|
||||
}
|
||||
|
||||
async fetchTest(id: string) {
|
||||
const r = await this.client.get(`${this.prefix}/${id}`);
|
||||
const j = await r.json();
|
||||
return j.data || [];
|
||||
}
|
||||
|
||||
async fetchTestSessions(id: string, page: number, limit: number) {
|
||||
const r = await this.client.get(`${this.prefix}/${id}/sessions`, { page, limit });
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
async fetchTaskResponses(id: number, task: number, page: number, limit: number) {
|
||||
const r = await this.client.get(`${this.prefix}/${id}/responses/${task}`, {
|
||||
page,
|
||||
limit,
|
||||
// query: 'comment',
|
||||
});
|
||||
const j = await r.json();
|
||||
return j.data || [];
|
||||
}
|
||||
|
||||
async fetchTestStats(id: string) {
|
||||
const r = await this.client.get(`${this.prefix}/${id}/statistics`);
|
||||
const j = await r.json();
|
||||
return j.data || [];
|
||||
}
|
||||
|
||||
async fetchTestTaskStats(id: string) {
|
||||
const r = await this.client.get(`${this.prefix}/${id}/task-statistics`);
|
||||
const j = await r.json();
|
||||
return j.data || [];
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import WebhookService from './WebhookService'
|
|||
import HealthService from "./HealthService";
|
||||
import FFlagsService from "App/services/FFlagsService";
|
||||
import AssistStatsService from './AssistStatsService'
|
||||
import UxtestingService from './UxtestingService'
|
||||
|
||||
export const dashboardService = new DashboardService();
|
||||
export const metricService = new MetricService();
|
||||
|
|
@ -33,6 +34,8 @@ export const fflagsService = new FFlagsService();
|
|||
|
||||
export const assistStatsService = new AssistStatsService();
|
||||
|
||||
export const uxtestingService = new UxtestingService();
|
||||
|
||||
export const services = [
|
||||
dashboardService,
|
||||
metricService,
|
||||
|
|
@ -48,5 +51,6 @@ export const services = [
|
|||
webhookService,
|
||||
healthService,
|
||||
fflagsService,
|
||||
assistStatsService
|
||||
assistStatsService,
|
||||
uxtestingService
|
||||
]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<svg viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 33 34" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<rect width="32" height="32" fill="transparent" transform="translate(1 1.5)"/>
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
|
@ -46,6 +46,7 @@ module.exports = {
|
|||
'transparent': 'transparent',
|
||||
},
|
||||
transparent: 'transparent',
|
||||
cyan: '#EBF4F5',
|
||||
|
||||
// actual theme colors - use this for new components
|
||||
figmaColors: {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ const CUSTOM = 'CUSTOM';
|
|||
const CLICKRAGE = 'CLICKRAGE';
|
||||
const TAPRAGE = 'tap_rage'
|
||||
const IOS_VIEW = 'VIEW';
|
||||
|
||||
const UTX_EVENT = 'UTX_EVENT';
|
||||
const TOUCH = 'TAP';
|
||||
const SWIPE = 'SWIPE';
|
||||
|
||||
export const TYPES = { CONSOLE, CLICK, INPUT, LOCATION, CUSTOM, CLICKRAGE, IOS_VIEW, TOUCH, SWIPE, TAPRAGE };
|
||||
export const TYPES = { CONSOLE, CLICK, INPUT, LOCATION, CUSTOM, CLICKRAGE, IOS_VIEW, TOUCH, SWIPE, TAPRAGE, UTX_EVENT };
|
||||
|
||||
export type EventType =
|
||||
| typeof CONSOLE
|
||||
|
|
@ -201,9 +201,12 @@ export class Location extends Event {
|
|||
}
|
||||
}
|
||||
|
||||
export type InjectedEvent = Console | Click | Input | Location | Touch | Swipe;
|
||||
export type InjectedEvent = Console | Click | Input | Location | Touch | Swipe | UtxEvent;
|
||||
|
||||
export default function (event: EventData) {
|
||||
if ('allow_typing' in event) {
|
||||
return new UtxEvent(event);
|
||||
}
|
||||
if (!event.type) {
|
||||
return console.error('Unknown event type: ', event)
|
||||
}
|
||||
|
|
@ -226,3 +229,31 @@ export default function (event: EventData) {
|
|||
return console.error(`Unknown event type: ${event.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class UtxEvent {
|
||||
readonly name = 'UtxEvent'
|
||||
readonly type = UTX_EVENT;
|
||||
allowTyping: boolean;
|
||||
comment: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
taskId: number;
|
||||
timestamp: number;
|
||||
title: string;
|
||||
|
||||
constructor(event: Record<string, any>) {
|
||||
Object.assign(this, {
|
||||
type: UTX_EVENT,
|
||||
name: 'UtxEvent',
|
||||
allowTyping: event.allow_typing,
|
||||
comment: event.comment,
|
||||
description: event.description,
|
||||
duration: event.duration,
|
||||
status: event.status,
|
||||
taskId: event.taskId,
|
||||
timestamp: event.timestamp,
|
||||
title: event.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ export interface ISession {
|
|||
canvasURL: string[];
|
||||
domURL: string[];
|
||||
devtoolsURL: string[];
|
||||
utxVideo: string[];
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
|
@ -238,6 +239,7 @@ export default class Session {
|
|||
crashes = [],
|
||||
notes = [],
|
||||
canvasURL = [],
|
||||
utxVideo = [],
|
||||
...session
|
||||
} = sessionData;
|
||||
const duration = Duration.fromMillis(session.duration < 1000 ? 1000 : session.duration);
|
||||
|
|
@ -332,6 +334,7 @@ export default class Session {
|
|||
canvasURL,
|
||||
notesWithEvents: mixedEventsWithIssues,
|
||||
frustrations: frustrationList,
|
||||
utxVideo: utxVideo[0],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -342,7 +345,8 @@ export default class Session {
|
|||
issues: any[],
|
||||
resources: any[],
|
||||
userEvents: any[] = [],
|
||||
stackEvents: any[] = []
|
||||
stackEvents: any[] = [],
|
||||
userTestingEvents: any[] = []
|
||||
) {
|
||||
const exceptions = (errors as IError[])?.map((e) => new SessionError(e)) || [];
|
||||
const issuesList =
|
||||
|
|
@ -358,10 +362,12 @@ export default class Session {
|
|||
}
|
||||
|
||||
const events: InjectedEvent[] = [];
|
||||
const utxDoneEvents = userTestingEvents.filter(e => e.status === 'done' && e.title).map(e => ({ ...e, type: 'UTX_EVENT', key: e.signal_id }))
|
||||
const rawEvents: (EventData & { key: number })[] = [];
|
||||
|
||||
if (sessionEvents.length) {
|
||||
sessionEvents.forEach((event, k) => {
|
||||
const eventsWithUtx = mergeEventLists(sessionEvents, utxDoneEvents)
|
||||
eventsWithUtx.forEach((event, k) => {
|
||||
const time = event.timestamp - this.startedAt;
|
||||
if (event.type !== TYPES.CONSOLE && time <= this.durationSeconds) {
|
||||
const EventClass = SessionEvent({ ...event, time, key: k });
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## 6.0.3
|
||||
|
||||
- expose assist version to window as `__OR_ASSIST_VERSION`
|
||||
|
||||
## 6.0.2
|
||||
|
||||
- fix cursor position for remote control
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -21,7 +21,7 @@
|
|||
"replace-paths": "replace-in-files cjs/* --string='@openreplay/tracker' --replacement='@openreplay/tracker/cjs' && replace-in-files cjs/* --string='/lib/' --replacement='/'",
|
||||
"replace-pkg-version": "sh pkgver.sh",
|
||||
"replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='9.0.0'",
|
||||
"prepublishOnly": "bun run build",
|
||||
"prepublishOnly": "bun run test && bun run build",
|
||||
"prepare": "cd ../../ && husky install tracker/.husky/",
|
||||
"lint-front": "lint-staged",
|
||||
"test": "jest --coverage=false",
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ export default class Assist {
|
|||
options?: Partial<Options>,
|
||||
private readonly noSecureMode: boolean = false,
|
||||
) {
|
||||
// @ts-ignore
|
||||
window.__OR_ASSIST_VERSION = this.version
|
||||
this.options = Object.assign({
|
||||
session_calling_peer_key: '__openreplay_calling_peer',
|
||||
session_control_peer_key: '__openreplay_control_peer',
|
||||
|
|
@ -545,7 +547,6 @@ export default class Assist {
|
|||
})
|
||||
|
||||
call.answer(lStreams[call.peer].stream)
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
initiateCallEnd()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "10.0.3-43",
|
||||
"version": "10.0.3-55",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"test": "jest --coverage=false",
|
||||
"test:ci": "jest --coverage=true",
|
||||
"postversion": "bun run build",
|
||||
"prepublishOnly": "bun run build"
|
||||
"prepublishOnly": "bun run test && bun run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.10.2",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type { Options as LoggerOptions } from './logger.js'
|
|||
import type { Options as SessOptions } from './session.js'
|
||||
import type { Options as NetworkOptions } from '../modules/network.js'
|
||||
import CanvasRecorder from './canvas.js'
|
||||
import UserTestManager from '../modules/userTesting/index.js'
|
||||
|
||||
import type {
|
||||
Options as WebworkerOptions,
|
||||
|
|
@ -149,6 +150,7 @@ export default class App {
|
|||
canvasQuality: 'medium',
|
||||
canvasFPS: 1,
|
||||
}
|
||||
private uxtManager: UserTestManager
|
||||
|
||||
constructor(projectKey: string, sessionToken: string | undefined, options: Partial<Options>) {
|
||||
// if (options.onStart !== undefined) {
|
||||
|
|
@ -308,6 +310,8 @@ export default class App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.uxtManager = new UserTestManager(this)
|
||||
}
|
||||
|
||||
private _debug(context: string, e: any) {
|
||||
|
|
@ -707,6 +711,15 @@ export default class App {
|
|||
this.options.onStart(onStartInfo)
|
||||
}
|
||||
this.restartAttempts = 0
|
||||
|
||||
if (location?.search) {
|
||||
const query = new URLSearchParams(location.search)
|
||||
if (query.has('oruxt')) {
|
||||
const testId = query.get('oruxt')
|
||||
if (testId) this.uxtManager.getTest(parseInt(testId, 10), token)
|
||||
}
|
||||
}
|
||||
|
||||
return SuccessfulStart(onStartInfo)
|
||||
})
|
||||
.catch((reason) => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import Network from './modules/network.js'
|
|||
import ConstructedStyleSheets from './modules/constructedStyleSheets.js'
|
||||
import Selection from './modules/selection.js'
|
||||
import Tabs from './modules/tabs.js'
|
||||
|
||||
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js'
|
||||
import FeatureFlags, { IFeatureFlag } from './modules/featureFlags.js'
|
||||
import type { Options as AppOptions } from './app/index.js'
|
||||
|
|
@ -95,6 +96,7 @@ function processOptions(obj: any): obj is Options {
|
|||
|
||||
export default class API {
|
||||
public featureFlags: FeatureFlags
|
||||
|
||||
private readonly app: App | null = null
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
|
|
|
|||
56
tracker/tracker/src/main/modules/userTesting/dnd.ts
Normal file
56
tracker/tracker/src/main/modules/userTesting/dnd.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// @ts-nocheck
|
||||
|
||||
export default function attachDND(element, dragTarget) {
|
||||
dragTarget.onmousedown = function (event) {
|
||||
const clientRect = element.getBoundingClientRect()
|
||||
const shiftX = event.clientX - clientRect.left
|
||||
const shiftY = event.clientY - clientRect.top
|
||||
|
||||
element.style.position = 'fixed'
|
||||
element.style.zIndex = 99999999999999
|
||||
|
||||
moveAt(event.pageX, event.pageY)
|
||||
|
||||
function moveAt(pageX, pageY) {
|
||||
let leftC = pageX - shiftX
|
||||
let topC = pageY - shiftY
|
||||
|
||||
if (leftC <= 5) leftC = 5
|
||||
if (topC <= 5) topC = 5
|
||||
if (leftC >= window.innerWidth - clientRect.width)
|
||||
leftC = window.innerWidth - clientRect.width
|
||||
if (topC >= window.innerHeight - clientRect.height)
|
||||
topC = window.innerHeight - clientRect.height
|
||||
|
||||
element.style.left = `${leftC}px`
|
||||
element.style.top = `${topC}px`
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
moveAt(event.pageX, event.pageY)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
|
||||
dragTarget.onmouseup = function () {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
dragTarget.onmouseup = null
|
||||
}
|
||||
|
||||
// dragTarget.onmouseleave = function () {
|
||||
// document.removeEventListener('mousemove', onMouseMove)
|
||||
// dragTarget.onmouseleave = null
|
||||
// }
|
||||
|
||||
const onMouseOut = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
window?.removeEventListener('mouseout', onMouseOut)
|
||||
}
|
||||
|
||||
window?.addEventListener('mouseout', onMouseOut)
|
||||
}
|
||||
|
||||
dragTarget.ondragstart = function () {
|
||||
return false
|
||||
}
|
||||
}
|
||||
560
tracker/tracker/src/main/modules/userTesting/index.ts
Normal file
560
tracker/tracker/src/main/modules/userTesting/index.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
import App from '../../app/index.js'
|
||||
import * as styles from './styles.js'
|
||||
import Recorder, { Quality } from './recorder.js'
|
||||
import attachDND from './dnd.js'
|
||||
|
||||
function createElement(
|
||||
tag: string,
|
||||
className: string,
|
||||
styles: any,
|
||||
textContent?: string,
|
||||
id?: string,
|
||||
) {
|
||||
const element = document.createElement(tag)
|
||||
element.className = className
|
||||
Object.assign(element.style, styles)
|
||||
if (textContent) {
|
||||
element.textContent = textContent
|
||||
}
|
||||
if (id) {
|
||||
element.id = id
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
interface Test {
|
||||
title: string
|
||||
description: string
|
||||
startingPath: string
|
||||
status: string
|
||||
reqMic: boolean
|
||||
reqCamera: boolean
|
||||
guidelines: string
|
||||
conclusion: string
|
||||
tasks: {
|
||||
task_id: number
|
||||
title: string
|
||||
description: string
|
||||
allow_typing: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export default class UserTestManager {
|
||||
private readonly userRecorder: Recorder
|
||||
private readonly bg = createElement('div', 'bg', styles.bgStyle, undefined, '__or_ut_bg')
|
||||
private readonly container = createElement(
|
||||
'div',
|
||||
'container',
|
||||
styles.containerStyle,
|
||||
undefined,
|
||||
'__or_ut_ct',
|
||||
)
|
||||
private widgetGuidelinesVisible = true
|
||||
private widgetTasksVisible = false
|
||||
private widgetVisible = true
|
||||
private descriptionSection: HTMLElement | null = null
|
||||
private taskSection: HTMLElement | null = null
|
||||
private endSection: HTMLElement | null = null
|
||||
private stopButton: HTMLElement | null = null
|
||||
private test: Test | null = null
|
||||
private testId: number | null = null
|
||||
private token: string | null = null
|
||||
private readonly durations = {
|
||||
testStart: 0,
|
||||
tasks: [] as unknown as {
|
||||
taskId: number
|
||||
started: number
|
||||
}[],
|
||||
}
|
||||
|
||||
constructor(private readonly app: App) {
|
||||
this.userRecorder = new Recorder(app)
|
||||
const taskIndex = this.app.sessionStorage.getItem('or_uxt_task_index')
|
||||
if (taskIndex) {
|
||||
this.currentTaskIndex = parseInt(taskIndex, 10)
|
||||
}
|
||||
}
|
||||
|
||||
signalTask = (taskId: number, status: 'begin' | 'done' | 'skipped', answer?: string) => {
|
||||
if (!taskId) return console.error('OR: no task id')
|
||||
const taskStart = this.durations.tasks.find((t) => t.taskId === taskId)
|
||||
const timestamp = this.app.timestamp()
|
||||
const duration = taskStart ? timestamp - taskStart.started : 0
|
||||
const ingest = this.app.options.ingestPoint
|
||||
return fetch(`${ingest}/v1/web/uxt/signals/task`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
testId: this.testId,
|
||||
taskId,
|
||||
status,
|
||||
duration,
|
||||
timestamp,
|
||||
answer,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
signalTest = (status: 'begin' | 'done' | 'skipped') => {
|
||||
const ingest = this.app.options.ingestPoint
|
||||
const timestamp = this.app.timestamp()
|
||||
const duration = timestamp - this.durations.testStart
|
||||
|
||||
return fetch(`${ingest}/v1/web/uxt/signals/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
testId: this.testId,
|
||||
status,
|
||||
duration,
|
||||
timestamp,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
getTest = (id: number, token: string) => {
|
||||
this.testId = id
|
||||
this.token = token
|
||||
const ingest = this.app.options.ingestPoint
|
||||
fetch(`${ingest}/v1/web/uxt/test/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(({ test }: { test: Test }) => {
|
||||
this.test = test
|
||||
this.createGreeting(test.title, test.reqMic, test.reqCamera)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('OR: Error fetching test', err)
|
||||
})
|
||||
}
|
||||
|
||||
hideTaskSection = () => false
|
||||
showTaskSection = () => true
|
||||
collapseWidget = () => false
|
||||
|
||||
createGreeting(title: string, micRequired: boolean, cameraRequired: boolean) {
|
||||
const titleElement = createElement('div', 'title', styles.titleStyle, title)
|
||||
const descriptionElement = createElement(
|
||||
'div',
|
||||
'description',
|
||||
styles.descriptionStyle,
|
||||
'Welcome, this session will be recorded. You have complete control, and can stop the session at any time.',
|
||||
)
|
||||
const noticeElement = createElement(
|
||||
'div',
|
||||
'notice',
|
||||
styles.noticeStyle,
|
||||
`Please note that your ${micRequired ? 'audio,' : ''} ${cameraRequired ? 'video,' : ''} ${
|
||||
micRequired || cameraRequired ? 'and' : ''
|
||||
} screen will be recorded for research purposes during this test.`,
|
||||
)
|
||||
const buttonElement = createElement(
|
||||
'div',
|
||||
'button',
|
||||
styles.buttonStyle,
|
||||
'Read guidelines to begin',
|
||||
)
|
||||
|
||||
buttonElement.onclick = () => {
|
||||
this.container.innerHTML = ''
|
||||
if (micRequired || cameraRequired) {
|
||||
void this.userRecorder.startRecording(30, Quality.Standard, micRequired, cameraRequired)
|
||||
}
|
||||
this.durations.testStart = this.app.timestamp()
|
||||
void this.signalTest('begin')
|
||||
this.showWidget(this.test?.description || '', this.test?.tasks || [])
|
||||
this.container.removeChild(buttonElement)
|
||||
this.container.removeChild(noticeElement)
|
||||
this.container.removeChild(descriptionElement)
|
||||
this.container.removeChild(titleElement)
|
||||
}
|
||||
|
||||
this.container.append(titleElement, descriptionElement, noticeElement, buttonElement)
|
||||
this.bg.appendChild(this.container)
|
||||
document.body.appendChild(this.bg)
|
||||
}
|
||||
|
||||
showWidget(
|
||||
description: string,
|
||||
tasks: {
|
||||
title: string
|
||||
description: string
|
||||
task_id: number
|
||||
allow_typing: boolean
|
||||
}[],
|
||||
) {
|
||||
this.container.innerHTML = ''
|
||||
Object.assign(this.bg.style, {
|
||||
position: 'fixed',
|
||||
zIndex: 99999999999999,
|
||||
right: '8px',
|
||||
left: 'unset',
|
||||
width: 'fit-content',
|
||||
top: '8px',
|
||||
height: 'fit-content',
|
||||
background: 'unset',
|
||||
display: 'unset',
|
||||
alignItems: 'unset',
|
||||
justifyContent: 'unset',
|
||||
})
|
||||
// Create title section
|
||||
const titleSection = this.createTitleSection()
|
||||
Object.assign(this.container.style, styles.containerWidgetStyle)
|
||||
const descriptionSection = this.createDescriptionSection(description)
|
||||
const tasksSection = this.createTasksSection(tasks)
|
||||
const stopButton = createElement('div', 'stop_bn_or', styles.stopWidgetStyle, 'Abort Session')
|
||||
|
||||
this.container.append(titleSection, descriptionSection, tasksSection, stopButton)
|
||||
this.taskSection = tasksSection
|
||||
this.descriptionSection = descriptionSection
|
||||
this.stopButton = stopButton
|
||||
stopButton.onclick = () => {
|
||||
this.userRecorder.discard()
|
||||
void this.signalTest('skipped')
|
||||
document.body.removeChild(this.bg)
|
||||
}
|
||||
this.hideTaskSection()
|
||||
}
|
||||
|
||||
createTitleSection() {
|
||||
const title = createElement('div', 'title', styles.titleWidgetStyle)
|
||||
const leftIcon = generateGrid()
|
||||
const titleText = createElement('div', 'title_text', {}, this.test?.title)
|
||||
const rightIcon = generateChevron()
|
||||
|
||||
title.append(leftIcon, titleText, rightIcon)
|
||||
|
||||
const toggleWidget = (isVisible: boolean) => {
|
||||
this.widgetVisible = isVisible
|
||||
Object.assign(
|
||||
this.container.style,
|
||||
this.widgetVisible
|
||||
? styles.containerWidgetStyle
|
||||
: { border: 'none', background: 'none', padding: 0 },
|
||||
)
|
||||
if (this.taskSection) {
|
||||
Object.assign(
|
||||
this.taskSection.style,
|
||||
this.widgetVisible ? styles.descriptionWidgetStyle : { display: 'none' },
|
||||
)
|
||||
}
|
||||
if (this.descriptionSection) {
|
||||
Object.assign(
|
||||
this.descriptionSection.style,
|
||||
this.widgetVisible ? styles.descriptionWidgetStyle : { display: 'none' },
|
||||
)
|
||||
}
|
||||
if (this.endSection) {
|
||||
Object.assign(
|
||||
this.endSection.style,
|
||||
this.widgetVisible ? styles.descriptionWidgetStyle : { display: 'none' },
|
||||
)
|
||||
}
|
||||
if (this.stopButton) {
|
||||
Object.assign(
|
||||
this.stopButton.style,
|
||||
this.widgetVisible ? styles.stopWidgetStyle : { display: 'none' },
|
||||
)
|
||||
}
|
||||
return isVisible
|
||||
}
|
||||
|
||||
rightIcon.onclick = () => {
|
||||
Object.assign(rightIcon.style, {
|
||||
transform: this.widgetVisible ? 'rotate(0deg)' : 'rotate(180deg)',
|
||||
})
|
||||
toggleWidget(!this.widgetVisible)
|
||||
}
|
||||
attachDND(this.bg, leftIcon)
|
||||
|
||||
this.collapseWidget = () => toggleWidget(false)
|
||||
return title
|
||||
}
|
||||
|
||||
createDescriptionSection(description: string) {
|
||||
const section = createElement('div', 'description_section_or', styles.descriptionWidgetStyle)
|
||||
const titleContainer = createElement('div', 'description_s_title_or', styles.sectionTitleStyle)
|
||||
const title = createElement('div', 'title', {}, 'Introduction & Guidelines')
|
||||
const icon = createElement('div', 'icon', styles.symbolIcon, '-')
|
||||
const content = createElement('div', 'content', styles.contentStyle)
|
||||
const ul = document.createElement('ul')
|
||||
ul.innerHTML = description
|
||||
const button = createElement('div', 'button_begin_or', styles.buttonWidgetStyle, 'Begin Test')
|
||||
|
||||
titleContainer.append(title, icon)
|
||||
content.append(ul, button)
|
||||
section.append(titleContainer, content)
|
||||
|
||||
const toggleDescriptionVisibility = () => {
|
||||
this.widgetGuidelinesVisible = !this.widgetGuidelinesVisible
|
||||
icon.textContent = this.widgetGuidelinesVisible ? '-' : '+'
|
||||
Object.assign(
|
||||
content.style,
|
||||
this.widgetGuidelinesVisible ? styles.contentStyle : { display: 'none' },
|
||||
)
|
||||
}
|
||||
|
||||
titleContainer.onclick = toggleDescriptionVisibility
|
||||
button.onclick = () => {
|
||||
toggleDescriptionVisibility()
|
||||
if (this.test) {
|
||||
if (
|
||||
this.durations.tasks.findIndex(
|
||||
(t) => this.test && t.taskId === this.test.tasks[0].task_id,
|
||||
) === -1
|
||||
) {
|
||||
this.durations.tasks.push({
|
||||
taskId: this.test.tasks[0].task_id,
|
||||
started: this.app.timestamp(),
|
||||
})
|
||||
}
|
||||
void this.signalTask(this.test.tasks[0].task_id, 'begin')
|
||||
}
|
||||
this.showTaskSection()
|
||||
content.removeChild(button)
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
currentTaskIndex = 0
|
||||
|
||||
createTasksSection(
|
||||
tasks: {
|
||||
title: string
|
||||
description: string
|
||||
task_id: number
|
||||
allow_typing: boolean
|
||||
}[],
|
||||
) {
|
||||
const section = createElement('div', 'task_section_or', styles.descriptionWidgetStyle)
|
||||
const titleContainer = createElement('div', 'description_t_title_or', styles.sectionTitleStyle)
|
||||
const title = createElement('div', 'title', {}, 'Tasks')
|
||||
const icon = createElement('div', 'icon', styles.symbolIcon, '-')
|
||||
const content = createElement('div', 'content', styles.contentStyle)
|
||||
const pagination = createElement('div', 'pagination', styles.paginationStyle)
|
||||
const leftArrow = createElement('span', 'leftArrow', {}, '<')
|
||||
const rightArrow = createElement('span', 'rightArrow', {}, '>')
|
||||
const taskCard = createElement('div', 'taskCard', styles.taskDescriptionCard)
|
||||
const taskText = createElement('div', 'taskText', styles.taskTextStyle)
|
||||
const taskDescription = createElement('div', 'taskDescription', styles.taskDescriptionStyle)
|
||||
const taskButtons = createElement('div', 'taskButtons', styles.taskButtonsRow)
|
||||
const inputTitle = createElement('div', 'taskText', styles.taskTextStyle)
|
||||
inputTitle.textContent = 'Your answer'
|
||||
const inputArea = createElement('textarea', 'taskDescription', {
|
||||
resize: 'vertical',
|
||||
}) as HTMLTextAreaElement
|
||||
const inputContainer = createElement('div', 'inputArea', styles.taskDescriptionCard)
|
||||
inputContainer.append(inputTitle, inputArea)
|
||||
const closePanelButton = createElement(
|
||||
'div',
|
||||
'closePanelButton',
|
||||
styles.taskButtonStyle,
|
||||
'Collapse panel',
|
||||
)
|
||||
const nextButton = createElement(
|
||||
'div',
|
||||
'nextButton',
|
||||
styles.taskButtonBorderedStyle,
|
||||
'Done, next',
|
||||
)
|
||||
|
||||
titleContainer.append(title, icon)
|
||||
taskCard.append(taskText, taskDescription)
|
||||
taskButtons.append(closePanelButton, nextButton)
|
||||
content.append(pagination, taskCard, inputContainer, taskButtons)
|
||||
section.append(titleContainer, content)
|
||||
|
||||
const updateTaskContent = () => {
|
||||
const task = tasks[this.currentTaskIndex]
|
||||
taskText.textContent = task.title
|
||||
taskDescription.textContent = task.description
|
||||
if (task.allow_typing) {
|
||||
inputContainer.style.display = 'flex'
|
||||
} else {
|
||||
inputContainer.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
pagination.appendChild(leftArrow)
|
||||
tasks.forEach((_, index) => {
|
||||
const pageNumber = createElement('span', `or_task_${index}`, {}, (index + 1).toString())
|
||||
pageNumber.id = `or_task_${index}`
|
||||
pagination.append(pageNumber)
|
||||
})
|
||||
pagination.appendChild(rightArrow)
|
||||
|
||||
const toggleTasksVisibility = () => {
|
||||
this.widgetTasksVisible = !this.widgetTasksVisible
|
||||
icon.textContent = this.widgetTasksVisible ? '-' : '+'
|
||||
Object.assign(
|
||||
content.style,
|
||||
this.widgetTasksVisible ? styles.contentStyle : { display: 'none' },
|
||||
)
|
||||
}
|
||||
this.hideTaskSection = () => {
|
||||
icon.textContent = '+'
|
||||
Object.assign(content.style, {
|
||||
display: 'none',
|
||||
})
|
||||
this.widgetTasksVisible = false
|
||||
return false
|
||||
}
|
||||
this.showTaskSection = () => {
|
||||
icon.textContent = '-'
|
||||
Object.assign(content.style, styles.contentStyle)
|
||||
this.widgetTasksVisible = true
|
||||
return true
|
||||
}
|
||||
|
||||
titleContainer.onclick = toggleTasksVisibility
|
||||
closePanelButton.onclick = this.collapseWidget
|
||||
|
||||
nextButton.onclick = () => {
|
||||
const textAnswer = tasks[this.currentTaskIndex].allow_typing ? inputArea.value : undefined
|
||||
inputArea.value = ''
|
||||
void this.signalTask(tasks[this.currentTaskIndex].task_id, 'done', textAnswer)
|
||||
if (this.currentTaskIndex < tasks.length - 1) {
|
||||
this.currentTaskIndex++
|
||||
updateTaskContent()
|
||||
if (
|
||||
this.durations.tasks.findIndex(
|
||||
(t) => t.taskId === tasks[this.currentTaskIndex].task_id,
|
||||
) === -1
|
||||
) {
|
||||
this.durations.tasks.push({
|
||||
taskId: tasks[this.currentTaskIndex].task_id,
|
||||
started: this.app.timestamp(),
|
||||
})
|
||||
}
|
||||
void this.signalTask(tasks[this.currentTaskIndex].task_id, 'begin')
|
||||
const activeTaskEl = document.getElementById(`or_task_${this.currentTaskIndex}`)
|
||||
if (activeTaskEl) {
|
||||
Object.assign(activeTaskEl.style, styles.taskNumberActive)
|
||||
}
|
||||
for (let i = 0; i < this.currentTaskIndex; i++) {
|
||||
const taskEl = document.getElementById(`or_task_${i}`)
|
||||
if (taskEl) {
|
||||
Object.assign(taskEl.style, styles.taskNumberDone)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.showEndSection()
|
||||
}
|
||||
this.app.sessionStorage.setItem('or_uxt_task_index', this.currentTaskIndex.toString())
|
||||
}
|
||||
|
||||
updateTaskContent()
|
||||
setTimeout(() => {
|
||||
const firstTaskEl = document.getElementById('or_task_0')
|
||||
console.log(firstTaskEl, styles.taskNumberActive)
|
||||
if (firstTaskEl) {
|
||||
Object.assign(firstTaskEl.style, styles.taskNumberActive)
|
||||
}
|
||||
}, 1)
|
||||
return section
|
||||
}
|
||||
|
||||
showEndSection() {
|
||||
void this.signalTest('done')
|
||||
const section = createElement('div', 'end_section_or', styles.endSectionStyle)
|
||||
const title = createElement(
|
||||
'div',
|
||||
'end_title_or',
|
||||
{
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '500',
|
||||
},
|
||||
this.test?.reqMic || this.test?.reqCamera ? 'Uploading test recording...' : 'Thank you! 👍',
|
||||
)
|
||||
const description = createElement(
|
||||
'div',
|
||||
'end_description_or',
|
||||
{},
|
||||
this.test?.conclusion ??
|
||||
'Thank you for participating in our user test. Your feedback has been captured and will be used to enhance our website. \n' +
|
||||
'\n' +
|
||||
'We appreciate your time and valuable input.',
|
||||
)
|
||||
if (this.test?.reqMic || this.test?.reqCamera) {
|
||||
this.userRecorder.sendToAPI().then(() => {
|
||||
title.textContent = 'Thank you! 👍'
|
||||
})
|
||||
}
|
||||
const button = createElement('div', 'end_button_or', styles.buttonWidgetStyle, 'End Session')
|
||||
|
||||
if (this.taskSection) {
|
||||
this.container.removeChild(this.taskSection)
|
||||
}
|
||||
if (this.descriptionSection) {
|
||||
this.container.removeChild(this.descriptionSection)
|
||||
}
|
||||
if (this.stopButton) {
|
||||
this.container.removeChild(this.stopButton)
|
||||
}
|
||||
|
||||
button.onclick = () => {
|
||||
document.body.removeChild(this.bg)
|
||||
}
|
||||
section.append(title, description, button)
|
||||
this.endSection = section
|
||||
this.container.append(section)
|
||||
}
|
||||
}
|
||||
|
||||
function generateGrid() {
|
||||
const grid = document.createElement('div')
|
||||
grid.className = 'grid'
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const cell = document.createElement('div')
|
||||
Object.assign(cell.style, {
|
||||
width: '2px',
|
||||
height: '2px',
|
||||
borderRadius: '10px',
|
||||
background: 'white',
|
||||
})
|
||||
cell.className = 'cell'
|
||||
grid.appendChild(cell)
|
||||
}
|
||||
Object.assign(grid.style, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gridTemplateRows: 'repeat(4, 1fr)',
|
||||
gap: '2px',
|
||||
cursor: 'grab',
|
||||
})
|
||||
return grid
|
||||
}
|
||||
|
||||
function generateChevron() {
|
||||
const triangle = document.createElement('div')
|
||||
Object.assign(triangle.style, {
|
||||
width: '0',
|
||||
height: '0',
|
||||
borderLeft: '7px solid transparent',
|
||||
borderRight: '7px solid transparent',
|
||||
borderBottom: '7px solid white',
|
||||
})
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(triangle)
|
||||
Object.assign(container.style, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer',
|
||||
marginLeft: 'auto',
|
||||
transform: 'rotate(180deg)',
|
||||
})
|
||||
return container
|
||||
}
|
||||
115
tracker/tracker/src/main/modules/userTesting/recorder.ts
Normal file
115
tracker/tracker/src/main/modules/userTesting/recorder.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import App from '../../app/index.js'
|
||||
|
||||
export const Quality = {
|
||||
Standard: { width: 1280, height: 720 },
|
||||
High: { width: 1920, height: 1080 },
|
||||
}
|
||||
|
||||
export default class Recorder {
|
||||
private mediaRecorder: MediaRecorder | null = null
|
||||
private recordedChunks: Blob[] = []
|
||||
private stream: MediaStream | null = null
|
||||
private recStartTs: number | null = null
|
||||
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
async startRecording(
|
||||
fps: number,
|
||||
quality: (typeof Quality)[keyof typeof Quality],
|
||||
micReq: boolean,
|
||||
camReq: boolean,
|
||||
) {
|
||||
this.recStartTs = this.app.timestamp()
|
||||
|
||||
const videoConstraints: MediaTrackConstraints = quality
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: camReq ? { ...videoConstraints, frameRate: { ideal: fps } } : false,
|
||||
audio: micReq,
|
||||
})
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(this.stream, {
|
||||
mimeType: 'video/webm;codecs=vp9',
|
||||
})
|
||||
|
||||
this.recordedChunks = []
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.recordedChunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
this.mediaRecorder.start()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording() {
|
||||
return new Promise<Blob>((resolve) => {
|
||||
if (!this.mediaRecorder) return
|
||||
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(this.recordedChunks, {
|
||||
type: 'video/webm',
|
||||
})
|
||||
resolve(blob)
|
||||
}
|
||||
|
||||
this.mediaRecorder.stop()
|
||||
})
|
||||
}
|
||||
|
||||
async sendToAPI() {
|
||||
const blob = await this.stopRecording()
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', blob, 'record.webm')
|
||||
// formData.append('start', this.recStartTs?.toString() ?? '')
|
||||
|
||||
return fetch(`${this.app.options.ingestPoint}/v1/web/uxt/upload-url`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.app.session.getSessionToken() as string}`,
|
||||
},
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
return r.json()
|
||||
} else {
|
||||
throw new Error('Failed to get upload url')
|
||||
}
|
||||
})
|
||||
.then(({ url }) => {
|
||||
return fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'video/webm',
|
||||
},
|
||||
body: blob,
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
this.discard()
|
||||
})
|
||||
}
|
||||
|
||||
async saveToFile(fileName = 'recorded-video.webm') {
|
||||
const blob = await this.stopRecording()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
discard() {
|
||||
this.mediaRecorder?.stop()
|
||||
this.stream?.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
}
|
||||
260
tracker/tracker/src/main/modules/userTesting/styles.ts
Normal file
260
tracker/tracker/src/main/modules/userTesting/styles.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
export const bgStyle = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'rgba(0, 0, 0, 0.40)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
export const containerStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid #D9D9D9',
|
||||
background: '#FFF',
|
||||
width: '29rem',
|
||||
}
|
||||
export const containerWidgetStyle = {
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
gap: '8px',
|
||||
'align-items': 'center',
|
||||
padding: '1rem',
|
||||
'border-radius': '0.375rem',
|
||||
border: '1px solid #D9D9D9',
|
||||
background: '#FFF',
|
||||
width: '29rem',
|
||||
}
|
||||
|
||||
export const titleStyle = {
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '1.25rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1.75rem',
|
||||
color: 'rgba(0, 0, 0, 0.85)',
|
||||
}
|
||||
|
||||
export const descriptionStyle = {
|
||||
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
padding: '1.25rem 0rem',
|
||||
color: 'rgba(0, 0, 0, 0.85)',
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '1rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '400',
|
||||
lineHeight: '1.5rem',
|
||||
}
|
||||
|
||||
export const noticeStyle = {
|
||||
color: 'rgba(0, 0, 0, 0.85)',
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '400',
|
||||
lineHeight: '1.375rem',
|
||||
}
|
||||
|
||||
export const buttonStyle = {
|
||||
display: 'flex',
|
||||
padding: '0.4rem 0.9375rem',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #394EFF',
|
||||
background: '#394EFF',
|
||||
boxShadow: '0px 2px 0px 0px rgba(0, 0, 0, 0.04)',
|
||||
color: '#FFF',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '1rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
export const sectionTitleStyle = {
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1.375rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
export const contentStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.625rem',
|
||||
}
|
||||
|
||||
// New widget styles
|
||||
export const titleWidgetStyle = {
|
||||
padding: '0.5rem',
|
||||
gap: '0.5rem',
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '1.25rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1.75rem',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(0, 0, 0, 0.60)',
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
|
||||
export const descriptionWidgetStyle = {
|
||||
boxSizing: 'border-box',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid #D9D9D9',
|
||||
background: '#FFF',
|
||||
padding: '0.625rem 1rem',
|
||||
alignSelf: 'stretch',
|
||||
color: '#000',
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '400',
|
||||
lineHeight: '1.375rem',
|
||||
}
|
||||
|
||||
export const endSectionStyle = {
|
||||
...descriptionWidgetStyle,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
}
|
||||
|
||||
export const symbolIcon = {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
color: '#394EFF',
|
||||
}
|
||||
|
||||
export const buttonWidgetStyle = {
|
||||
display: 'flex',
|
||||
padding: '0.4rem 0.9375rem',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #394EFF',
|
||||
background: '#394EFF',
|
||||
boxShadow: '0px 2px 0px 0px rgba(0, 0, 0, 0.04)',
|
||||
color: '#FFF',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '1rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1.5rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
export const stopWidgetStyle = {
|
||||
marginTop: '2rem',
|
||||
cursor: 'pointer',
|
||||
display: 'block',
|
||||
fontWeight: '600',
|
||||
}
|
||||
|
||||
export const paginationStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '0.5rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
|
||||
export const taskNumberActive = {
|
||||
display: 'flex',
|
||||
padding: '0.0625rem 0.5rem',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderRadius: '6.25em',
|
||||
outline: '1px solid #394EFF',
|
||||
}
|
||||
export const taskNumberDone = {
|
||||
display: 'flex',
|
||||
padding: '0.0625rem 0.5rem',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderRadius: '6.25em',
|
||||
outline: '1px solid #D2DFFF',
|
||||
boxShadow: '0px 2px 0px 0px rgba(0, 0, 0, 0.04)',
|
||||
background: '#D2DFFF',
|
||||
}
|
||||
|
||||
export const taskDescriptionCard = {
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
background: '#F5F7FF',
|
||||
boxShadow: '0px 2px 0px 0px rgba(0, 0, 0, 0.04)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '0.625rem 0.9375rem',
|
||||
gap: '0.5rem',
|
||||
alignSelf: 'stretch',
|
||||
}
|
||||
|
||||
export const taskTextStyle = {
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
|
||||
export const taskDescriptionStyle = {
|
||||
color: '#8C8C8C',
|
||||
}
|
||||
|
||||
export const taskButtonStyle = {
|
||||
marginRight: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
color: '#394EFF',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Verdana, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1.375rem',
|
||||
}
|
||||
export const taskButtonBorderedStyle = {
|
||||
...taskButtonStyle,
|
||||
display: 'flex',
|
||||
padding: '0.25rem 0.9375rem',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #394EFF',
|
||||
}
|
||||
|
||||
export const taskButtonsRow = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-nocheck
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
|
||||
import { describe, test, expect, beforeEach, jest } from '@jest/globals'
|
||||
import setProxy from '../main/modules/Network/index.js'
|
||||
import FetchProxy from '../main/modules/Network/fetchProxy.js'
|
||||
import XHRProxy from '../main/modules/Network/xhrProxy.js'
|
||||
|
|
@ -32,7 +32,7 @@ describe('Network Proxy', () => {
|
|||
XHRProxy.create.mockReturnValue(jest.fn())
|
||||
BeaconProxy.create.mockReturnValue(jest.fn())
|
||||
})
|
||||
it('should not replace fetch if not present', () => {
|
||||
test('should not replace fetch if not present', () => {
|
||||
context = {
|
||||
XMLHttpRequest: jest.fn(),
|
||||
navigator: {
|
||||
|
|
@ -53,7 +53,7 @@ describe('Network Proxy', () => {
|
|||
expect(XHRProxy.create).toHaveBeenCalled()
|
||||
expect(BeaconProxy.create).toHaveBeenCalled()
|
||||
})
|
||||
it('should replace XMLHttpRequest if present', () => {
|
||||
test('should replace XMLHttpRequest if present', () => {
|
||||
setProxy(
|
||||
context,
|
||||
ignoredHeaders,
|
||||
|
|
@ -67,7 +67,7 @@ describe('Network Proxy', () => {
|
|||
expect(XHRProxy.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should replace fetch if present', () => {
|
||||
test('should replace fetch if present', () => {
|
||||
setProxy(
|
||||
context,
|
||||
ignoredHeaders,
|
||||
|
|
@ -81,7 +81,7 @@ describe('Network Proxy', () => {
|
|||
expect(FetchProxy.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should replace navigator.sendBeacon if present', () => {
|
||||
test('should replace navigator.sendBeacon if present', () => {
|
||||
setProxy(
|
||||
context,
|
||||
ignoredHeaders,
|
||||
|
|
|
|||
58
tracker/tracker/src/tests/ut.recorder.test.ts
Normal file
58
tracker/tracker/src/tests/ut.recorder.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// @ts-nocheck
|
||||
|
||||
import { describe, test, expect, beforeEach, jest } from '@jest/globals'
|
||||
import mockApp from '../main/app/index'
|
||||
import Recorder, { Quality } from '../main/modules/userTesting/recorder' // Adjust the import path
|
||||
|
||||
global.MediaRecorder = jest.fn()
|
||||
global.navigator.mediaDevices = {
|
||||
getUserMedia: jest.fn(),
|
||||
}
|
||||
global.fetch = jest.fn()
|
||||
|
||||
jest.mock('../main/app/index')
|
||||
|
||||
describe('Recorder', () => {
|
||||
let recorder
|
||||
let mockAppInstance
|
||||
let mockMediaStream
|
||||
let mockMediaRecorder
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup
|
||||
mockMediaStream = {
|
||||
getTracks: jest.fn().mockReturnValue([{}]),
|
||||
}
|
||||
mockMediaRecorder = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
onstop: null,
|
||||
ondataavailable: null,
|
||||
}
|
||||
global.navigator.mediaDevices.getUserMedia.mockResolvedValue(mockMediaStream)
|
||||
global.MediaRecorder.mockImplementation(() => mockMediaRecorder)
|
||||
|
||||
mockAppInstance = {
|
||||
timestamp: () => 123456,
|
||||
} as unknown as mockApp
|
||||
recorder = new Recorder(mockAppInstance)
|
||||
})
|
||||
|
||||
test('should start recording', async () => {
|
||||
await recorder.startRecording(30, Quality.Standard)
|
||||
expect(mockMediaRecorder.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should stop recording and return blob', async () => {
|
||||
await recorder.startRecording(30, Quality.Standard)
|
||||
mockMediaRecorder.onstop = jest.fn()
|
||||
|
||||
const promise = recorder.stopRecording()
|
||||
const blob = new Blob([], { type: 'video/webm' })
|
||||
|
||||
mockMediaRecorder.onstop()
|
||||
mockMediaRecorder.ondataavailable({ data: blob })
|
||||
|
||||
await expect(promise).resolves.toEqual(blob)
|
||||
})
|
||||
})
|
||||
57
tracker/tracker/src/tests/ut.test.ts
Normal file
57
tracker/tracker/src/tests/ut.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, test, expect, beforeEach, jest } from '@jest/globals'
|
||||
import UserTestManager from '../main/modules/userTesting/index'
|
||||
import mockApp from '../main/app/index'
|
||||
|
||||
jest.mock('../main/app/index')
|
||||
jest.mock('../main/modules/userTesting/recorder.js')
|
||||
jest.mock('../main/modules/userTesting/styles.js')
|
||||
jest.mock('../main/modules/userTesting/dnd.js')
|
||||
|
||||
describe('UserTestManager', () => {
|
||||
let userTestManager: UserTestManager
|
||||
let mockAppInstance
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
mockAppInstance = jest.fn()
|
||||
userTestManager = new UserTestManager(mockAppInstance as unknown as mockApp)
|
||||
})
|
||||
|
||||
test('should create a greeting', () => {
|
||||
userTestManager.createGreeting('Hello', true, true)
|
||||
expect(document.body.innerHTML).toContain('Hello')
|
||||
expect(document.body.innerHTML).toContain('Welcome, this session will be recorded.')
|
||||
})
|
||||
|
||||
test('should show a widget with descriptions and tasks', () => {
|
||||
userTestManager.createGreeting('Hello', true, true)
|
||||
userTestManager.showWidget(['Desc1'], [{ title: 'Task1', description: 'Task1 Description' }])
|
||||
expect(document.body.innerHTML).toContain('Desc1')
|
||||
expect(document.body.innerHTML).toContain('Task1')
|
||||
})
|
||||
|
||||
test('should create a title section', () => {
|
||||
const titleSection = userTestManager.createTitleSection()
|
||||
expect(titleSection).toBeDefined()
|
||||
})
|
||||
|
||||
test('should create a description section', () => {
|
||||
const descriptionSection = userTestManager.createDescriptionSection(['Desc1'])
|
||||
expect(descriptionSection).toBeDefined()
|
||||
expect(descriptionSection.innerHTML).toContain('Desc1')
|
||||
})
|
||||
|
||||
test('should create tasks section', () => {
|
||||
const tasksSection = userTestManager.createTasksSection([
|
||||
{ title: 'Task1', description: 'Desc1' },
|
||||
])
|
||||
expect(tasksSection).toBeDefined()
|
||||
expect(tasksSection.innerHTML).toContain('Task1')
|
||||
expect(tasksSection.innerHTML).toContain('Desc1')
|
||||
})
|
||||
|
||||
test('should show end section', () => {
|
||||
userTestManager.createGreeting('Hello', true, true)
|
||||
userTestManager.showEndSection()
|
||||
expect(document.body.innerHTML).toContain('Thank you!')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue