[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:
Delirium 2023-11-29 12:22:30 +01:00 committed by GitHub
parent 4f055dbfa7
commit cc34356294
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 3240 additions and 320 deletions

View file

@ -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);

View file

@ -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 => {

View file

@ -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) => {

View file

@ -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}

View file

@ -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,
}));

View file

@ -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} />

View file

@ -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 */}

View file

@ -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' />}
</>

View file

@ -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']),

View 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

View file

@ -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}

View file

@ -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));

View 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;

View 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

View 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;

View 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);

View 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);

View 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);

View file

@ -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}

View file

@ -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'>

View file

@ -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>;

View file

@ -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', {})

View file

@ -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),

View file

@ -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' }
]

View file

@ -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() {

View 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;
}
}

View file

@ -1,5 +1,3 @@
import { audioContextManager } from 'App/utils/screenRecorder';
declare global {
interface HTMLCanvasElement {
captureStream(frameRate?: number): MediaStream;

View file

@ -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 =>

View 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 || [];
}
}

View file

@ -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
]

View file

@ -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

View file

@ -46,6 +46,7 @@ module.exports = {
'transparent': 'transparent',
},
transparent: 'transparent',
cyan: '#EBF4F5',
// actual theme colors - use this for new components
figmaColors: {

View file

@ -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,
});
}
}

View file

@ -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 });

View file

@ -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.

View file

@ -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",

View file

@ -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()
})

View file

@ -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",

View file

@ -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) => {

View file

@ -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) {

View 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
}
}

View 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
}

View 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())
}
}

View 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',
}

View file

@ -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,

View 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)
})
})

View 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!')
})
})