From cc34356294083c28d2910521a719061fcfe04b18 Mon Sep 17 00:00:00 2001 From: Delirium Date: Wed, 29 Nov 2023 12:22:30 +0100 Subject: [PATCH] [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 --- frontend/app/PrivateRoutes.tsx | 165 ++++-- frontend/app/api_client.ts | 3 +- .../Funnels/FunnelWidget/FunnelBar.tsx | 87 ++- .../Funnels/FunnelWidget/FunnelWidget.tsx | 26 +- .../Player/ReplayPlayer/PlayerBlockHeader.tsx | 4 +- .../app/components/Session/RightBlock.tsx | 4 +- frontend/app/components/Session/WebPlayer.tsx | 7 +- .../Session_/EventsBlock/EventGroupWrapper.js | 107 ++-- .../Session_/EventsBlock/EventsBlock.tsx | 30 +- .../Session_/EventsBlock/UtxEvent.tsx | 26 + .../Session_/Player/Controls/Controls.tsx | 18 +- frontend/app/components/Session_/Subheader.js | 291 ++++----- .../UsabilityTesting/ResponsesOverview.tsx | 107 ++++ .../components/UsabilityTesting/SidePanel.tsx | 47 ++ .../UsabilityTesting/StepsModal.tsx | 63 ++ .../components/UsabilityTesting/TestEdit.tsx | 331 +++++++++++ .../UsabilityTesting/TestOverview.tsx | 394 ++++++++++++ .../UsabilityTesting/UsabilityTesting.tsx | 205 +++++++ .../shared/SessionItem/PlayLink/PlayLink.tsx | 4 +- .../shared/SessionItem/SessionItem.tsx | 5 +- frontend/app/components/ui/SVG.tsx | 2 +- frontend/app/duck/sessions.ts | 8 +- frontend/app/layout/SideMenu.tsx | 1 + frontend/app/layout/data.ts | 11 +- frontend/app/mstore/index.tsx | 3 + frontend/app/mstore/uxtestingStore.ts | 284 +++++++++ frontend/app/player/web/assist/LocalStream.ts | 2 - frontend/app/routes.ts | 15 +- frontend/app/services/UxtestingService.ts | 110 ++++ frontend/app/services/index.ts | 6 +- .../app/svg/icons/event/click_hesitation.svg | 2 +- frontend/app/theme/colors.js | 1 + frontend/app/types/session/event.ts | 37 +- frontend/app/types/session/session.ts | 10 +- tracker/tracker-assist/CHANGELOG.md | 4 + tracker/tracker-assist/bun.lockb | Bin 238231 -> 243405 bytes tracker/tracker-assist/package.json | 2 +- tracker/tracker-assist/src/Assist.ts | 3 +- tracker/tracker/package.json | 4 +- tracker/tracker/src/main/app/index.ts | 13 + tracker/tracker/src/main/index.ts | 2 + .../src/main/modules/userTesting/dnd.ts | 56 ++ .../src/main/modules/userTesting/index.ts | 560 ++++++++++++++++++ .../src/main/modules/userTesting/recorder.ts | 115 ++++ .../src/main/modules/userTesting/styles.ts | 260 ++++++++ .../tracker/src/tests/networkProxy.test.ts | 10 +- tracker/tracker/src/tests/ut.recorder.test.ts | 58 ++ tracker/tracker/src/tests/ut.test.ts | 57 ++ 48 files changed, 3240 insertions(+), 320 deletions(-) create mode 100644 frontend/app/components/Session_/EventsBlock/UtxEvent.tsx create mode 100644 frontend/app/components/UsabilityTesting/ResponsesOverview.tsx create mode 100644 frontend/app/components/UsabilityTesting/SidePanel.tsx create mode 100644 frontend/app/components/UsabilityTesting/StepsModal.tsx create mode 100644 frontend/app/components/UsabilityTesting/TestEdit.tsx create mode 100644 frontend/app/components/UsabilityTesting/TestOverview.tsx create mode 100644 frontend/app/components/UsabilityTesting/UsabilityTesting.tsx create mode 100644 frontend/app/mstore/uxtestingStore.ts create mode 100644 frontend/app/services/UxtestingService.ts create mode 100644 tracker/tracker/src/main/modules/userTesting/dnd.ts create mode 100644 tracker/tracker/src/main/modules/userTesting/index.ts create mode 100644 tracker/tracker/src/main/modules/userTesting/recorder.ts create mode 100644 tracker/tracker/src/main/modules/userTesting/styles.ts create mode 100644 tracker/tracker/src/tests/ut.recorder.test.ts create mode 100644 tracker/tracker/src/tests/ut.test.ts diff --git a/frontend/app/PrivateRoutes.tsx b/frontend/app/PrivateRoutes.tsx index e39a90982..d65ac2689 100644 --- a/frontend/app/PrivateRoutes.tsx +++ b/frontend/app/PrivateRoutes.tsx @@ -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 ( - }> - + }> + - + { 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 && } {/* DASHBOARD and Metrics */} - + - - - - - - - - + + + + + + + + + + + + - - + + {Object.entries(routes.redirects).map(([fr, 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); \ No newline at end of file + state.getIn(['user', 'authDetails', 'edition']) === 'ee', +}))(PrivateRoutes); diff --git a/frontend/app/api_client.ts b/frontend/app/api_client.ts index 99daddc92..716211ca0 100644 --- a/frontend/app/api_client.ts +++ b/frontend/app/api_client.ts @@ -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 => { diff --git a/frontend/app/components/Funnels/FunnelWidget/FunnelBar.tsx b/frontend/app/components/Funnels/FunnelWidget/FunnelBar.tsx index 4e738935d..c6a9974ad 100644 --- a/frontend/app/components/Funnels/FunnelWidget/FunnelBar.tsx +++ b/frontend/app/components/Funnels/FunnelWidget/FunnelBar.tsx @@ -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 (
@@ -37,32 +38,6 @@ function FunnelBar(props: Props) { {filter.completedPercentageTotal}%
- {/* {filter.dropDueToIssues > 0 && ( -
-
- -
- -
-
- )} */}
{/* @ts-ignore */} @@ -84,6 +59,62 @@ function FunnelBar(props: Props) { ); } +export function UxTFunnelBar(props: Props) { + const { filter } = props; + + return ( +
+
{filter.title}
+
+
+
+ {(filter.completed/(filter.completed+filter.skipped))*100}% +
+
+
+
+ {/* @ts-ignore */} +
+
+ + {filter.completed} completed this step +
+
+ + + {durationFormatted(filter.avgCompletionTime)} Avg. completion time + +
+
+ {/* @ts-ignore */} +
+ + {filter.skipped} skipped +
+
+
+ ); +} + export default FunnelBar; const calculatePercentage = (completed: number, dropped: number) => { diff --git a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx index a4b54e7b1..0e93c7e25 100644 --- a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx +++ b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx @@ -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 ? ( -
- - - {!isWidget && ( - - )} +export function Stage({ stage, index, isWidget, uxt }: any) { + return useObserver(() => + stage ? ( +
+ + {!uxt ? : } + {!isWidget && !uxt && }
- ) : <>) + ) : ( + <> + ) + ); } -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} - -
- - - {enabledIntegration && } - - -
- } - /> - , - }, - { - 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} + +
+ + + {enabledIntegration && } + + +
+ } + /> + , + }, + { + 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 + +
+ } + /> + {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" + /> +
+
+ + + +
+ ); +}); + +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. +
+
+
+ + +
+
+ ); +} + +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={ + + } + > + 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 ? ( + <> + + + + ) : ( + + )} +
+
+ +
+ Task List + {uxtestingStore.instance!.tasks.map((task, index) => ( + + +
+
+ +
+ Conclusion Message +
+ {isConclusionEditing ? ( + + uxtestingStore.instance!.setProperty('conclusionMessage', e.target.value) + } + /> + ) : ( + {uxtestingStore.instance!.conclusionMessage} + )} +
+
+ {isConclusionEditing ? ( + <> + + + + ) : ( + + )} +
+
+
+ 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 ? ( + <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); 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 ( + <> + <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); diff --git a/frontend/app/components/shared/SessionItem/PlayLink/PlayLink.tsx b/frontend/app/components/shared/SessionItem/PlayLink/PlayLink.tsx index 805240dd8..751a2a178 100644 --- a/frontend/app/components/shared/SessionItem/PlayLink/PlayLink.tsx +++ b/frontend/app/components/shared/SessionItem/PlayLink/PlayLink.tsx @@ -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} diff --git a/frontend/app/components/shared/SessionItem/SessionItem.tsx b/frontend/app/components/shared/SessionItem/SessionItem.tsx index da269ce12..262e71e60 100644 --- a/frontend/app/components/shared/SessionItem/SessionItem.tsx +++ b/frontend/app/components/shared/SessionItem/SessionItem.tsx @@ -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'> diff --git a/frontend/app/components/ui/SVG.tsx b/frontend/app/components/ui/SVG.tsx index 9df1c2286..9b77566ad 100644 --- a/frontend/app/components/ui/SVG.tsx +++ b/frontend/app/components/ui/SVG.tsx @@ -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>; diff --git a/frontend/app/duck/sessions.ts b/frontend/app/duck/sessions.ts index 38bd60131..d051eaf85 100644 --- a/frontend/app/duck/sessions.ts +++ b/frontend/app/duck/sessions.ts @@ -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', {}) diff --git a/frontend/app/layout/SideMenu.tsx b/frontend/app/layout/SideMenu.tsx index 401d82708..336d460d4 100644 --- a/frontend/app/layout/SideMenu.tsx +++ b/frontend/app/layout/SideMenu.tsx @@ -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), diff --git a/frontend/app/layout/data.ts b/frontend/app/layout/data.ts index 15e47e71e..6c63a844e 100644 --- a/frontend/app/layout/data.ts +++ b/frontend/app/layout/data.ts @@ -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' } ] diff --git a/frontend/app/mstore/index.tsx b/frontend/app/mstore/index.tsx index 25ee1da0d..f10a4e43f 100644 --- a/frontend/app/mstore/index.tsx +++ b/frontend/app/mstore/index.tsx @@ -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() { diff --git a/frontend/app/mstore/uxtestingStore.ts b/frontend/app/mstore/uxtestingStore.ts new file mode 100644 index 000000000..e5266e195 --- /dev/null +++ b/frontend/app/mstore/uxtestingStore.ts @@ -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; + } +} diff --git a/frontend/app/player/web/assist/LocalStream.ts b/frontend/app/player/web/assist/LocalStream.ts index 42021c1b7..8db4ac728 100644 --- a/frontend/app/player/web/assist/LocalStream.ts +++ b/frontend/app/player/web/assist/LocalStream.ts @@ -1,5 +1,3 @@ -import { audioContextManager } from 'App/utils/screenRecorder'; - declare global { interface HTMLCanvasElement { captureStream(frameRate?: number): MediaStream; diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index 18f4fa256..ff29b4dbc 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -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 => diff --git a/frontend/app/services/UxtestingService.ts b/frontend/app/services/UxtestingService.ts new file mode 100644 index 000000000..f768a482c --- /dev/null +++ b/frontend/app/services/UxtestingService.ts @@ -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 || []; + } +} diff --git a/frontend/app/services/index.ts b/frontend/app/services/index.ts index b1b318ab3..14d6af804 100644 --- a/frontend/app/services/index.ts +++ b/frontend/app/services/index.ts @@ -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 ] \ No newline at end of file diff --git a/frontend/app/svg/icons/event/click_hesitation.svg b/frontend/app/svg/icons/event/click_hesitation.svg index 144b82cd5..cdc732c8e 100644 --- a/frontend/app/svg/icons/event/click_hesitation.svg +++ b/frontend/app/svg/icons/event/click_hesitation.svg @@ -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)"/> diff --git a/frontend/app/theme/colors.js b/frontend/app/theme/colors.js index 829cd335d..1bb9921d0 100644 --- a/frontend/app/theme/colors.js +++ b/frontend/app/theme/colors.js @@ -46,6 +46,7 @@ module.exports = { 'transparent': 'transparent', }, transparent: 'transparent', + cyan: '#EBF4F5', // actual theme colors - use this for new components figmaColors: { diff --git a/frontend/app/types/session/event.ts b/frontend/app/types/session/event.ts index 0dd1864c7..2db7f4f87 100644 --- a/frontend/app/types/session/event.ts +++ b/frontend/app/types/session/event.ts @@ -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, + }); + } +} \ No newline at end of file diff --git a/frontend/app/types/session/session.ts b/frontend/app/types/session/session.ts index 8700876ea..c5f7b945f 100644 --- a/frontend/app/types/session/session.ts +++ b/frontend/app/types/session/session.ts @@ -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 }); diff --git a/tracker/tracker-assist/CHANGELOG.md b/tracker/tracker-assist/CHANGELOG.md index 626d0360a..aa7698b27 100644 --- a/tracker/tracker-assist/CHANGELOG.md +++ b/tracker/tracker-assist/CHANGELOG.md @@ -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 diff --git a/tracker/tracker-assist/bun.lockb b/tracker/tracker-assist/bun.lockb index 2eb6b31b25a2ffb72ad67d9d988a377e4fcc7186..4f843c2d1624e754190e0475c9e7ebc59a4529f4 100755 GIT binary patch delta 7387 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 DwgAmy delta 5198 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 a{;HQg*y}F&UI#U8&++Ne-r)1`<NpP#wkGHR diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json index 02ba6e018..fdf2c46ee 100644 --- a/tracker/tracker-assist/package.json +++ b/tracker/tracker-assist/package.json @@ -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", diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index b70e5ad1e..92c7faac0 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -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() }) diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index 14c630fae..4b6400e66 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -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", diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index cb4bd2826..ed673961a 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -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) => { diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index 6f401075e..65002b2fa 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -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) { diff --git a/tracker/tracker/src/main/modules/userTesting/dnd.ts b/tracker/tracker/src/main/modules/userTesting/dnd.ts new file mode 100644 index 000000000..bb5d300b4 --- /dev/null +++ b/tracker/tracker/src/main/modules/userTesting/dnd.ts @@ -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 + } +} diff --git a/tracker/tracker/src/main/modules/userTesting/index.ts b/tracker/tracker/src/main/modules/userTesting/index.ts new file mode 100644 index 000000000..b814db7db --- /dev/null +++ b/tracker/tracker/src/main/modules/userTesting/index.ts @@ -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 +} diff --git a/tracker/tracker/src/main/modules/userTesting/recorder.ts b/tracker/tracker/src/main/modules/userTesting/recorder.ts new file mode 100644 index 000000000..e50df531e --- /dev/null +++ b/tracker/tracker/src/main/modules/userTesting/recorder.ts @@ -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()) + } +} diff --git a/tracker/tracker/src/main/modules/userTesting/styles.ts b/tracker/tracker/src/main/modules/userTesting/styles.ts new file mode 100644 index 000000000..84e254a7d --- /dev/null +++ b/tracker/tracker/src/main/modules/userTesting/styles.ts @@ -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', +} diff --git a/tracker/tracker/src/tests/networkProxy.test.ts b/tracker/tracker/src/tests/networkProxy.test.ts index 75662ece3..15844bd7c 100644 --- a/tracker/tracker/src/tests/networkProxy.test.ts +++ b/tracker/tracker/src/tests/networkProxy.test.ts @@ -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, diff --git a/tracker/tracker/src/tests/ut.recorder.test.ts b/tracker/tracker/src/tests/ut.recorder.test.ts new file mode 100644 index 000000000..b813eb9d4 --- /dev/null +++ b/tracker/tracker/src/tests/ut.recorder.test.ts @@ -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) + }) +}) diff --git a/tracker/tracker/src/tests/ut.test.ts b/tracker/tracker/src/tests/ut.test.ts new file mode 100644 index 000000000..8585f3607 --- /dev/null +++ b/tracker/tracker/src/tests/ut.test.ts @@ -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!') + }) +})