- )}
+export function Stage({ stage, index, isWidget, uxt }: any) {
+ return useObserver(() =>
+ stage ? (
+
- ) : <>>)
+ ) : (
+ <>>
+ )
+ );
}
-function IndexNumber({ index }: any) {
+export function IndexNumber({ index }: any) {
return (
{index === 0 ?
: index}
diff --git a/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlockHeader.tsx b/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlockHeader.tsx
index b4b11b2f1..d9cf2c7db 100644
--- a/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlockHeader.tsx
+++ b/frontend/app/components/Session/Player/ReplayPlayer/PlayerBlockHeader.tsx
@@ -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,
}));
diff --git a/frontend/app/components/Session/RightBlock.tsx b/frontend/app/components/Session/RightBlock.tsx
index 9e4302648..93fcdd927 100644
--- a/frontend/app/components/Session/RightBlock.tsx
+++ b/frontend/app/components/Session/RightBlock.tsx
@@ -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 (
)
}
- if (activeTab === props.tabs.HEATMAPS) {
+ if (activeTab === 'HEATMAPS') {
return (
diff --git a/frontend/app/components/Session/WebPlayer.tsx b/frontend/app/components/Session/WebPlayer.tsx
index 7d6668168..2b43d8521 100644
--- a/frontend/app/components/Session/WebPlayer.tsx
+++ b/frontend/app/components/Session/WebPlayer.tsx
@@ -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
(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 */}
diff --git a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js
index cff9c8778..4fa54c15b 100644
--- a/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js
+++ b/frontend/app/components/Session_/EventsBlock/EventGroupWrapper.js
@@ -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 (
+
+ )
+ }
+ if (isNote) {
+ return (
+
+ )
+ }
+ if (isLocation) {
+ return (
+
+ )
+ }
+ if (isTabChange) {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ }
return (
<>
-
+
{isFirst && isLocation && event.referrer && (
@@ -87,42 +133,7 @@ class EventGroupWrapper extends React.Component {
)}
- {isNote ? (
-
- ) : isLocation ? (
-
- ) : isTabChange ? (
) : (
-
- )}
+ {returnEvt()}
{(isLastInGroup && !isTabChange) &&
}
>
diff --git a/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx b/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx
index 0b7ff0d59..493de5e74 100644
--- a/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx
+++ b/frontend/app/components/Session_/EventsBlock/EventsBlock.tsx
@@ -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
(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) => {
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 (
<>
+ {uxtestingStore.isUxt() ? (
+
+ ) : null}
@@ -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']),
diff --git a/frontend/app/components/Session_/EventsBlock/UtxEvent.tsx b/frontend/app/components/Session_/EventsBlock/UtxEvent.tsx
new file mode 100644
index 000000000..d5436d2f7
--- /dev/null
+++ b/frontend/app/components/Session_/EventsBlock/UtxEvent.tsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import { durationFromMsFormatted } from "App/date";
+
+function UtxEvent({ event }: any) {
+ return (
+
+
+
+
{event.title}
+
{durationFromMsFormatted(event.duration)}
+
+ {event.description &&
{event.description}
}
+
+ {event.comment ? (
+
+
+ Participant Response
+
+
{event.comment}
+
+ ) : null}
+
+ );
+}
+
+export default UtxEvent
\ No newline at end of file
diff --git a/frontend/app/components/Session_/Player/Controls/Controls.tsx b/frontend/app/components/Session_/Player/Controls/Controls.tsx
index 3db05c917..94a08f197 100644
--- a/frontend/app/components/Session_/Player/Controls/Controls.tsx
+++ b/frontend/app/components/Session_/Player/Controls/Controls.tsx
@@ -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) {
-
+ {uxtestingStore.hideDevtools && uxtestingStore.isUxt() ? null :
+
+ }
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(
- ,
- { 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 (
- <>
-
- {showWarning ? (
-
- Some assets may load incorrectly on localhost.
-
- Learn More
-
-
-
-
-
- ) : null}
-
-
-
- Create Bug Report
-
-
- {enabledIntegration && }
-
-
- Share
-
-
- }
- />
-
,
- },
- {
- key: 2,
- component:
,
- },
- ]}
- />
-
-
-
-
-
-
- {location && (
-
- )}
- >
+ showModal(
+ ,
+ { 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 (
+ <>
+
+ {showWarning ? (
+
+ Some assets may load incorrectly on localhost.
+
+ Learn More
+
+
+
+
+
+ ) : null}
+
+
+
+ Create Bug Report
+
+
+ {enabledIntegration && }
+
+
+ Share
+
+
+ }
+ />
+
,
+ },
+ {
+ key: 2,
+ component:
,
+ },
+ ]}
+ />
+
+ {uxtestingStore.isUxt() ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {location && (
+
+ )}
+ >
+ );
}
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));
diff --git a/frontend/app/components/UsabilityTesting/ResponsesOverview.tsx b/frontend/app/components/UsabilityTesting/ResponsesOverview.tsx
new file mode 100644
index 000000000..77fa346d3
--- /dev/null
+++ b/frontend/app/components/UsabilityTesting/ResponsesOverview.tsx
@@ -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 (
+
+
+ Open-ended task responses
+
+
+ Select Task / Question
+
+ setShowAll(!showAll)}
+ icon={ }
+ size={'small'}
+ />
+
+ }
+ />
+ {showAll
+ ? uxtestingStore.instance?.tasks
+ .filter((t) => t.taskId !== taskId && t.allowTyping)
+ .map((task) => (
+
setTaskId(task.taskId)}>
+ t.taskId === task.taskId)!}
+ title={task.title}
+ description={task.description}
+ />
+
+ ))
+ : null}
+
+
+
+ # Response
+
+
+ Participant
+
+
+ Response (add search text)
+
+
+
+ No data yet }
+ >
+
+ {uxtestingStore.responses[taskId!]?.list.map((r, i) => (
+ <>
+
{i + 10 * (page - 1) + 1}
+
{r.user_id || 'Anonymous User'}
+
{r.comment}
+ >
+ ))}
+
+
+
+ Showing {(page - 1) * 10 + 1} to{' '}
+
+ {(page - 1) * 10 + uxtestingStore.responses[taskId!]?.list.length}
+ {' '}
+ of{' '}
+
+ {numberWithCommas(uxtestingStore.responses[taskId!]?.total)}
+ {' '}
+ replies.
+
+
setPage(p)}
+ />
+
+
+
+
+ );
+});
+
+export default ResponsesOverview;
diff --git a/frontend/app/components/UsabilityTesting/SidePanel.tsx b/frontend/app/components/UsabilityTesting/SidePanel.tsx
new file mode 100644
index 000000000..bf9283cf4
--- /dev/null
+++ b/frontend/app/components/UsabilityTesting/SidePanel.tsx
@@ -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 (
+
+
+
Participant Requirements
+
+ Mic
+ uxtestingStore.instance!.setProperty('requireMic', checked)}
+ checkedChildren="Yes"
+ unCheckedChildren="No"
+ />
+
+
+ Camera
+ uxtestingStore.instance!.setProperty('requireCamera', checked)}
+ checkedChildren="Yes"
+ unCheckedChildren="No"
+ />
+
+
+
+
+
+ Preview
+
+
+
+ Publish Test
+
+
+ );
+});
+
+export default SidePanel
\ No newline at end of file
diff --git a/frontend/app/components/UsabilityTesting/StepsModal.tsx b/frontend/app/components/UsabilityTesting/StepsModal.tsx
new file mode 100644
index 000000000..99bdcd970
--- /dev/null
+++ b/frontend/app/components/UsabilityTesting/StepsModal.tsx
@@ -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 (
+
+
+ Add a task or question
+
+
+
+ Title/Question
+
+
setTitle(e.target.value)}
+ placeholder={'Task title'}
+ />
+
+ Instructions
+
+
setDescription(e.target.value)}
+ placeholder={'Task instructions'}
+ />
+
+ Allow participants to type an answer
+
+ setIsAnswerEnabled(checked)}
+ checkedChildren="Yes"
+ unCheckedChildren="No"
+ />
+
+ Enabling this option will show a text field for participants to type their answer.
+
+
+
+
+ Add
+
+ Cancel
+
+
+ );
+}
+
+export default StepsModal;
\ No newline at end of file
diff --git a/frontend/app/components/UsabilityTesting/TestEdit.tsx b/frontend/app/components/UsabilityTesting/TestEdit.tsx
new file mode 100644
index 000000000..1693b4a98
--- /dev/null
+++ b/frontend/app/components/UsabilityTesting/TestEdit.tsx
@@ -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:
,
+ },
+ {
+ key: '2',
+ label: 'Delete',
+ icon:
,
+ },
+];
+
+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
Loading...
;
+ }
+
+ 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 (
+ <>
+
+
onClose(true)}
+ onCancel={() => onClose(false)}
+ footer={
+ onClose(true)}>
+ Save
+
+ }
+ >
+ Title
+ setNewTestTitle(e.target.value)}
+ />
+ Test Objective (optional)
+ setNewTestDescription(e.target.value)}
+ placeholder="Share a brief statement about what you aim to discover through this study."
+ />
+
+
+
+
+
+ {uxtestingStore.instance.title}
+ {uxtestingStore.instance.description}
+
+
+
+ }>
+
+
+
+
+
+ Starting point
+ {
+ uxtestingStore.instance!.setProperty('startingPath', e.target.value);
+ }}
+ />
+ Test will begin on this page
+
+
+
+
Introduction & Guidelines
+
+ {isOverviewEditing ? (
+
uxtestingStore.instance!.setProperty('guidelines', e.target.value)}
+ />
+ ) : (
+
+ {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.'}
+
+ )}
+
+ {isOverviewEditing ? (
+ <>
+ setIsOverviewEditing(false)}>
+ Save
+
+ {
+ uxtestingStore.instance!.setProperty('guidelines', '');
+ setIsOverviewEditing(false);
+ }}
+ >
+ Remove
+
+ >
+ ) : (
+ setIsOverviewEditing(true)}>Add
+ )}
+
+
+
+
+
Task List
+ {uxtestingStore.instance!.tasks.map((task, index) => (
+
+ } />
+ {
+ uxtestingStore.instance!.setProperty(
+ 'tasks',
+ uxtestingStore.instance!.tasks.filter(
+ (t) => t.title !== task.title && t.description !== task.description
+ )
+ );
+ }}
+ size={'small'}
+ icon={ }
+ />
+ >
+ }
+ />
+ ))}
+
+
+ showModal(
+ {
+ uxtestingStore.instance!.setProperty('tasks', [
+ ...uxtestingStore.instance!.tasks,
+ task,
+ ]);
+ }}
+ />,
+ { right: true }
+ )
+ }
+ >
+ Add a task or question
+
+
+
+
+
+
Conclusion Message
+
+ {isConclusionEditing ? (
+
+ uxtestingStore.instance!.setProperty('conclusionMessage', e.target.value)
+ }
+ />
+ ) : (
+ {uxtestingStore.instance!.conclusionMessage}
+ )}
+
+
+ {isConclusionEditing ? (
+ <>
+ setIsConclusionEditing(false)}>
+ Save
+
+ {
+ uxtestingStore.instance!.setProperty('conclusionMessage', '');
+ setIsConclusionEditing(false);
+ }}
+ >
+ Remove
+
+ >
+ ) : (
+ setIsConclusionEditing(true)}>Edit
+ )}
+
+
+
+
onSave(false)} onPreview={() => onSave(true)} />
+
+ >
+ );
+}
+
+export function Step({
+ buttons,
+ ind,
+ title,
+ description,
+}: {
+ buttons?: React.ReactNode;
+ ind: number;
+ title: string;
+ description: string | null;
+}) {
+ return (
+
+
+ {ind + 1}
+
+
+
+
{title}
+
{description}
+
+
+
+ {buttons}
+
+ );
+}
+
+export default observer(TestEdit);
diff --git a/frontend/app/components/UsabilityTesting/TestOverview.tsx b/frontend/app/components/UsabilityTesting/TestOverview.tsx
new file mode 100644
index 000000000..b21715695
--- /dev/null
+++ b/frontend/app/components/UsabilityTesting/TestOverview.tsx
@@ -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:
},
+ { value: 'in-progress', label: 'In Progress', icon:
},
+ { value: 'paused', label: 'Pause', icon:
},
+ { value: 'closed', label: 'End Testing', icon:
},
+];
+
+const menuItems = [
+ {
+ key: '1',
+ label: 'Download Results',
+ icon:
,
+ },
+ {
+ key: '2',
+ label: 'Edit',
+ icon:
,
+ },
+ {
+ key: '3',
+ label: 'Delete',
+ icon:
,
+ },
+];
+
+function TestOverview() {
+ // @ts-ignore
+ const { siteId, testId } = useParams();
+ const { showModal } = useModal();
+ const { uxtestingStore } = useStore();
+
+ React.useEffect(() => {
+ uxtestingStore.getTest(testId);
+ }, [testId]);
+
+ if (!uxtestingStore.instance) {
+ return
No data. ;
+ }
+
+ const onPageChange = (page: number) => {
+ uxtestingStore.setSessionsPage(page);
+ };
+
+ return (
+ <>
+
+
+
+ {uxtestingStore.instance.liveCount ? (
+
+
+
+ {uxtestingStore.instance.liveCount} participants are engaged in this usability test at
+ the moment.
+
+
+
+ Moderate Real-Time
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ Open-ended task responses
+
+ {uxtestingStore.instance.responsesCount ? (
+
showModal( , { right: true, width: 900 })}>
+
+ Review All {uxtestingStore.instance.responsesCount} Responses
+
+
+
+ ) : (
+
0 at the moment.
+ )}
+
+
+
+
+ Sessions
+
+ {/*
in your selection */}
+ {/*
clear selection
*/}
+
+
+
+ {uxtestingStore.testSessions.list.map((session) => (
+ // @ts-ignore
+
+ ))}
+
+
+ Showing{' '}
+
+ {(uxtestingStore.testSessions.page - 1) * 10 + 1}
+ {' '}
+ to{' '}
+
+ {(uxtestingStore.page - 1) * 10 + uxtestingStore.testSessions.list.length}
+ {' '}
+ of{' '}
+
+ {numberWithCommas(uxtestingStore.testSessions.total)}
+ {' '}
+ tests.
+
+
+
+
+
+
+
+ >
+ );
+}
+
+const ParticipantOverview = observer(() => {
+ const { uxtestingStore } = useStore();
+
+ return (
+
+
Participant Overview
+ {uxtestingStore.testStats ? (
+
+
+
+
+ Total Participants
+
+
+ {uxtestingStore.testStats.tests_attempts}
+
+
+
+
+
+ Completed all tasks
+
+
+ {uxtestingStore.testStats.tests_attempts > 0 ? (
+
+ {Math.round(
+ (uxtestingStore.testStats.completed_all_tasks /
+ uxtestingStore.testStats.tests_attempts) *
+ 100
+ )}
+ %
+
+ ) : null}
+ {uxtestingStore.testStats.completed_all_tasks}
+
+
+
+
+
+ Skipped tasks
+
+
+ {uxtestingStore.testStats.tests_attempts > 0 ? (
+
+ {Math.round(
+ (uxtestingStore.testStats.tasks_skipped /
+ uxtestingStore.testStats.tests_attempts) *
+ 100
+ )}
+ %
+
+ ) : null}
+ {uxtestingStore.testStats.tasks_skipped}
+
+
+
+
+
+ Aborted the test
+
+
+ {uxtestingStore.testStats.tests_attempts > 0 ? (
+
+ {Math.round(
+ (uxtestingStore.testStats.tests_skipped /
+ uxtestingStore.testStats.tests_attempts) *
+ 100
+ )}
+ %
+
+ ) : null}
+ {uxtestingStore.testStats.tests_skipped}
+
+
+
+
+ ) : null}
+
+ )
+})
+
+const TaskSummary = observer(() => {
+ const { uxtestingStore } = useStore();
+ return (
+
+
+
Task Summary
+
+ {uxtestingStore.taskStats.length ? (
+
+ Average completion time for all tasks:
+
+ {uxtestingStore.taskStats
+ ? durationFormatted(
+ uxtestingStore.taskStats.reduce(
+ (stats, task) => stats + task.avgCompletionTime,
+ 0
+ ) / uxtestingStore.taskStats.length
+ )
+ : null}
+
+
+
+ ) : null}
+
+ {!uxtestingStore.taskStats.length ?
: null}
+ {uxtestingStore.taskStats.map((tst, index) => (
+
+ ))}
+
+ )
+})
+
+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 (
+
+
+
{uxtestingStore.instance!.title}
+
{uxtestingStore.instance!.description}
+
+
+
+ {statusItems.map((item) => (
+
+
+ {item.icon} {item.label}
+
+
+ ))}
+
+
+
+ {uxtestingStore.instance!.tasks.length} Tasks {' '}
+
+
+
+
+ {`${uxtestingStore.instance!.startingPath}?oruxt=${
+ uxtestingStore.instance!.testId
+ }`}
+
+ {
+ copy(
+ `${uxtestingStore.instance!.startingPath}?oruxt=${
+ uxtestingStore.instance!.testId
+ }`
+ );
+ }}
+ >
+ Copy
+
+
+ }
+ >
+
+
+ Distribute
+
+
+
+
+
+ }>
+
+
+ )
+})
+export default observer(TestOverview);
diff --git a/frontend/app/components/UsabilityTesting/UsabilityTesting.tsx b/frontend/app/components/UsabilityTesting/UsabilityTesting.tsx
new file mode 100644
index 000000000..217e77ba2
--- /dev/null
+++ b/frontend/app/components/UsabilityTesting/UsabilityTesting.tsx
@@ -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 (
+ <>
+