diff --git a/package.json b/package.json index b24be5185..b2955fc9b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@mantine/form": "^7.10.1", "@mantine/hooks": "^7.10.1", "@mantine/modals": "^7.10.1", + "@mantine/notifications": "^7.12.0", "@quentinroy/latin-square": "^1.1.1", "@reduxjs/toolkit": "^2.2.5", "@tabler/icons-react": "^3.5.0", diff --git a/public/global.json b/public/global.json index a39f649c6..bac94facc 100644 --- a/public/global.json +++ b/public/global.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.1/src/parser/GlobalConfigSchema.json", + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.2/src/parser/GlobalConfigSchema.json", "configsList": [ "Upset-Alttext-User-Survey", "test-randomization", diff --git a/public/test-parser-errors/config.json b/public/test-parser-errors/config.json index 8d7b78989..c16bb7113 100644 --- a/public/test-parser-errors/config.json +++ b/public/test-parser-errors/config.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.1/src/parser/StudyConfigSchema.json", + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.2/src/parser/StudyConfigSchema.json", "studyMetadata": { "title": "Test for parser errors", "version": "pilot", diff --git a/public/test-randomization/config.json b/public/test-randomization/config.json index 4a2161927..fb526db97 100644 --- a/public/test-randomization/config.json +++ b/public/test-randomization/config.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.1/src/parser/StudyConfigSchema.json", + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.2/src/parser/StudyConfigSchema.json", "studyMetadata": { "title": "Using Randomization", "description": "This is a test study to check the functionality of the reVISit sequence generator. This study is not meant to be used for any real data collection.", diff --git a/src/Login.tsx b/src/Login.tsx index dd4f20b75..aca7bc418 100644 --- a/src/Login.tsx +++ b/src/Login.tsx @@ -1,5 +1,5 @@ import { - Badge, Button, Card, Text, Container, Flex, Image, LoadingOverlay, + Button, Card, Text, Container, Flex, Image, LoadingOverlay, } from '@mantine/core'; import { useState, useEffect } from 'react'; import { @@ -12,19 +12,18 @@ import { useAuth } from './store/hooks/useAuth'; import { useStorageEngine } from './storage/storageEngineHooks'; import { FirebaseStorageEngine } from './storage/engines/FirebaseStorageEngine'; import { StorageEngine } from './storage/engines/StorageEngine'; +import { showNotification } from './utils/notifications'; -export async function signInWithGoogle(storageEngine: StorageEngine | undefined, setLoading: (val: boolean) => void, setErrorMessage?: (val: string) => void) { +export async function signInWithGoogle(storageEngine: StorageEngine | undefined, setLoading: (val: boolean) => void) { if (storageEngine instanceof FirebaseStorageEngine) { setLoading(true); const provider = new GoogleAuthProvider(); const auth = getAuth(); try { await signInWithPopup(auth, provider, browserPopupRedirectResolver); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { - if (setErrorMessage) { - setErrorMessage(error.message); - } + showNotification({ title: 'Error', message: error.message, color: 'red' }); } finally { setLoading(false); } @@ -35,13 +34,12 @@ export async function signInWithGoogle(storageEngine: StorageEngine | undefined, export function Login() { const { user } = useAuth(); - const [errorMessage, setErrorMessage] = useState(null); const [loading, setLoading] = useState(false); const { storageEngine } = useStorageEngine(); useEffect(() => { if (!user.determiningStatus && !user.isAdmin && user.adminVerification) { - setErrorMessage('You are not authorized to use this application.'); + showNotification({ title: 'Unauthorized', message: 'You are not authorized to use this application.', color: 'red' }); } }, [user.adminVerification]); @@ -56,9 +54,8 @@ export function Login() { Revisit Logo <> To access admin settings, please sign in using your Google account. - + - {errorMessage ? {errorMessage} : null} diff --git a/src/analysis/dashboard/StudyCard.tsx b/src/analysis/dashboard/StudyCard.tsx index 14a4e8ccc..b9641221c 100644 --- a/src/analysis/dashboard/StudyCard.tsx +++ b/src/analysis/dashboard/StudyCard.tsx @@ -1,16 +1,17 @@ import { - Box, Button, Card, Center, Text, Title, Container, Flex, Group, Popover, + Box, Button, Card, Center, Text, Title, Container, Flex, Group, Tooltip, } from '@mantine/core'; import React, { useMemo, useState } from 'react'; import { IconChartHistogram } from '@tabler/icons-react'; import { DatePickerInput } from '@mantine/dates'; import { VegaLite } from 'react-vega'; -import { useDisclosure, useResizeObserver } from '@mantine/hooks'; +import { useResizeObserver } from '@mantine/hooks'; import { useNavigate } from 'react-router-dom'; import { ParticipantData } from '../../storage/types'; import { StoredAnswer } from '../../parser/types'; import { DownloadButtons } from '../../components/downloader/DownloadButtons'; import { ParticipantStatusBadges } from '../interface/ParticipantStatusBadges'; +import { PREFIX } from '../../utils/Prefix'; function isWithinRange(answers: Record, rangeTime: [Date | null, Date | null]) { const timeStamps = Object.values(answers).map((ans) => [ans.startTime, ans.endTime]).flat(); @@ -75,8 +76,6 @@ export function StudyCard({ studyId, allParticipants }: { studyId: string; allPa data: { values: completedStatsData }, }), [dms.width, rangeTime, completedStatsData]); - const [checkOpened, { close: closeCheck, open: openCheck }] = useDisclosure(false); - return ( @@ -87,24 +86,16 @@ export function StudyCard({ studyId, allParticipants }: { studyId: string; allPa - - - - - - - Analyze and manage study data - - + + + diff --git a/src/analysis/individualStudy/StudyAnalysisTabs.tsx b/src/analysis/individualStudy/StudyAnalysisTabs.tsx index 35d5d5251..143b20f96 100644 --- a/src/analysis/individualStudy/StudyAnalysisTabs.tsx +++ b/src/analysis/individualStudy/StudyAnalysisTabs.tsx @@ -60,7 +60,7 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig - + diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index d461509ab..268b056a4 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -1,22 +1,29 @@ import { - Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, ActionIcon, Space, + Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, Space, Table, } from '@mantine/core'; import { useCallback, useEffect, useState } from 'react'; -import { IconTrashX, IconRefresh } from '@tabler/icons-react'; +import { IconTrashX, IconRefresh, IconPencil } from '@tabler/icons-react'; import { openConfirmModal } from '@mantine/modals'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; -import { FirebaseStorageEngine } from '../../../storage/engines/FirebaseStorageEngine'; +import { showNotification, RevisitNotification } from '../../../utils/notifications'; +import { DownloadButtons } from '../../../components/downloader/DownloadButtons'; +import { + FirebaseStorageEngine, FirebaseActionResponse, SnapshotNameItem, +} from '../../../storage/engines/FirebaseStorageEngine'; export function DataManagementAccordionItem({ studyId, refresh }: { studyId: string, refresh: () => Promise }) { const [modalArchiveOpened, setModalArchiveOpened] = useState(false); const [modalDeleteSnapshotOpened, setModalDeleteSnapshotOpened] = useState(false); + const [modalRenameSnapshotOpened, setModalRenameSnapshotOpened] = useState(false); const [modalDeleteLiveOpened, setModalDeleteLiveOpened] = useState(false); const [currentSnapshot, setCurrentSnapshot] = useState(''); - const [snapshots, setSnapshots] = useState([]); + const [snapshots, setSnapshots] = useState([]); const [deleteValue, setDeleteValue] = useState(''); + const [renameValue, setRenameValue] = useState(''); + const [loading, setLoading] = useState(false); const [snapshotListLoading, setSnapshotListLoading] = useState(false); @@ -35,50 +42,60 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str refreshSnapshots(); }, [refreshSnapshots]); - const handleCreateSnapshot = async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type FirebaseAction = (...args: any[]) => Promise; + + // Generalized snapshot action handler + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const snapshotAction = async (action: FirebaseAction, ...args: any[]) => { setLoading(true); - await storageEngine.createSnapshot(studyId, false); - refreshSnapshots(); - setLoading(false); - await refresh(); + const response: FirebaseActionResponse = await action(...args); + if (response.status === 'SUCCESS') { + refreshSnapshots(); + setLoading(false); + await refresh(); + // Show all notifications from success. + if (response.notifications) { + response.notifications.forEach((notification: RevisitNotification) => { + showNotification(notification); + }); + } + } else { + // Show returned error as a notification in red. + setLoading(false); + showNotification({ title: response.error.title, message: response.error.message, color: 'red' }); + } + }; + + const handleCreateSnapshot = async () => { + await snapshotAction(storageEngine.createSnapshot.bind(storageEngine), studyId, false); }; const handleArchiveData = async () => { - setLoading(true); setDeleteValue(''); setModalArchiveOpened(false); - await storageEngine.createSnapshot(studyId, true); - refreshSnapshots(); - setLoading(false); - await refresh(); + await snapshotAction(storageEngine.createSnapshot.bind(storageEngine), studyId, true); + }; + + const handleRenameSnapshot = async () => { + setModalRenameSnapshotOpened(false); + await snapshotAction(storageEngine.renameSnapshot.bind(storageEngine), currentSnapshot, renameValue); }; const handleRestoreSnapshot = async (snapshot: string) => { - setLoading(true); - await storageEngine.restoreSnapshot(studyId, snapshot); - refreshSnapshots(); - setLoading(false); - await refresh(); + await snapshotAction(storageEngine.restoreSnapshot.bind(storageEngine), studyId, snapshot); }; const handleDeleteSnapshot = async () => { - setLoading(true); setDeleteValue(''); setModalDeleteSnapshotOpened(false); - await storageEngine.removeSnapshotOrLive(currentSnapshot, true); - refreshSnapshots(); - setLoading(false); - await refresh(); + await snapshotAction(storageEngine.removeSnapshotOrLive.bind(storageEngine), currentSnapshot, true); }; const handleDeleteLive = async () => { - setLoading(true); setDeleteValue(''); setModalDeleteLiveOpened(false); - await storageEngine.removeSnapshotOrLive(studyId, true); - refreshSnapshots(); - setLoading(false); - await refresh(); + await snapshotAction(storageEngine.removeSnapshotOrLive.bind(storageEngine), studyId, true); }; const openCreateSnapshotModal = () => openConfirmModal({ @@ -89,7 +106,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str labels: { confirm: 'Create', cancel: 'Cancel' }, // confirmProps: { color: 'blue' }, cancelProps: { variant: 'subtle', color: 'dark' }, - onCancel: () => {}, + onCancel: () => { }, onConfirm: () => handleCreateSnapshot(), }); @@ -101,10 +118,25 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str labels: { confirm: 'Restore', cancel: 'Cancel' }, confirmProps: { color: 'red' }, cancelProps: { variant: 'subtle', color: 'dark' }, - onCancel: () => {}, + onCancel: () => { }, onConfirm: () => handleRestoreSnapshot(snapshot), }); + const getDateFromSnapshotName = (snapshotName: string): string | null => { + const regex = /-snapshot-(.+)$/; + const match = snapshotName.match(regex); + + if (match && match[1]) { + const dateStuff = match[1]; + return dateStuff.replace('T', ' '); + } + return null; + }; + + const fetchParticipants = async (snapshotName: string) => { + const strippedFilename = snapshotName.slice(snapshotName.indexOf('-') + 1); + return await storageEngine.getAllParticipantsDataByStudy(strippedFilename); + }; return ( <> @@ -183,33 +215,66 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str {/* Position relative keeps the loading overlay only on the list */} - { snapshots.length > 0 - ? snapshots.map( - (datasetName: string) => ( - - {datasetName} - - - { openRestoreSnapshotModal(datasetName); }} - > - - - - - { setModalDeleteSnapshotOpened(true); setCurrentSnapshot(datasetName); }} - > - - - - - - ), + {snapshots.length > 0 + ? ( + + + + Snapshot Name + Date Created + Actions + + + + {snapshots.map( + (snapshotItem: SnapshotNameItem) => ( + + {snapshotItem.alternateName} + {getDateFromSnapshotName(snapshotItem.originalName)} + + + + + + + + + + + + + fetchParticipants(snapshotItem.originalName)} studyId={studyId} gap="10px" fileName={snapshotItem.alternateName} /> + + + + ), + )} + +
) : No snapshots.}
@@ -266,6 +331,27 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str
+ { setModalRenameSnapshotOpened(false); setRenameValue(''); }} + title={Rename Snapshot} + > + setRenameValue(event.target.value)} + /> + + + + + + + { setModalDeleteLiveOpened(false); setDeleteValue(''); }} diff --git a/src/analysis/interface/AppHeader.tsx b/src/analysis/interface/AppHeader.tsx index 66df34f4c..229813898 100644 --- a/src/analysis/interface/AppHeader.tsx +++ b/src/analysis/interface/AppHeader.tsx @@ -33,19 +33,19 @@ export default function AppHeader({ studyIds }: { studyIds: string[] }) { { name: 'Studies', leftIcon: , - href: '/', + href: '', needAdmin: false, }, { name: 'Analysis', leftIcon: , - href: '/analysis/dashboard', + href: 'analysis/dashboard', needAdmin: false, }, { name: 'Settings', leftIcon: , - href: '/settings', + href: 'settings', needAdmin: true, }, ]; @@ -97,7 +97,9 @@ export default function AppHeader({ studyIds }: { studyIds: string[] }) { leftSection={menuItem.leftIcon} variant="default" style={{ border: 'none', display: 'flex', justifyContent: 'flex-start' }} - onClick={() => { navigate(menuItem.href); setNavOpen(false); }} + onClick={(event) => { event.preventDefault(); navigate(`/${menuItem.href}`); setNavOpen(false); }} + component="a" + href={`${PREFIX}${menuItem.href}`} > {menuItem.name} @@ -110,7 +112,9 @@ export default function AppHeader({ studyIds }: { studyIds: string[] }) { leftSection={menuItem.leftIcon} variant="default" style={{ border: 'none', display: 'flex', justifyContent: 'flex-start' }} - onClick={() => { navigate(menuItem.href); setNavOpen(false); }} + onClick={(event) => { event.preventDefault(); navigate(`/${menuItem.href}`); setNavOpen(false); }} + component="a" + href={`${PREFIX}${menuItem.href}`} > {menuItem.name} diff --git a/src/components/ConfigSwitcher.tsx b/src/components/ConfigSwitcher.tsx index 5d6875ef4..626423afa 100644 --- a/src/components/ConfigSwitcher.tsx +++ b/src/components/ConfigSwitcher.tsx @@ -1,8 +1,8 @@ import { - Anchor, AppShell, Card, Container, Flex, Image, Text, UnstyledButton, + ActionIcon, Anchor, AppShell, Card, Container, Flex, Image, Text, UnstyledButton, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; -import { IconAlertTriangle } from '@tabler/icons-react'; +import { IconAlertTriangle, IconExternalLink } from '@tabler/icons-react'; import { GlobalConfig, ParsedStudyConfig } from '../parser/types'; import { sanitizeStringForUrl } from '../utils/sanitizeStringForUrl'; import { PREFIX } from '../utils/Prefix'; @@ -42,7 +42,8 @@ function ConfigSwitcher({ return ( { + onClick={(event) => { + event.preventDefault(); navigate(`/${url}`); }} my="sm" @@ -62,7 +63,23 @@ function ConfigSwitcher({ ) : ( <> - {config.studyMetadata.title} + + + {config.studyMetadata.title} + + { + event.stopPropagation(); + }} + variant="transparent" + size={20} + style={{ float: 'right' }} + component="a" + href={`${PREFIX}${url}`} + > + + + Authors: {config.studyMetadata.authors} diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index eca21737b..aa36a94a8 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -6,24 +6,21 @@ type Props = { label?: string; disabled?: boolean; onClick?: null | (() => void | Promise); - setCheckClicked: (arg: boolean) => void | null }; export function NextButton({ label = 'Next', disabled = false, - setCheckClicked = () => {}, onClick, }: Props) { const { isNextDisabled, goToNextStep } = useNextStep(); const handleClick = useCallback(() => { - setCheckClicked(false); if (onClick) { onClick(); } goToNextStep(); - }, [goToNextStep, onClick, setCheckClicked]); + }, [goToNextStep, onClick]); return ( - - - Download all participants data as JSON - - - - - - - - Download all participants data as a tidy CSV - - + + + + + + + - {openDownload && ( - + {openDownload && participants.length > 0 && ( + )} ); diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index b3c04d125..56de05a79 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import { Alert, Button, Group } from '@mantine/core'; import React, { useEffect, useMemo, useState } from 'react'; @@ -29,6 +30,8 @@ export default function ResponseBlock({ status, style, }: Props) { + const storeDispatch = useStoreDispatch(); + const { updateResponseBlockValidation } = useStoreActions(); const currentStep = useCurrentStep(); const currentComponent = useCurrentComponent(); const storedAnswer = status?.answer; @@ -37,13 +40,15 @@ export default function ResponseBlock({ const responses = useMemo(() => configInUse?.response?.filter((r) => (r.location ? r.location === location : location === 'belowStimulus')) || [], [configInUse?.response, location]); - const storeDispatch = useStoreDispatch(); - const { updateResponseBlockValidation } = useStoreActions(); const answerValidator = useAnswerField(responses, currentStep, storedAnswer || {}); const [provenanceGraph, setProvenanceGraph] = useState(undefined); - const [checkClicked, setCheckClicked] = useState(false); const { iframeAnswers, iframeProvenance } = useStoreSelector((state) => state); + const hasCorrectAnswerFeedback = configInUse?.provideFeedback && ((configInUse?.correctAnswer?.length || 0) > 0); + const allowFailedTraining = configInUse?.allowFailedTraining === undefined ? true : configInUse.allowFailedTraining; + const [attemptsUsed, setAttemptsUsed] = useState(0); + const trainingAttempts = configInUse?.trainingAttempts || 2; + const [enableNextButton, setEnableNextButton] = useState(false); const showNextBtn = location === (configInUse?.nextButtonLocation || 'belowStimulus'); @@ -74,39 +79,92 @@ export default function ResponseBlock({ ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [answerValidator.values, currentComponent, currentStep, location, storeDispatch, updateResponseBlockValidation, provenanceGraph]); + const [alertConfig, setAlertConfig] = useState({ + visible: false, + title: 'Correct Answer', + message: 'The correct answer is: ', + color: 'green', + }); + const checkAnswerProvideFeedback = () => { + const newAttemptsUsed = attemptsUsed + 1; + setAttemptsUsed(newAttemptsUsed); + + const correctAnswers = responses.every((response) => { + const configCorrectAnswer = configInUse.correctAnswer?.find((answer) => answer.id === response.id)?.answer; + const suppliedAnswer = answerValidator.getInputProps(response.id, { + type: response.type === 'checkbox' ? 'checkbox' : 'input', + }).value; + return configCorrectAnswer === suppliedAnswer; + }); + + if (hasCorrectAnswerFeedback) { + if (correctAnswers && !alertConfig.message.includes('You\'ve failed to answer this question correctly')) { + setAlertConfig({ + visible: true, + title: 'Correct Answer', + message: 'You have answered the question correctly.', + color: 'green', + }); + } else { + let message = ''; + if (newAttemptsUsed >= trainingAttempts) { + message = `You've failed to answer this question correctly after ${trainingAttempts} attempts. ${allowFailedTraining ? 'You can continue to the next question.' : 'Unfortunately you have not met the criteria for continuing this study.'}`; + } else if (trainingAttempts - newAttemptsUsed === 1) { + message = 'Please try again. You have 1 attempt left. Please read the help text carefully.'; + } else { + message = `Please try again. You have ${trainingAttempts - newAttemptsUsed} attempts left.`; + } + setAlertConfig({ + visible: true, + title: 'Incorrect Answer', + message, + color: 'red', + }); + } + } + + setEnableNextButton((allowFailedTraining && newAttemptsUsed >= trainingAttempts) || (correctAnswers && newAttemptsUsed <= trainingAttempts)); + }; return (
- {responses.map((response, index) => ( - - {response.hidden ? ( - '' - ) : ( - <> - - {hasCorrectAnswerFeedback && checkClicked && ( - - {`The correct answer is: ${configInUse.correctAnswer?.find((answer) => answer.id === response.id)?.answer}`} - - )} - - )} - - ))} + {responses.map((response, index) => { + const configCorrectAnswer = configInUse.correctAnswer?.find((answer) => answer.id === response.id)?.answer; + + return ( + + {response.hidden ? ( + '' + ) : ( + <> + + {alertConfig.visible && ( + + {alertConfig.message} +
+
+ {attemptsUsed >= trainingAttempts && configCorrectAnswer && ` The correct answer was: ${configCorrectAnswer}.`} +
+ )} + + )} +
+ ); + })} {hasCorrectAnswerFeedback && showNextBtn && ( + + diff --git a/src/main.tsx b/src/main.tsx index 32eb06ccb..9c5915409 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,15 +1,18 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { MantineProvider } from '@mantine/core'; +import { Notifications } from '@mantine/notifications'; import { StorageEngineProvider } from './storage/storageEngineHooks'; import '@mantine/core/styles.css'; import '@mantine/dates/styles.css'; +import '@mantine/notifications/styles.css'; import { GlobalConfigParser } from './GlobalConfigParser'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 6d623065c..015aa4064 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -32,6 +32,10 @@ "additionalProperties": { "additionalProperties": false, "properties": { + "allowFailedTraining": { + "description": "Controls whether the component should allow failed training. If not provided, the default is true.", + "type": "boolean" + }, "correctAnswer": { "description": "The correct answer to the component. This is used for training trials where the user is shown the correct answer after a guess.", "items": { @@ -118,6 +122,10 @@ "description": "The style of the image. This is an object with css properties as keys and css values as values.", "type": "object" }, + "trainingAttempts": { + "description": "The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user.", + "type": "number" + }, "type": { "enum": [ "markdown", @@ -436,6 +444,10 @@ "additionalProperties": false, "description": "The ImageComponent interface is used to define the properties of an image component. This component is used to render an image with optional styling.\n\nFor example, to render an image with a path of `path/to/study/assets/image.jpg` and a max width of 50%, you would use the following snippet: ```js { \"type\": \"image\", \"path\": \"/assets/image.jpg\", \"style\": { \"maxWidth\": \"50%\" } } ```", "properties": { + "allowFailedTraining": { + "description": "Controls whether the component should allow failed training. If not provided, the default is true.", + "type": "boolean" + }, "correctAnswer": { "description": "The correct answer to the component. This is used for training trials where the user is shown the correct answer after a guess.", "items": { @@ -490,6 +502,10 @@ "description": "The style of the image. This is an object with css properties as keys and css values as values.", "type": "object" }, + "trainingAttempts": { + "description": "The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user.", + "type": "number" + }, "type": { "const": "image", "type": "string" @@ -597,6 +613,10 @@ "additionalProperties": false, "description": "An InheritedComponent is a component that inherits properties from a baseComponent. This is used to avoid repeating properties in components. This also means that components in the baseComponents object can be partially defined, while components in the components object can inherit from them and must be fully defined and include all properties (after potentially merging with a base component).", "properties": { + "allowFailedTraining": { + "description": "Controls whether the component should allow failed training. If not provided, the default is true.", + "type": "boolean" + }, "baseComponent": { "type": "string" }, @@ -686,6 +706,10 @@ "description": "The style of the image. This is an object with css properties as keys and css values as values.", "type": "object" }, + "trainingAttempts": { + "description": "The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user.", + "type": "number" + }, "type": { "enum": [ "markdown", @@ -842,6 +866,10 @@ "additionalProperties": false, "description": "The MarkdownComponent interface is used to define the properties of a markdown component. The components can be used to render many different things, such as consent forms, instructions, and debriefs. Additionally, you can use the markdown component to render images, videos, and other media, with supporting text. Markdown components can have responses (e.g. in a consent form), or no responses (e.g. in a help text file). Here's an example with no responses for a simple help text file:\n\n```js { \"type\": \"markdown\", \"path\": \"/assets/help.md\", \"response\": [] } ```", "properties": { + "allowFailedTraining": { + "description": "Controls whether the component should allow failed training. If not provided, the default is true.", + "type": "boolean" + }, "correctAnswer": { "description": "The correct answer to the component. This is used for training trials where the user is shown the correct answer after a guess.", "items": { @@ -889,6 +917,10 @@ }, "type": "array" }, + "trainingAttempts": { + "description": "The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user.", + "type": "number" + }, "type": { "const": "markdown", "type": "string" @@ -988,6 +1020,10 @@ "additionalProperties": false, "description": "A QuestionnaireComponent is used to render simple questions that require a response. The main use case of this component type is to ask participants questions when you don't need to render a stimulus. Please note, that even though we're not using a stimulus, the responses still require a `location`. For example this could be used to collect demographic information from a participant using the following snippet:\n\n```js { \"type\": \"questionnaire\", \"response\": [ { \"id\": \"gender\", \"prompt\": \"Gender:\", \"required\": true, \"location\": \"belowStimulus\", \"type\": \"checkbox\", \"options\": [ { \"label\": \"Man\", \"value\": \"Man\" }, { \"label\": \"Woman\", \"value\": \"Woman\" }, { \"label\": \"Genderqueer\", \"value\": \"Genderqueer\" }, { \"label\": \"Third-gender\", \"value\": \"Third-gender\" }, ... etc. ] } ] } ```", "properties": { + "allowFailedTraining": { + "description": "Controls whether the component should allow failed training. If not provided, the default is true.", + "type": "boolean" + }, "correctAnswer": { "description": "The correct answer to the component. This is used for training trials where the user is shown the correct answer after a guess.", "items": { @@ -1031,6 +1067,10 @@ }, "type": "array" }, + "trainingAttempts": { + "description": "The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user.", + "type": "number" + }, "type": { "const": "questionnaire", "type": "string" @@ -1140,8 +1180,12 @@ }, "ReactComponent": { "additionalProperties": false, - "description": "The ReactComponent interface is used to define the properties of a react component. This component is used to render react code with certain parameters. These parameters can be used within your react code to render different things.\n\nUnlike other types of components, the path for a React component is relative to the `src/public/` folder. Similar to our standard assets, we suggest creating a folder named `src/public/{studyName}/assets` to house all of the React component assets for a particular study. Your React component which you link to in the path must be default exported from its file.\n\nReact components created this way have a generic prop type passed to the component on render, `>`, which has the following types.\n\n```ts { parameters: T; setAnswer: ({ status, provenanceGraph, answers }: { status: boolean, provenanceGraph?: TrrackedProvenance, answers: Record }) => void } ```\n\nparameters is the same object passed in from the ReactComponent type below, allowing you to pass options in from the config to your component. setAnswer is a callback function allowing the creator of the ReactComponent to programmatically set the answer, as well as the provenance graph. This can be useful if you don't use the default answer interface, and instead have something more unique.\n\nSo, for example, if I had the following ReactComponent in my config ```js { type: 'react-component'; path: 'my_study/CoolComponent.tsx'; parameters: { name: 'Zach'; age: 26; } } ```\n\nMy react component, CoolComponent.tsx, would exist in src/public/my_study/assets, and look something like this\n\n```ts export default function CoolComponent({ parameters, setAnswer }: StimulusParams<{name: string, age: number}>) { // render something } ```\n\nFor in depth examples, see the following studies, and their associated codebases. https://revisit.dev/study/demo-click-accuracy-test (https://github.com/revisit-studies/study/tree/v1.0.1/src/public/demo-click-accuracy-test/assets) https://revisit.dev/study/demo-brush-interactions (https://github.com/revisit-studies/study/tree/v1.0.1/src/public/demo-brush-interactions/assets)", + "description": "The ReactComponent interface is used to define the properties of a react component. This component is used to render react code with certain parameters. These parameters can be used within your react code to render different things.\n\nUnlike other types of components, the path for a React component is relative to the `src/public/` folder. Similar to our standard assets, we suggest creating a folder named `src/public/{studyName}/assets` to house all of the React component assets for a particular study. Your React component which you link to in the path must be default exported from its file.\n\nReact components created this way have a generic prop type passed to the component on render, `>`, which has the following types.\n\n```ts { parameters: T; setAnswer: ({ status, provenanceGraph, answers }: { status: boolean, provenanceGraph?: TrrackedProvenance, answers: Record }) => void } ```\n\nparameters is the same object passed in from the ReactComponent type below, allowing you to pass options in from the config to your component. setAnswer is a callback function allowing the creator of the ReactComponent to programmatically set the answer, as well as the provenance graph. This can be useful if you don't use the default answer interface, and instead have something more unique.\n\nSo, for example, if I had the following ReactComponent in my config ```js { type: 'react-component'; path: 'my_study/CoolComponent.tsx'; parameters: { name: 'Zach'; age: 26; } } ```\n\nMy react component, CoolComponent.tsx, would exist in src/public/my_study/assets, and look something like this\n\n```ts export default function CoolComponent({ parameters, setAnswer }: StimulusParams<{name: string, age: number}>) { // render something } ```\n\nFor in depth examples, see the following studies, and their associated codebases. https://revisit.dev/study/demo-click-accuracy-test (https://github.com/revisit-studies/study/tree/v1.0.2/src/public/demo-click-accuracy-test/assets) https://revisit.dev/study/demo-brush-interactions (https://github.com/revisit-studies/study/tree/v1.0.2/src/public/demo-brush-interactions/assets)", "properties": { + "allowFailedTraining": { + "description": "Controls whether the component should allow failed training. If not provided, the default is true.", + "type": "boolean" + }, "correctAnswer": { "description": "The correct answer to the component. This is used for training trials where the user is shown the correct answer after a guess.", "items": { @@ -1194,6 +1238,10 @@ }, "type": "array" }, + "trainingAttempts": { + "description": "The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user.", + "type": "number" + }, "type": { "const": "react-component", "type": "string" @@ -1441,7 +1489,7 @@ }, "StudyConfig": { "additionalProperties": false, - "description": "The StudyConfig interface is used to define the properties of a study configuration. This is a JSON object with four main components: the StudyMetadata, the UIConfig, the Components, and the Sequence. Below is the general template that should be followed when constructing a Study configuration file.\n\n```js { \"$schema\": \"https://raw.githubusercontent.com/revisit-studies/study/v1.0.1/src/parser/StudyConfigSchema.json\", \"studyMetadata\": { ... }, \"uiConfig\": { ... }, \"components\": { ... }, \"sequence\": { ... } } ```\n\n:::info For information about each of the individual pieces of the study configuration file, you can visit the documentation for each one individually. :::
\n\nThe `$schema` line is used to verify the schema. If you're using VSCode (or other similar IDEs), including this line will allow for autocomplete and helpful suggestions when writing the study configuration.", + "description": "The StudyConfig interface is used to define the properties of a study configuration. This is a JSON object with four main components: the StudyMetadata, the UIConfig, the Components, and the Sequence. Below is the general template that should be followed when constructing a Study configuration file.\n\n```js { \"$schema\": \"https://raw.githubusercontent.com/revisit-studies/study/v1.0.2/src/parser/StudyConfigSchema.json\", \"studyMetadata\": { ... }, \"uiConfig\": { ... }, \"components\": { ... }, \"sequence\": { ... } } ```\n\n:::info For information about each of the individual pieces of the study configuration file, you can visit the documentation for each one individually. :::
\n\nThe `$schema` line is used to verify the schema. If you're using VSCode (or other similar IDEs), including this line will allow for autocomplete and helpful suggestions when writing the study configuration.", "properties": { "$schema": { "description": "A required json schema property. This should point to the github link for the version of the schema you would like. The `$schema` line is used to verify the schema. If you're using VSCode (or other similar IDEs), including this line will allow for autocomplete and helpful suggestions when writing the study configuration. See examples for more information", @@ -1601,6 +1649,10 @@ "additionalProperties": false, "description": "The WebsiteComponent interface is used to define the properties of a website component. A WebsiteComponent is used to render an iframe with a website inside of it. This can be used to display an external website or an html file that is located in the public folder.\n\n```js { \"type\": \"website\", \"path\": \"/assets/website.html\", } ```\n\nTo pass a data from the config to the website, you can use the `parameters` field as below:\n\n```js { \"type\": \"website\", \"path\": \"/website.html\", \"parameters\": { \"barData\": [0.32, 0.01, 1.2, 1.3, 0.82, 0.4, 0.3] } \"response\": [ { \"id\": \"barChart\", \"prompt\": \"Your selected answer:\", \"required\": true, \"location\": \"belowStimulus\", \"type\": \"iframe\" } ], } ``` In the `website.html` file, by including `revisit-communicate.js`, you can use the `Revisit.onDataReceive` method to retrieve the data, and `Revisit.postAnswers` to send the user's responses back to the reVISit as shown in the example below:\n\n```html ```\n\n If the html website implements Trrack library for provenance tracking, you can send the provenance graph back to reVISit by calling `Revisit.postProvenanceGraph` as shown in the example below. You need to call this each time the Trrack state is updated so that reVISit is kept aware of the changes in the provenance graph.\n\n```js const trrack = initializeTrrack({ initialState, registry });\n\n... Revisit.postProvenance(trrack.graph.backend); ```", "properties": { + "allowFailedTraining": { + "description": "Controls whether the component should allow failed training. If not provided, the default is true.", + "type": "boolean" + }, "correctAnswer": { "description": "The correct answer to the component. This is used for training trials where the user is shown the correct answer after a guess.", "items": { @@ -1653,6 +1705,10 @@ }, "type": "array" }, + "trainingAttempts": { + "description": "The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user.", + "type": "number" + }, "type": { "const": "website", "type": "string" diff --git a/src/parser/types.ts b/src/parser/types.ts index f1694d365..78a8cbdfb 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -501,6 +501,10 @@ export interface BaseIndividualComponent { correctAnswer?: Answer[]; /** Controls whether the component should provide feedback to the participant, such as in a training trial. If not provided, the default is false. */ provideFeedback?: boolean; + /** The number of training attempts allowed for the component. The next button will be disabled until either the correct answer is given or the number of attempts is reached. When the number of attempts is reached, if the answer is incorrect still, the correct value will be shown to the participant. The default value is 2. Providing a value of -1 will allow infinite attempts and the participant must enter the correct answer to continue, and reVISit will not show the correct answer to the user. */ + trainingAttempts?: number; + /** Controls whether the component should allow failed training. If not provided, the default is true. */ + allowFailedTraining?: boolean; /** The meta data for the component. This is used to identify and provide additional information for the component in the admin panel. */ meta?: Record; /** The description of the component. This is used to identify and provide additional information for the component in the admin panel. */ @@ -566,8 +570,8 @@ export default function CoolComponent({ parameters, setAnswer }: StimulusParams< ``` * * For in depth examples, see the following studies, and their associated codebases. - * https://revisit.dev/study/demo-click-accuracy-test (https://github.com/revisit-studies/study/tree/v1.0.1/src/public/demo-click-accuracy-test/assets) - * https://revisit.dev/study/demo-brush-interactions (https://github.com/revisit-studies/study/tree/v1.0.1/src/public/demo-brush-interactions/assets) + * https://revisit.dev/study/demo-click-accuracy-test (https://github.com/revisit-studies/study/tree/v1.0.2/src/public/demo-click-accuracy-test/assets) + * https://revisit.dev/study/demo-brush-interactions (https://github.com/revisit-studies/study/tree/v1.0.2/src/public/demo-brush-interactions/assets) */ export interface ReactComponent extends BaseIndividualComponent { type: 'react-component'; @@ -1161,7 +1165,7 @@ export type BaseComponents = Record>; ```js { - "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.1/src/parser/StudyConfigSchema.json", + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.2/src/parser/StudyConfigSchema.json", "studyMetadata": { ... }, diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index a40dafde6..5605e88ca 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -11,27 +11,78 @@ import { StorageReference, } from 'firebase/storage'; import { - CollectionReference, DocumentData, Firestore, collection, doc, enableNetwork, getDoc, getDocs, initializeFirestore, orderBy, query, serverTimestamp, setDoc, where, deleteDoc, updateDoc, writeBatch, + CollectionReference, + DocumentData, + Firestore, + collection, + doc, + enableNetwork, + getDoc, + getDocs, + initializeFirestore, + orderBy, + query, + serverTimestamp, + setDoc, + where, + deleteDoc, + updateDoc, + writeBatch, } from 'firebase/firestore'; import { ReCaptchaV3Provider, initializeAppCheck } from '@firebase/app-check'; import { getAuth, signInAnonymously } from '@firebase/auth'; import localforage from 'localforage'; import { v4 as uuidv4 } from 'uuid'; import { - StorageEngine, UserWrapped, StoredUser, REVISIT_MODE, + StorageEngine, + UserWrapped, + StoredUser, + REVISIT_MODE, } from './StorageEngine'; import { ParticipantData } from '../types'; -import { - ParticipantMetadata, Sequence, StoredAnswer, -} from '../../store/types'; +import { ParticipantMetadata, Sequence, StoredAnswer } from '../../store/types'; +import { RevisitNotification } from '../../utils/notifications'; import { hash } from './utils'; import { StudyConfig } from '../../parser/types'; +export interface FirebaseError { + title: string; + message: string; + details?: string; +} + +// Success response always has list of notifications which are then presented to user. Notifications can contain pieces which are individual errors from upstream functions. +interface FirebaseActionResponseSuccess { + status: 'SUCCESS'; + error?: undefined; + notifications?: RevisitNotification[]; +} + +// Failed responses never take notifications, only report error. Notifications will be handled in downstream functions. +interface FirebaseActionResponseFailed { + status: 'FAILED'; + error: FirebaseError; + notifications?: undefined; +} + +export type FirebaseActionResponse = + | FirebaseActionResponseSuccess + | FirebaseActionResponseFailed; + +export interface SnapshotNameItem { + originalName: string; + alternateName: string; +} + type FirebaseStorageObjectType = 'sequenceArray' | 'participantData' | 'config'; -type FirebaseStorageObject = T extends 'sequenceArray' ? (Sequence[] | object) - : T extends 'participantData' ? (ParticipantData | object) - : T extends 'config' ? (StudyConfig | object) - : never; +type FirebaseStorageObject = + T extends 'sequenceArray' + ? Sequence[] | object + : T extends 'participantData' + ? ParticipantData | object + : T extends 'config' + ? StudyConfig | object + : never; function isParticipantData(obj: unknown): obj is ParticipantData { const potentialParticipantData = obj as ParticipantData; @@ -45,14 +96,18 @@ export class FirebaseStorageEngine extends StorageEngine { private collectionPrefix = import.meta.env.DEV ? 'dev-' : 'prod-'; - private studyCollection: CollectionReference | undefined = undefined; + private studyCollection: + | CollectionReference + | undefined = undefined; private storage: FirebaseStorage; private studyId = ''; // localForage instance for storing currentParticipantId - private localForage = localforage.createInstance({ name: 'currentParticipantId' }); + private localForage = localforage.createInstance({ + name: 'currentParticipantId', + }); private participantData: ParticipantData | null = null; @@ -102,11 +157,18 @@ export class FirebaseStorageEngine extends StorageEngine { const configHash = await hash(JSON.stringify(config)); // Create or retrieve database for study - this.studyCollection = collection(this.firestore, `${this.collectionPrefix}${studyId}`); + this.studyCollection = collection( + this.firestore, + `${this.collectionPrefix}${studyId}`, + ); this.studyId = studyId; // Push the config ref in storage - await this._pushToFirebaseStorage(`configs/${configHash}`, 'config', config); + await this._pushToFirebaseStorage( + `configs/${configHash}`, + 'config', + config, + ); // Clear sequence array and current participant data if the config has changed if (currentConfigHash && currentConfigHash !== configHash) { @@ -127,7 +189,13 @@ export class FirebaseStorageEngine extends StorageEngine { } } - async initializeParticipantSession(studyId: string, searchParams: Record, config: StudyConfig, metadata: ParticipantMetadata, urlParticipantId?: string) { + async initializeParticipantSession( + studyId: string, + searchParams: Record, + config: StudyConfig, + metadata: ParticipantMetadata, + urlParticipantId?: string, + ) { if (!this._verifyStudyDatabase(this.studyCollection)) { throw new Error('Study database not initialized'); } @@ -139,7 +207,10 @@ export class FirebaseStorageEngine extends StorageEngine { } // Check if the participant has already been initialized - const participant = await this._getFromFirebaseStorage(`participants/${this.currentParticipantId}`, 'participantData'); + const participant = await this._getFromFirebaseStorage( + `participants/${this.currentParticipantId}`, + 'participantData', + ); // Get modes const modes = await this.getModes(studyId); @@ -163,41 +234,51 @@ export class FirebaseStorageEngine extends StorageEngine { }; if (modes.dataCollectionEnabled) { - await this._pushToFirebaseStorage(`participants/${this.currentParticipantId}`, 'participantData', this.participantData); + await this._pushToFirebaseStorage( + `participants/${this.currentParticipantId}`, + 'participantData', + this.participantData, + ); } return this.participantData; } async getCurrentConfigHash() { - return await this.localForage.getItem('currentConfigHash') as string; + return (await this.localForage.getItem('currentConfigHash')) as string; } async getAllConfigsFromHash(tempHashes: string[], studyId: string) { const allConfigs = tempHashes.map((singleHash) => { - const participantRef = ref(this.storage, `${this.collectionPrefix}${studyId}/configs/${singleHash}_config`); + const participantRef = ref( + this.storage, + `${this.collectionPrefix}${studyId}/configs/${singleHash}_config`, + ); return this._getFromFirebaseStorageByRef(participantRef, 'config'); }); - const configs = await Promise.all(allConfigs) as StudyConfig[]; + const configs = (await Promise.all(allConfigs)) as StudyConfig[]; const obj: Record = {}; // eslint-disable-next-line no-return-assign - tempHashes.forEach((singleHash, i) => obj[singleHash] = configs[i]); + tempHashes.forEach((singleHash, i) => (obj[singleHash] = configs[i])); return obj; } async getCurrentParticipantId(urlParticipantId?: string) { // Get currentParticipantId from localForage - const currentParticipantId = await this.localForage.getItem('currentParticipantId'); + const currentParticipantId = await this.localForage.getItem( + 'currentParticipantId', + ); // Prioritize urlParticipantId, then currentParticipantId, then generate a new participantId if (urlParticipantId) { this.currentParticipantId = urlParticipantId; await this.localForage.setItem('currentParticipantId', urlParticipantId); return urlParticipantId; - } if (currentParticipantId) { + } + if (currentParticipantId) { this.currentParticipantId = currentParticipantId as string; return currentParticipantId as string; } @@ -210,7 +291,10 @@ export class FirebaseStorageEngine extends StorageEngine { this.currentParticipantId = uuidv4(); // Set currentParticipantId in localForage - await this.localForage.setItem('currentParticipantId', this.currentParticipantId); + await this.localForage.setItem( + 'currentParticipantId', + this.currentParticipantId, + ); return this.currentParticipantId; } @@ -235,7 +319,11 @@ export class FirebaseStorageEngine extends StorageEngine { }; // Push the updated participant data to Firebase - await this._pushToFirebaseStorage(`participants/${this.currentParticipantId}`, 'participantData', this.participantData); + await this._pushToFirebaseStorage( + `participants/${this.currentParticipantId}`, + 'participantData', + this.participantData, + ); } async setSequenceArray(latinSquare: Sequence[]) { @@ -251,7 +339,10 @@ export class FirebaseStorageEngine extends StorageEngine { throw new Error('Study database not initialized'); } - const sequenceArrayDocData = await this._getFromFirebaseStorage('', 'sequenceArray'); + const sequenceArrayDocData = await this._getFromFirebaseStorage( + '', + 'sequenceArray', + ); return Array.isArray(sequenceArrayDocData) ? sequenceArrayDocData : null; } @@ -275,33 +366,58 @@ export class FirebaseStorageEngine extends StorageEngine { const modes = await this.getModes(this.studyId); // Note intent to get a sequence in the sequenceAssignment collection - const sequenceAssignmentDoc = doc(this.studyCollection, 'sequenceAssignment'); + const sequenceAssignmentDoc = doc( + this.studyCollection, + 'sequenceAssignment', + ); // Initializes document await setDoc(sequenceAssignmentDoc, {}); - const sequenceAssignmentCollection = collection(sequenceAssignmentDoc, 'sequenceAssignment'); - const participantSequenceAssignmentDoc = doc(sequenceAssignmentCollection, this.currentParticipantId); - - const rejectedQuery = query(sequenceAssignmentCollection, where('participantId', '==', '')); + const sequenceAssignmentCollection = collection( + sequenceAssignmentDoc, + 'sequenceAssignment', + ); + const participantSequenceAssignmentDoc = doc( + sequenceAssignmentCollection, + this.currentParticipantId, + ); + + const rejectedQuery = query( + sequenceAssignmentCollection, + where('participantId', '==', ''), + ); const rejectedDocs = await getDocs(rejectedQuery); if (rejectedDocs.docs.length > 0) { const firstReject = rejectedDocs.docs[0]; const firstRejectTime = firstReject.data().timestamp; if (modes.dataCollectionEnabled) { await deleteDoc(firstReject.ref); - await setDoc(participantSequenceAssignmentDoc, { participantId: this.currentParticipantId, timestamp: firstRejectTime }); + await setDoc(participantSequenceAssignmentDoc, { + participantId: this.currentParticipantId, + timestamp: firstRejectTime, + }); } } else if (modes.dataCollectionEnabled) { - await setDoc(participantSequenceAssignmentDoc, { participantId: this.currentParticipantId, timestamp: serverTimestamp() }); + await setDoc(participantSequenceAssignmentDoc, { + participantId: this.currentParticipantId, + timestamp: serverTimestamp(), + }); } // Query all the intents to get a sequence and find our position in the queue - const intentsQuery = query(sequenceAssignmentCollection, orderBy('timestamp', 'asc')); + const intentsQuery = query( + sequenceAssignmentCollection, + orderBy('timestamp', 'asc'), + ); const intentDocs = await getDocs(intentsQuery); const intents = intentDocs.docs.map((intent) => intent.data()); // Get the current row - const intentIndex = intents.findIndex((intent) => intent.participantId === this.currentParticipantId) % sequenceArray.length; - const selectedIndex = intentIndex === -1 ? Math.floor(Math.random() * sequenceArray.length - 1) : intentIndex; + const intentIndex = intents.findIndex( + (intent) => intent.participantId === this.currentParticipantId, + ) % sequenceArray.length; + const selectedIndex = intentIndex === -1 + ? Math.floor(Math.random() * sequenceArray.length - 1) + : intentIndex; const currentRow = sequenceArray[selectedIndex]; if (!currentRow) { @@ -317,12 +433,18 @@ export class FirebaseStorageEngine extends StorageEngine { } // Get all participants - const participantRefs = ref(this.storage, `${this.collectionPrefix}${this.studyId}/participants`); + const participantRefs = ref( + this.storage, + `${this.collectionPrefix}${this.studyId}/participants`, + ); const participants = await listAll(participantRefs); const participantsData: ParticipantData[] = []; const participantPulls = participants.items.map(async (participant) => { - const participantData = await this._getFromFirebaseStorageByRef(participant, 'participantData'); + const participantData = await this._getFromFirebaseStorageByRef( + participant, + 'participantData', + ); if (isParticipantData(participantData)) { participantsData.push(participantData); @@ -343,7 +465,10 @@ export class FirebaseStorageEngine extends StorageEngine { throw new Error('Participant not initialized'); } - const participantData = await this._getFromFirebaseStorage(`participants/${this.currentParticipantId}`, 'participantData'); + const participantData = await this._getFromFirebaseStorage( + `participants/${this.currentParticipantId}`, + 'participantData', + ); return isParticipantData(participantData) ? participantData : null; } @@ -380,7 +505,11 @@ export class FirebaseStorageEngine extends StorageEngine { this.participantData.completed = true; if (modes.dataCollectionEnabled) { - await this._pushToFirebaseStorage(`participants/${this.currentParticipantId}`, 'participantData', this.participantData); + await this._pushToFirebaseStorage( + `participants/${this.currentParticipantId}`, + 'participantData', + this.participantData, + ); } return true; @@ -389,11 +518,16 @@ export class FirebaseStorageEngine extends StorageEngine { // Gets data from the user-management collection based on the inputted string async getUserManagementData(key: string) { // Get the user-management collection in Firestore - const userManagementCollection = collection(this.firestore, 'user-management'); + const userManagementCollection = collection( + this.firestore, + 'user-management', + ); // Grabs all user-management data and returns data based on key const querySnapshot = await getDocs(userManagementCollection); // Converts querySnapshot data to Object - const docsObject = Object.fromEntries(querySnapshot.docs.map((queryDoc) => [queryDoc.id, queryDoc.data()])); + const docsObject = Object.fromEntries( + querySnapshot.docs.map((queryDoc) => [queryDoc.id, queryDoc.data()]), + ); if (key in docsObject) { return docsObject[key]; } @@ -407,19 +541,31 @@ export class FirebaseStorageEngine extends StorageEngine { if (authInfo?.isEnabled) { const adminUsers = await this.getUserManagementData('adminUsers'); if (adminUsers && adminUsers.adminUsersList) { - const adminUsersObject = Object.fromEntries(adminUsers.adminUsersList.map((storedUser:StoredUser) => [storedUser.email, storedUser.uid])); + const adminUsersObject = Object.fromEntries( + adminUsers.adminUsersList.map((storedUser: StoredUser) => [ + storedUser.email, + storedUser.uid, + ]), + ); // Verifies that, if the user has signed in and thus their UID is added to the Firestore, that the current UID matches the Firestore entries UID. Prevents impersonation (otherwise, users would be able to alter email to impersonate). - const isAdmin = user.user.email && (adminUsersObject[user.user.email] === user.user.uid || adminUsersObject[user.user.email] === null); + const isAdmin = user.user.email + && (adminUsersObject[user.user.email] === user.user.uid + || adminUsersObject[user.user.email] === null); if (isAdmin) { // Add UID to user in collection if not existent. if (user.user.email && adminUsersObject[user.user.email] === null) { - const adminUser: StoredUser | undefined = adminUsers.adminUsersList.find((u: StoredUser) => u.email === user.user!.email); + const adminUser: StoredUser | undefined = adminUsers.adminUsersList.find( + (u: StoredUser) => u.email === user.user!.email, + ); if (adminUser) { adminUser.uid = user.user.uid; } - await setDoc(doc(this.firestore, 'user-management', 'adminUsers'), { - adminUsersList: adminUsers.adminUsersList, - }); + await setDoc( + doc(this.firestore, 'user-management', 'adminUsers'), + { + adminUsersList: adminUsers.adminUsersList, + }, + ); } return true; } @@ -441,7 +587,9 @@ export class FirebaseStorageEngine extends StorageEngine { const adminUsers = await this.getUserManagementData('adminUsers'); if (adminUsers?.adminUsersList) { const adminList = adminUsers.adminUsersList; - const isInList = adminList.find((storedUser: StoredUser) => storedUser.email === user.email); + const isInList = adminList.find( + (storedUser: StoredUser) => storedUser.email === user.email, + ); if (!isInList) { adminList.push({ email: user.email, uid: user.uid }); await setDoc(doc(this.firestore, 'user-management', 'adminUsers'), { @@ -458,8 +606,14 @@ export class FirebaseStorageEngine extends StorageEngine { async removeAdminUser(email: string) { const adminUsers = await this.getUserManagementData('adminUsers'); if (adminUsers?.adminUsersList && adminUsers.adminUsersList.length > 1) { - if (adminUsers.adminUsersList.find((storedUser: StoredUser) => storedUser.email === email)) { - adminUsers.adminUsersList = adminUsers?.adminUsersList.filter((storedUser:StoredUser) => storedUser.email !== email); + if ( + adminUsers.adminUsersList.find( + (storedUser: StoredUser) => storedUser.email === email, + ) + ) { + adminUsers.adminUsersList = adminUsers?.adminUsersList.filter( + (storedUser: StoredUser) => storedUser.email !== email, + ); await setDoc(doc(this.firestore, 'user-management', 'adminUsers'), { adminUsersList: adminUsers?.adminUsersList, }); @@ -468,14 +622,20 @@ export class FirebaseStorageEngine extends StorageEngine { } async getAllParticipantsDataByStudy(studyId: string) { - const currentStorageRef = ref(this.storage, `${this.collectionPrefix}${studyId}/participants`); + const currentStorageRef = ref( + this.storage, + `${this.collectionPrefix}${studyId}/participants`, + ); // Get all participants const participants = await listAll(currentStorageRef); const participantsData: ParticipantData[] = []; const participantPulls = participants.items.map(async (participant) => { - const participantData = await this._getFromFirebaseStorageByRef(participant, 'participantData'); + const participantData = await this._getFromFirebaseStorageByRef( + participant, + 'participantData', + ); if (isParticipantData(participantData)) { participantsData.push(participantData); @@ -488,24 +648,47 @@ export class FirebaseStorageEngine extends StorageEngine { } async rejectParticipant(studyId: string, participantId: string) { - const studyCollection = collection(this.firestore, `${this.collectionPrefix}${studyId}`); - const participantRef = ref(this.storage, `${this.collectionPrefix}${studyId}/participants/${participantId}`); - const participant = await this._getFromFirebaseStorageByRef(participantRef, 'participantData'); + const studyCollection = collection( + this.firestore, + `${this.collectionPrefix}${studyId}`, + ); + const participantRef = ref( + this.storage, + `${this.collectionPrefix}${studyId}/participants/${participantId}_participantData`, + ); + const participant = await this._getFromFirebaseStorageByRef( + participantRef, + 'participantData', + ); try { // If the user doesn't exist or is already rejected, return - if (!participant || !isParticipantData(participant) || participant.rejected) { + if ( + !participant + || !isParticipantData(participant) + || participant.rejected + ) { return; } // set reject flag participant.rejected = true; - await this._pushToFirebaseStorageByRef(participantRef, 'participantData', participant); + await this._pushToFirebaseStorageByRef( + participantRef, + 'participantData', + participant, + ); // set sequence assignment to empty string, keep the timestamp const sequenceAssignmentDoc = doc(studyCollection, 'sequenceAssignment'); - const sequenceAssignmentCollection = collection(sequenceAssignmentDoc, 'sequenceAssignment'); - const participantSequenceAssignmentDoc = doc(sequenceAssignmentCollection, participantId); + const sequenceAssignmentCollection = collection( + sequenceAssignmentDoc, + 'sequenceAssignment', + ); + const participantSequenceAssignmentDoc = doc( + sequenceAssignmentCollection, + participantId, + ); await updateDoc(participantSequenceAssignmentDoc, { participantId: '' }); } catch { console.warn('Failed to reject the participant.'); @@ -513,13 +696,21 @@ export class FirebaseStorageEngine extends StorageEngine { } async setMode(studyId: string, mode: REVISIT_MODE, value: boolean) { - const revisitModesDoc = doc(this.firestore, 'metadata', `${this.collectionPrefix}${studyId}`); + const revisitModesDoc = doc( + this.firestore, + 'metadata', + `${this.collectionPrefix}${studyId}`, + ); return await setDoc(revisitModesDoc, { [mode]: value }, { merge: true }); } async getModes(studyId: string) { - const revisitModesDoc = doc(this.firestore, 'metadata', `${this.collectionPrefix}${studyId}`); + const revisitModesDoc = doc( + this.firestore, + 'metadata', + `${this.collectionPrefix}${studyId}`, + ); const revisitModesSnapshot = await getDoc(revisitModesDoc); if (revisitModesSnapshot.exists()) { @@ -536,12 +727,23 @@ export class FirebaseStorageEngine extends StorageEngine { return defaultModes; } - async createSnapshot(studyId: string, deleteData: boolean) { + async createSnapshot( + studyId: string, + deleteData: boolean, + ): Promise { const sourceName = `${this.collectionPrefix}${studyId}`; - if (!await this._directoryExists(sourceName)) { + if (!(await this._directoryExists(sourceName))) { console.warn(`Source directory ${sourceName} does not exist.`); - return false; + + return { + status: 'FAILED', + error: { + message: + 'There is currently no data in your study. A snapshot could not be created.', + title: 'Failed to Create Snapshot.', + }, + }; } const today = new Date(); @@ -557,27 +759,81 @@ export class FirebaseStorageEngine extends StorageEngine { const targetName = `${this.collectionPrefix}${studyId}-snapshot-${formattedDate}`; await this._copyDirectory(`${sourceName}/configs`, `${targetName}/configs`); - await this._copyDirectory(`${sourceName}/participants`, `${targetName}/participants`); + await this._copyDirectory( + `${sourceName}/participants`, + `${targetName}/participants`, + ); await this._copyDirectory(sourceName, targetName); await this._copyCollection(sourceName, targetName); await this._addDirectoryNameToMetadata(targetName); + const createSnapshotSuccessNotifications: RevisitNotification[] = []; if (deleteData) { - await this.removeSnapshotOrLive(sourceName, false); + const removeSnapshotResponse = await this.removeSnapshotOrLive( + sourceName, + false, + ); + if (removeSnapshotResponse.status === 'FAILED') { + createSnapshotSuccessNotifications.push({ + title: removeSnapshotResponse.error.title, + message: removeSnapshotResponse.error.message, + color: 'red', + }); + } else { + createSnapshotSuccessNotifications.push({ + title: 'Success!', + message: 'Successfully deleted live data.', + color: 'green', + }); + } } - return true; + createSnapshotSuccessNotifications.push({ + message: 'Successfully created snapshot', + title: 'Success!', + color: 'green', + }); + return { + status: 'SUCCESS', + notifications: createSnapshotSuccessNotifications, + }; } - async removeSnapshotOrLive(targetName: string, includeMetadata: boolean) { - const targetNameWithPrefix = targetName.startsWith(this.collectionPrefix) ? targetName : `${this.collectionPrefix}${targetName}`; + async removeSnapshotOrLive( + targetName: string, + includeMetadata: boolean, + ): Promise { + try { + const targetNameWithPrefix = targetName.startsWith(this.collectionPrefix) + ? targetName + : `${this.collectionPrefix}${targetName}`; - await this._deleteDirectory(`${targetNameWithPrefix}/configs`); - await this._deleteDirectory(`${targetNameWithPrefix}/participants`); - await this._deleteDirectory(targetNameWithPrefix); - await this._deleteCollection(targetNameWithPrefix); + await this._deleteDirectory(`${targetNameWithPrefix}/configs`); + await this._deleteDirectory(`${targetNameWithPrefix}/participants`); + await this._deleteDirectory(targetNameWithPrefix); + await this._deleteCollection(targetNameWithPrefix); - if (includeMetadata) { - await this._removeNameFromMetadata(targetNameWithPrefix); + if (includeMetadata) { + await this._removeNameFromMetadata(targetNameWithPrefix); + } + return { + status: 'SUCCESS', + notifications: [ + { + message: 'Successfully deleted snapshot or live data.', + title: 'Success!', + color: 'green', + }, + ], + }; + } catch (error) { + return { + status: 'FAILED', + error: { + title: 'Failed to delete live data or snapshot', + message: + 'There was an unspecified error when trying to remove a snapshot or live data.', + }, + }; } } @@ -589,8 +845,33 @@ export class FirebaseStorageEngine extends StorageEngine { if (metadataSnapshot.exists()) { const collections = metadataSnapshot.data(); const matchingCollections = Object.keys(collections) - .filter((directoryName) => directoryName.startsWith(`${this.collectionPrefix}${studyId}-snapshot`)); - return matchingCollections.sort().reverse(); + .filter((directoryName) => directoryName.startsWith( + `${this.collectionPrefix}${studyId}-snapshot`, + )) + .map((directoryName) => { + const value = collections[directoryName]; + let transformedValue; + if (typeof value === 'boolean') { + transformedValue = directoryName; + } else if ( + value + && typeof value === 'object' + && value.enabled === true + ) { + transformedValue = value.name; + } else { + transformedValue = null; + } + return { + originalName: directoryName, + alternateName: transformedValue, + }; + }) + .filter((item) => item.alternateName !== null); + const sortedCollections = matchingCollections + .sort((a, b) => a.originalName.localeCompare(b.originalName)) + .reverse(); // Reverse the sorted array if needed + return sortedCollections; } return []; } catch (error) { @@ -599,24 +880,107 @@ export class FirebaseStorageEngine extends StorageEngine { } } - async restoreSnapshot(studyId: string, snapshotName: string) { + async restoreSnapshot( + studyId: string, + snapshotName: string, + ): Promise { const originalName = `${this.collectionPrefix}${studyId}`; // Snapshot current collection - await this.createSnapshot(studyId, true); + const successNotifications: RevisitNotification[] = []; + try { + const createSnapshotResponse = await this.createSnapshot(studyId, true); + if (createSnapshotResponse.status === 'FAILED') { + console.warn('No live data to capture.'); + successNotifications.push({ + title: createSnapshotResponse.error.title, + message: createSnapshotResponse.error.message, + color: 'yellow', + }); + } else { + successNotifications.push({ + title: 'Success!', + message: 'Successfully created snapshot of live data.', + color: 'green', + }); + } - await this._copyDirectory(`${snapshotName}/configs`, `${originalName}/configs`); - await this._copyDirectory(`${snapshotName}/participants`, `${originalName}/participants`); - await this._copyDirectory(snapshotName, originalName); - await this._copyCollection(snapshotName, originalName); + await this._copyDirectory( + `${snapshotName}/configs`, + `${originalName}/configs`, + ); + await this._copyDirectory( + `${snapshotName}/participants`, + `${originalName}/participants`, + ); + await this._copyDirectory(snapshotName, originalName); + await this._copyCollection(snapshotName, originalName); + successNotifications.push({ + message: 'Successfully restored snapshot to live data.', + title: 'Success!', + color: 'green', + }); + return { + status: 'SUCCESS', + notifications: successNotifications, + }; + } catch (error) { + console.error('Error trying to delete a snapshot', error); + return { + status: 'FAILED', + error: { + title: 'Failed to restore a snapshot fully.', + message: + 'There was an unspecified error when trying to restore this snapshot.', + }, + }; + } } // Function to add collection name to metadata async _addDirectoryNameToMetadata(directoryName: string) { try { const metadataDoc = doc(this.firestore, 'metadata', 'collections'); - await setDoc(metadataDoc, { [directoryName]: true }, { merge: true }); + await setDoc( + metadataDoc, + { [directoryName]: { enabled: true, name: directoryName } }, + { merge: true }, + ); } catch (error) { console.error('Error adding collection to metadata:', error); + throw error; + } + } + + async renameSnapshot( + directoryName: string, + newName: string, + ): Promise { + try { + const metadataDoc = doc(this.firestore, 'metadata', 'collections'); + await setDoc( + metadataDoc, + { [directoryName]: { enabled: true, name: newName } }, + { merge: true }, + ); + return { + status: 'SUCCESS', + notifications: [ + { + message: 'Successfully renamed snapshot.', + title: 'Success!', + color: 'green', + }, + ], + }; + } catch (error) { + console.error('Error renaming collection in metadata', error); + return { + status: 'FAILED', + error: { + title: 'Failed to Rename Snapshot.', + message: 'There was an error when trying to rename the snapshot.', + }, + }; } } @@ -685,22 +1049,46 @@ export class FirebaseStorageEngine extends StorageEngine { } } - async _copyCollection(sourceCollectionName: string, targetCollectionName: string) { - const sourceCollectionRef = collection(this.firestore, sourceCollectionName); - const sourceSequenceAssignmentDocRef = doc(sourceCollectionRef, 'sequenceAssignment'); - const sourceSequenceAssignmentCollectionRef = collection(sourceSequenceAssignmentDocRef, 'sequenceAssignment'); - - const targetCollectionRef = collection(this.firestore, targetCollectionName); - const targetSequenceAssignmentDocRef = doc(targetCollectionRef, 'sequenceAssignment'); + async _copyCollection( + sourceCollectionName: string, + targetCollectionName: string, + ) { + const sourceCollectionRef = collection( + this.firestore, + sourceCollectionName, + ); + const sourceSequenceAssignmentDocRef = doc( + sourceCollectionRef, + 'sequenceAssignment', + ); + const sourceSequenceAssignmentCollectionRef = collection( + sourceSequenceAssignmentDocRef, + 'sequenceAssignment', + ); + + const targetCollectionRef = collection( + this.firestore, + targetCollectionName, + ); + const targetSequenceAssignmentDocRef = doc( + targetCollectionRef, + 'sequenceAssignment', + ); await setDoc(targetSequenceAssignmentDocRef, {}); - const targetSequenceAssignmentCollectionRef = collection(targetSequenceAssignmentDocRef, 'sequenceAssignment'); + const targetSequenceAssignmentCollectionRef = collection( + targetSequenceAssignmentDocRef, + 'sequenceAssignment', + ); const sourceSnapshot = await getDocs(sourceSequenceAssignmentCollectionRef); const batch = writeBatch(this.firestore); sourceSnapshot.docs.forEach((docSnapshot) => { - const targetDocRef = doc(targetSequenceAssignmentCollectionRef, docSnapshot.id); + const targetDocRef = doc( + targetSequenceAssignmentCollectionRef, + docSnapshot.id, + ); batch.set(targetDocRef, docSnapshot.data()); }); @@ -709,11 +1097,22 @@ export class FirebaseStorageEngine extends StorageEngine { } async _deleteCollection(sourceCollectionName: string) { - const sourceCollectionRef = collection(this.firestore, sourceCollectionName); - const sourceSequenceAssignmentDocRef = doc(sourceCollectionRef, 'sequenceAssignment'); - const sourceSequenceAssignmentCollectionRef = collection(sourceSequenceAssignmentDocRef, 'sequenceAssignment'); - - const collectionSnapshot = await getDocs(sourceSequenceAssignmentCollectionRef); + const sourceCollectionRef = collection( + this.firestore, + sourceCollectionName, + ); + const sourceSequenceAssignmentDocRef = doc( + sourceCollectionRef, + 'sequenceAssignment', + ); + const sourceSequenceAssignmentCollectionRef = collection( + sourceSequenceAssignmentDocRef, + 'sequenceAssignment', + ); + + const collectionSnapshot = await getDocs( + sourceSequenceAssignmentCollectionRef, + ); const batch = writeBatch(this.firestore); collectionSnapshot.forEach((docSnapshot) => { @@ -740,13 +1139,21 @@ export class FirebaseStorageEngine extends StorageEngine { } } - private _verifyStudyDatabase(db: CollectionReference | undefined): db is CollectionReference { + private _verifyStudyDatabase( + db: CollectionReference | undefined, + ): db is CollectionReference { return db !== undefined; } // Firebase storage helpers - private async _getFromFirebaseStorage(prefix: string, type: T) { - const storageRef = ref(this.storage, `${this.collectionPrefix}${this.studyId}/${prefix}_${type}`); + private async _getFromFirebaseStorage( + prefix: string, + type: T, + ) { + const storageRef = ref( + this.storage, + `${this.collectionPrefix}${this.studyId}/${prefix}_${type}`, + ); let storageObj: FirebaseStorageObject = {} as FirebaseStorageObject; try { @@ -755,13 +1162,17 @@ export class FirebaseStorageEngine extends StorageEngine { const fullProvStr = await response.text(); storageObj = JSON.parse(fullProvStr); } catch { - console.warn(`${prefix} does not have ${type} for ${this.collectionPrefix}${this.studyId}.`); + console.warn( + `${prefix} does not have ${type} for ${this.collectionPrefix}${this.studyId}.`, + ); } return storageObj; } - private async _getFromFirebaseStorageByRef(storageRef: StorageReference, type: T) { + private async _getFromFirebaseStorageByRef< + T extends FirebaseStorageObjectType + >(storageRef: StorageReference, type: T) { let storageObj: FirebaseStorageObject = {} as FirebaseStorageObject; try { @@ -776,9 +1187,16 @@ export class FirebaseStorageEngine extends StorageEngine { return storageObj; } - private async _pushToFirebaseStorage(prefix: string, type: T, objectToUpload: FirebaseStorageObject) { + private async _pushToFirebaseStorage( + prefix: string, + type: T, + objectToUpload: FirebaseStorageObject, + ) { if (Object.keys(objectToUpload).length > 0) { - const storageRef = ref(this.storage, `${this.collectionPrefix}${this.studyId}/${prefix}_${type}`); + const storageRef = ref( + this.storage, + `${this.collectionPrefix}${this.studyId}/${prefix}_${type}`, + ); const blob = new Blob([JSON.stringify(objectToUpload)], { type: 'application/json', }); @@ -786,7 +1204,13 @@ export class FirebaseStorageEngine extends StorageEngine { } } - private async _pushToFirebaseStorageByRef(storageRef: StorageReference, type: T, objectToUpload: FirebaseStorageObject) { + private async _pushToFirebaseStorageByRef< + T extends FirebaseStorageObjectType + >( + storageRef: StorageReference, + type: T, + objectToUpload: FirebaseStorageObject, + ) { if (Object.keys(objectToUpload).length > 0) { const blob = new Blob([JSON.stringify(objectToUpload)], { type: 'application/json', @@ -795,8 +1219,14 @@ export class FirebaseStorageEngine extends StorageEngine { } } - private async _deleteFromFirebaseStorage(prefix: string, type: T) { - const storageRef = ref(this.storage, `${this.collectionPrefix}${this.studyId}/${prefix}_${type}`); + private async _deleteFromFirebaseStorage( + prefix: string, + type: T, + ) { + const storageRef = ref( + this.storage, + `${this.collectionPrefix}${this.studyId}/${prefix}_${type}`, + ); await deleteObject(storageRef); } } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 000000000..137c721fb --- /dev/null +++ b/src/utils/notifications.ts @@ -0,0 +1,20 @@ +import { notifications } from '@mantine/notifications'; +import classes from './notify.module.css'; + +export interface RevisitNotification { + title: string; + message: string; + color?: string; +} + +export const showNotification = (notification: RevisitNotification) => { + const { title, message, color } = notification; + notifications.show({ + title, + message, + position: 'top-center', + classNames: classes, + color: color || 'blue', + autoClose: color === 'red' || color === 'yellow' ? false : 5000, // 'warnings' and 'errors' never auto-close. Successes or defaults auto close after 5 seconds. + }); +}; diff --git a/src/utils/notify.module.css b/src/utils/notify.module.css new file mode 100644 index 000000000..852544acc --- /dev/null +++ b/src/utils/notify.module.css @@ -0,0 +1,20 @@ +.root { + background-color: var(--notification-color, var(--mantine-primary-color-filled)); + + &::before { + background-color: var(--mantine-color-white); + } + } + +.description, +.title { + color: var(--mantine-color-white); +} + +.closeButton { + color: var(--mantine-color-white); +} + +.closeButton:hover { + background-color: rgba(0, 0, 0, 0.1); +} \ No newline at end of file diff --git a/tests/demo-cleveland.spec.ts b/tests/demo-cleveland.spec.ts index c1a8aaf09..9c044a69b 100644 --- a/tests/demo-cleveland.spec.ts +++ b/tests/demo-cleveland.spec.ts @@ -54,7 +54,7 @@ test('test', async ({ page }) => { await page.getByRole('button', { name: 'Check Answer' }).click(); // Check that the correct answer is shown - const correctAnswer = await page.getByText('The correct answer is: 66'); + const correctAnswer = await page.getByText('You have answered the question correctly.'); await expect(correctAnswer).toBeVisible(); // Click on the next button diff --git a/tests/test-training-feeback.spec.ts b/tests/test-training-feeback.spec.ts new file mode 100644 index 000000000..45c91a005 --- /dev/null +++ b/tests/test-training-feeback.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +async function goToTraining(page) { + // Check that the page contains the introduction text + const introText = await page.getByText('Welcome to our study. This is a more complex example to show how to embed React.js'); + await expect(introText).toBeVisible(); + + // Click on the next button + await page.getByRole('button', { name: 'Next', exact: true }).click(); + + // Check the page contains consent form + const consent = await page.getByRole('heading', { name: 'Consent' }); + await expect(consent).toBeVisible(); + + // Fill the consent form + await page.getByPlaceholder('Please provide your signature').click(); + await page.getByPlaceholder('Please provide your signature').fill('test'); + await page.getByLabel('Accept').check(); + + // Click on the next button + await page.getByRole('button', { name: 'Agree' }).click(); + + // Check the page contains the training image + const trainingImg = await page.getByRole('main').getByRole('img'); + await expect(trainingImg).toBeVisible(); + + // Click on the next button + await page.getByRole('button', { name: 'Next', exact: true }).click(); +} + +test('test', async ({ page }) => { + await page.goto('/'); + + // Click on cleveland + await page.getByRole('button', { name: 'Dynamic React.js Stimuli: A Graphical Perception Experiment' }).click(); + + await goToTraining(page); + + // Answer the training question incorrectly + await page.getByPlaceholder('0-100').fill('50'); + await page.getByRole('button', { name: 'Check Answer' }).click(); + const incorrectAnswer = await page.getByText('Incorrect Answer'); + await expect(incorrectAnswer).toBeVisible(); + + // Answer the training question incorrectly again + await page.getByPlaceholder('0-100').fill('51'); + await page.getByRole('button', { name: 'Check Answer' }).click(); + const incorrectAnswer2 = await page.getByText('Incorrect Answer'); + await expect(incorrectAnswer2).toBeVisible(); + + // Answer the training question incorrectly again + await page.getByPlaceholder('0-100').fill('52'); + await page.getByRole('button', { name: 'Check Answer' }).click(); + const incorrectAnswer3 = await page.getByText('You\'ve failed to answer this question correctly after 2 attempts. Unfortunately you have not met the criteria for continuing this study.'); + await expect(incorrectAnswer3).toBeVisible(); + + // Expect the next button to be disabled + const nextButton = await page.getByRole('button', { name: 'Next', exact: true }); + await expect(nextButton).toBeDisabled(); + + // Answer the training question correctly and expect the next button to be disabled still + await page.getByPlaceholder('0-100').fill('66'); + await page.getByRole('button', { name: 'Check Answer' }).click(); + await expect(nextButton).toBeDisabled(); + + // Click next participant + await page.getByRole('button', { name: 'Next Participant' }).click(); + + await goToTraining(page); + + // Answer the training question correctly and expect the next button to be enabled + await page.getByPlaceholder('0-100').fill('66'); + await page.getByRole('button', { name: 'Check Answer' }).click(); + await expect(nextButton).toBeEnabled(); +}); diff --git a/yarn.lock b/yarn.lock index 8647c12ac..9ca4db70b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17,6 +17,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": + version "7.25.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb" + integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw== + dependencies: + regenerator-runtime "^0.14.0" + "@esbuild/aix-ppc64@0.20.2": version "0.20.2" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" @@ -726,6 +733,19 @@ resolved "https://registry.yarnpkg.com/@mantine/modals/-/modals-7.10.1.tgz#28cab8bdf432ac185b33fb3bcaeae93bc4e85747" integrity sha512-2riQSNpVV7f0baizlqcggz9hx9/+y6SQTnW3zEkl/RIkuyK9dpeMFUG6M+M8ntwP79b7x9n7Em9PMWxRbgi28A== +"@mantine/notifications@^7.12.0": + version "7.12.0" + resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-7.12.0.tgz#0c096aa68485da866999b310af13b490662eed92" + integrity sha512-eW2g66b1K/EUdHD842QnQHWdKWbk1mCJkzDAyxcMGZ2BqU2zzpTUZdexbfDg2BqE/Mj/BGc3B9r2mKHt/6ebBg== + dependencies: + "@mantine/store" "7.12.0" + react-transition-group "4.4.5" + +"@mantine/store@7.12.0": + version "7.12.0" + resolved "https://registry.yarnpkg.com/@mantine/store/-/store-7.12.0.tgz#e5576ba734f3696b8b9beb69267ad086ec1949a5" + integrity sha512-gKOJQVKTxJQbjhG/qlaLiv47ydHgdN+ZC2jFRJHr1jjNeiCqzIT4wX1ofG27c5byPTAwAHvuf+/FLOV3rywUpA== + "@mantine/styles@5.10.5": version "5.10.5" resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-5.10.5.tgz#ace82a71b4fe3d14ee14638f1735d5680d93d36d" @@ -2499,6 +2519,14 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-helpers@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -4556,7 +4584,7 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prop-types@^15.6.1, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -4725,6 +4753,16 @@ react-textarea-autosize@8.5.3: use-composed-ref "^1.3.0" use-latest "^1.2.1" +react-transition-group@4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-vega@^7.6.0: version "7.6.0" resolved "https://registry.yarnpkg.com/react-vega/-/react-vega-7.6.0.tgz#b791c944046b20e02d366c7d0f8dcc21bdb4a6bb" @@ -5098,7 +5136,16 @@ string-argv@~0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5179,7 +5226,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6059,7 +6113,16 @@ wordwrapjs@^5.1.0: resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.0.tgz#4c4d20446dcc670b14fa115ef4f8fd9947af2b3a" integrity sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==