From 1e7c4490f3da1c4556f61a8566101cca97d057e1 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 31 Jul 2024 15:21:30 -0600 Subject: [PATCH 01/34] Fix the cmd+click hrefs --- src/analysis/dashboard/StudyCard.tsx | 3 ++- src/analysis/interface/AppHeader.tsx | 14 +++++++++----- src/components/ConfigSwitcher.tsx | 25 +++++++++++++++++++++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/analysis/dashboard/StudyCard.tsx b/src/analysis/dashboard/StudyCard.tsx index 14a4e8ccc..fe4545151 100644 --- a/src/analysis/dashboard/StudyCard.tsx +++ b/src/analysis/dashboard/StudyCard.tsx @@ -11,6 +11,7 @@ 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(); @@ -96,7 +97,7 @@ export function StudyCard({ studyId, allParticipants }: { studyId: string; allPa onMouseLeave={closeCheck} px={4} component="a" - href={`/analysis/stats/${studyId}`} + href={`${PREFIX}analysis/stats/${studyId}`} > 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} From 65ee75786cc6fbfc52059f0fb5253a77eb113870 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 1 Aug 2024 09:18:01 -0600 Subject: [PATCH 02/34] Error now appears when trying to enable authentication without Google provider enabled. --- src/components/settings/GlobalSettings.tsx | 53 ++++++++++++++++------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/components/settings/GlobalSettings.tsx b/src/components/settings/GlobalSettings.tsx index 29d427643..66fa1bb2c 100644 --- a/src/components/settings/GlobalSettings.tsx +++ b/src/components/settings/GlobalSettings.tsx @@ -20,8 +20,9 @@ export function GlobalSettings() { const [modalAddOpened, setModalAddOpened] = useState(false); const [modalRemoveOpened, setModalRemoveOpened] = useState(false); const [modalEnableAuthOpened, setModalEnableAuthOpened] = useState(false); + const [modalEnableAuthErrorOpened, setModalEnableAuthErrorOpened] = useState(false); const [userToRemove, setUserToRemove] = useState(''); - const [enableAuthUser, setEnableAuthUser] = useState(null); + const [enableAuthUser, setEnableAuthUser] = useState(null); const form = useForm({ initialValues: { @@ -40,7 +41,7 @@ export function GlobalSettings() { setAuthEnabled(authInfo?.isEnabled); const adminUsers = await storageEngine?.getUserManagementData('adminUsers'); if (adminUsers && adminUsers.adminUsersList) { - setAuthenticatedUsers(adminUsers?.adminUsersList.map((storedUser:StoredUser) => storedUser.email)); + setAuthenticatedUsers(adminUsers?.adminUsersList.map((storedUser: StoredUser) => storedUser.email)); } } else { setAuthEnabled(false); @@ -59,13 +60,15 @@ export function GlobalSettings() { email: newUser.email, uid: newUser.uid, }); + setModalEnableAuthOpened(true); + } else { + setModalEnableAuthErrorOpened(true); } - setModalEnableAuthOpened(true); } setLoading(false); }; - const confirmEnableAuth = async (rootUser: StoredUser| null) => { + const confirmEnableAuth = async (rootUser: StoredUser | null) => { setLoading(true); if (storageEngine instanceof FirebaseStorageEngine) { if (rootUser) { @@ -85,7 +88,7 @@ export function GlobalSettings() { if (storageEngine instanceof FirebaseStorageEngine) { await storageEngine.addAdminUser({ email: form.values.email, uid: null }); const adminUsers = await storageEngine.getUserManagementData('adminUsers'); - setAuthenticatedUsers(adminUsers?.adminUsersList.map((storedUser:StoredUser) => storedUser.email)); + setAuthenticatedUsers(adminUsers?.adminUsersList.map((storedUser: StoredUser) => storedUser.email)); } setLoading(false); setModalAddOpened(false); @@ -94,7 +97,7 @@ export function GlobalSettings() { }); }; - const handleRemoveUser = (inputUser:string) => { + const handleRemoveUser = (inputUser: string) => { setModalRemoveOpened(true); setUserToRemove(inputUser); }; @@ -104,7 +107,7 @@ export function GlobalSettings() { if (storageEngine instanceof FirebaseStorageEngine) { await storageEngine.removeAdminUser(userToRemove); const adminUsers = await storageEngine.getUserManagementData('adminUsers'); - setAuthenticatedUsers(adminUsers?.adminUsersList.map((storedUser:StoredUser) => storedUser.email)); + setAuthenticatedUsers(adminUsers?.adminUsersList.map((storedUser: StoredUser) => storedUser.email)); } setModalRemoveOpened(false); setLoading(false); @@ -136,15 +139,15 @@ export function GlobalSettings() { )} - { isAuthEnabled + {isAuthEnabled ? ( Enabled Users setModalAddOpened(true)} /> - { authenticatedUsers.length > 0 ? authenticatedUsers.map( - (storedUser:string) => ( + {authenticatedUsers.length > 0 ? authenticatedUsers.map( + (storedUser: string) => ( {storedUser} {storedUser === user.user?.email ? You @@ -188,7 +191,7 @@ export function GlobalSettings() { {userToRemove} ? -)} + )} > Are you sure you want to remove @@ -209,12 +212,12 @@ export function GlobalSettings() { setModalRemoveOpened(false)} + onClose={() => setModalEnableAuthOpened(false)} title={( Enable Authentication? -)} + )} > User @@ -233,6 +236,30 @@ export function GlobalSettings() { + + setModalEnableAuthErrorOpened(false)} + title={( + + An Error Occurred. + + )} + > + + An error has occurred when trying to enable authentication. Please consult the + {' '} + documentation + {' '} + for more information. + + + + + From 4d8b3281a48e978cb97fe71473825350c2251a3a Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Tue, 6 Aug 2024 11:38:32 -0600 Subject: [PATCH 03/34] Added error response for creatingSnapshots. Opens modal when receiving error. --- .../DataManagementAccordionItem.tsx | 51 +- src/storage/engines/FirebaseStorageEngine.ts | 465 ++++++++++++++---- 2 files changed, 409 insertions(+), 107 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index d461509ab..b70f67542 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -5,12 +5,14 @@ import { useCallback, useEffect, useState } from 'react'; import { IconTrashX, IconRefresh } from '@tabler/icons-react'; import { openConfirmModal } from '@mantine/modals'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; -import { FirebaseStorageEngine } from '../../../storage/engines/FirebaseStorageEngine'; +import { FirebaseStorageEngine, FirebaseError, FirebaseActionResponse } from '../../../storage/engines/FirebaseStorageEngine'; export function DataManagementAccordionItem({ studyId, refresh }: { studyId: string, refresh: () => Promise }) { const [modalArchiveOpened, setModalArchiveOpened] = useState(false); const [modalDeleteSnapshotOpened, setModalDeleteSnapshotOpened] = useState(false); const [modalDeleteLiveOpened, setModalDeleteLiveOpened] = useState(false); + const [modalErrorOpened, setModalErrorOpened] = useState(false); + const [error, setError] = useState(null); const [currentSnapshot, setCurrentSnapshot] = useState(''); @@ -37,20 +39,32 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str const handleCreateSnapshot = async () => { setLoading(true); - await storageEngine.createSnapshot(studyId, false); - refreshSnapshots(); - setLoading(false); - await refresh(); + const createdSnapshot: FirebaseActionResponse = await storageEngine.createSnapshot(studyId, false); + if (createdSnapshot.status === 'SUCCESS') { + refreshSnapshots(); + setLoading(false); + await refresh(); + } else { + setLoading(false); + setError(createdSnapshot.error); + setModalErrorOpened(true); + } }; const handleArchiveData = async () => { setLoading(true); setDeleteValue(''); setModalArchiveOpened(false); - await storageEngine.createSnapshot(studyId, true); - refreshSnapshots(); - setLoading(false); - await refresh(); + const createdSnapshot: FirebaseActionResponse = await storageEngine.createSnapshot(studyId, true); + if (createdSnapshot.status === 'SUCCESS') { + refreshSnapshots(); + setLoading(false); + await refresh(); + } else { + setLoading(false); + setError(createdSnapshot.error); + setModalErrorOpened(true); + } }; const handleRestoreSnapshot = async (snapshot: string) => { @@ -89,7 +103,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,7 +115,7 @@ 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), }); @@ -183,7 +197,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str {/* Position relative keeps the loading overlay only on the list */} - { snapshots.length > 0 + {snapshots.length > 0 ? snapshots.map( (datasetName: string) => ( @@ -297,6 +311,19 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str + + setModalErrorOpened(false)} + title={{error?.title}} + > + {error?.message} + + + + ); } diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index a40dafde6..b280a0900 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -11,27 +11,68 @@ 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 { hash } from './utils'; import { StudyConfig } from '../../parser/types'; +export interface FirebaseError { + title: string; + message: string; + details?: string; +} + +interface FirebaseActionResponseSuccess { + status: 'SUCCESS'; + error?: undefined; +} + +interface FirebaseActionResponseFailed { + status: 'FAILED'; + error: FirebaseError; +} + +export type FirebaseActionResponse = + | FirebaseActionResponseSuccess + | FirebaseActionResponseFailed; + 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 +86,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 +147,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 +179,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 +197,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 +224,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 +281,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 +309,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 +329,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 +356,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 +423,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 +455,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 +495,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 +508,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 +531,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 +577,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 +596,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 +612,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 +638,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}`, + ); + 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 +686,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 +717,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,7 +749,10 @@ 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); @@ -565,11 +760,16 @@ export class FirebaseStorageEngine extends StorageEngine { if (deleteData) { await this.removeSnapshotOrLive(sourceName, false); } - return true; + + return { + status: 'SUCCESS', + }; } async removeSnapshotOrLive(targetName: string, includeMetadata: boolean) { - const targetNameWithPrefix = targetName.startsWith(this.collectionPrefix) ? targetName : `${this.collectionPrefix}${targetName}`; + const targetNameWithPrefix = targetName.startsWith(this.collectionPrefix) + ? targetName + : `${this.collectionPrefix}${targetName}`; await this._deleteDirectory(`${targetNameWithPrefix}/configs`); await this._deleteDirectory(`${targetNameWithPrefix}/participants`); @@ -588,8 +788,11 @@ 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`)); + const matchingCollections = Object.keys(collections).filter( + (directoryName) => directoryName.startsWith( + `${this.collectionPrefix}${studyId}-snapshot`, + ), + ); return matchingCollections.sort().reverse(); } return []; @@ -604,8 +807,14 @@ export class FirebaseStorageEngine extends StorageEngine { // Snapshot current collection await this.createSnapshot(studyId, true); - await this._copyDirectory(`${snapshotName}/configs`, `${originalName}/configs`); - await this._copyDirectory(`${snapshotName}/participants`, `${originalName}/participants`); + await this._copyDirectory( + `${snapshotName}/configs`, + `${originalName}/configs`, + ); + await this._copyDirectory( + `${snapshotName}/participants`, + `${originalName}/participants`, + ); await this._copyDirectory(snapshotName, originalName); await this._copyCollection(snapshotName, originalName); } @@ -685,22 +894,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 +942,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 +984,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 +1007,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 +1032,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 +1049,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 +1064,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); } } From 7eb90923112b019fce9cc3cceba091c7bc5e016f Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Tue, 6 Aug 2024 16:59:38 -0600 Subject: [PATCH 04/34] Added renaming support in FirebaseStorageEngine --- src/storage/engines/FirebaseStorageEngine.ts | 48 +++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index b280a0900..168d86473 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -788,12 +788,31 @@ export class FirebaseStorageEngine extends StorageEngine { if (metadataSnapshot.exists()) { const collections = metadataSnapshot.data(); - const matchingCollections = Object.keys(collections).filter( - (directoryName) => directoryName.startsWith( + const matchingCollections = Object.keys(collections) + .filter((directoryName) => directoryName.startsWith( `${this.collectionPrefix}${studyId}-snapshot`, - ), - ); - return matchingCollections.sort().reverse(); + )) + .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, transformedValue }; + }) + .filter((item) => item.transformedValue !== null); + const sortedCollections = matchingCollections + .sort((a, b) => a.originalName.localeCompare(b.originalName)) + .reverse(); // Reverse the sorted array if needed + return sortedCollections; } return []; } catch (error) { @@ -823,12 +842,29 @@ export class FirebaseStorageEngine extends StorageEngine { async _addDirectoryNameToMetadata(directoryName: string) { try { const metadataDoc = doc(this.firestore, 'metadata', 'collections'); - await setDoc(metadataDoc, { [directoryName]: true }, { merge: true }); + await setDoc( + metadataDoc, + { [directoryName]: { exists: true, name: directoryName } }, + { merge: true }, + ); } catch (error) { console.error('Error adding collection to metadata:', error); } } + async renameSnapshot(directoryName: string, newName: string) { + try { + const metadataDoc = doc(this.firestore, 'metadata', 'collections'); + await setDoc( + metadataDoc, + { [directoryName]: { exists: true, name: newName } }, + { merge: true }, + ); + } catch (error) { + console.error('Error renaming collection in metadata', error); + } + } + async _removeNameFromMetadata(directoryName: string) { try { const metadataDoc = doc(this.firestore, 'metadata', 'collections'); From c014d90732530cc954b650731d57c207182c333d Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Tue, 6 Aug 2024 17:22:18 -0600 Subject: [PATCH 05/34] Added renameModal. Changed snapshots to return entries with originalName and alternateName for indexing purposes --- .../DataManagementAccordionItem.tsx | 68 ++++++++++++++++--- src/storage/engines/FirebaseStorageEngine.ts | 12 +++- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index b70f67542..74d6f7d55 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -2,23 +2,28 @@ import { Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, ActionIcon, Space, } 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, FirebaseError, FirebaseActionResponse } from '../../../storage/engines/FirebaseStorageEngine'; +import { + FirebaseStorageEngine, FirebaseError, 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 [modalErrorOpened, setModalErrorOpened] = useState(false); const [error, setError] = useState(null); 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); @@ -85,6 +90,16 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str await refresh(); }; + const handleRenameSnapshot = async () => { + setLoading(true); + setModalRenameSnapshotOpened(false); + await storageEngine.renameSnapshot(currentSnapshot, renameValue); + setRenameValue(''); + refreshSnapshots(); + setLoading(false); + await refresh(); + }; + const handleDeleteLive = async () => { setLoading(true); setDeleteValue(''); @@ -199,15 +214,24 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str {snapshots.length > 0 ? snapshots.map( - (datasetName: string) => ( - - {datasetName} + (snapshotItem: SnapshotNameItem) => ( + + {snapshotItem.alternateName} + + { setModalRenameSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} + > + + + { openRestoreSnapshotModal(datasetName); }} + onClick={() => { openRestoreSnapshotModal(snapshotItem.originalName); }} > @@ -216,7 +240,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str { setModalDeleteSnapshotOpened(true); setCurrentSnapshot(datasetName); }} + onClick={() => { setModalDeleteSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} > @@ -280,6 +304,34 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str + { setModalRenameSnapshotOpened(false); setRenameValue(''); }} + title={Rename Snapshot} + > + + This will permanently remove this snapshot. This action is + {' '} + irreversible + . + + + setRenameValue(event.target.value)} + /> + + + + + + + { setModalDeleteLiveOpened(false); setDeleteValue(''); }} diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index 168d86473..52cef7dc9 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -64,6 +64,11 @@ export type FirebaseActionResponse = | FirebaseActionResponseSuccess | FirebaseActionResponseFailed; +export interface SnapshotNameItem { + originalName: string; + alternateName: string; +} + type FirebaseStorageObjectType = 'sequenceArray' | 'participantData' | 'config'; type FirebaseStorageObject = T extends 'sequenceArray' @@ -806,9 +811,12 @@ export class FirebaseStorageEngine extends StorageEngine { } else { transformedValue = null; } - return { originalName: directoryName, transformedValue }; + return { + originalName: directoryName, + alternateName: transformedValue, + }; }) - .filter((item) => item.transformedValue !== null); + .filter((item) => item.alternateName !== null); const sortedCollections = matchingCollections .sort((a, b) => a.originalName.localeCompare(b.originalName)) .reverse(); // Reverse the sorted array if needed From c0752db88f8eefd2b747e5b3014f810220a134be Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Tue, 6 Aug 2024 17:28:32 -0600 Subject: [PATCH 06/34] Changed exists to enabled --- .../management/DataManagementAccordionItem.tsx | 10 +++++----- src/storage/engines/FirebaseStorageEngine.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 74d6f7d55..74bc6f2d8 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -309,16 +309,16 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str onClose={() => { setModalRenameSnapshotOpened(false); setRenameValue(''); }} title={Rename Snapshot} > - + {/* This will permanently remove this snapshot. This action is {' '} irreversible . - + */} setRenameValue(event.target.value)} /> @@ -326,8 +326,8 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str - diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index 52cef7dc9..f0524c6dd 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -852,7 +852,7 @@ export class FirebaseStorageEngine extends StorageEngine { const metadataDoc = doc(this.firestore, 'metadata', 'collections'); await setDoc( metadataDoc, - { [directoryName]: { exists: true, name: directoryName } }, + { [directoryName]: { enabled: true, name: directoryName } }, { merge: true }, ); } catch (error) { @@ -865,7 +865,7 @@ export class FirebaseStorageEngine extends StorageEngine { const metadataDoc = doc(this.firestore, 'metadata', 'collections'); await setDoc( metadataDoc, - { [directoryName]: { exists: true, name: newName } }, + { [directoryName]: { enabled: true, name: newName } }, { merge: true }, ); } catch (error) { From e9c6fe554e7534d7eba85776b81587b77d424039 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Wed, 7 Aug 2024 17:32:02 -0600 Subject: [PATCH 07/34] Removed commented code. Refactored slightly. Added --- .../DataManagementAccordionItem.tsx | 75 +++++++------------ src/storage/engines/FirebaseStorageEngine.ts | 58 ++++++++++---- 2 files changed, 74 insertions(+), 59 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 74bc6f2d8..8cc15525b 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -42,42 +42,46 @@ 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; + + const finishSnapshotAction = async () => { + refreshSnapshots(); + setLoading(false); + await refresh(); + }; + + // Generalized snapshot action handler + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const snapshotAction = async (action: FirebaseAction, ...args: any[]) => { setLoading(true); - const createdSnapshot: FirebaseActionResponse = await storageEngine.createSnapshot(studyId, false); - if (createdSnapshot.status === 'SUCCESS') { - refreshSnapshots(); - setLoading(false); - await refresh(); + const response: FirebaseActionResponse = await action(...args); + if (response.status === 'SUCCESS') { + await finishSnapshotAction(); } else { setLoading(false); - setError(createdSnapshot.error); + setError(error); setModalErrorOpened(true); } }; + const handleCreateSnapshot = async () => { + await snapshotAction(storageEngine.createSnapshot, studyId, false); + }; + const handleArchiveData = async () => { - setLoading(true); setDeleteValue(''); setModalArchiveOpened(false); - const createdSnapshot: FirebaseActionResponse = await storageEngine.createSnapshot(studyId, true); - if (createdSnapshot.status === 'SUCCESS') { - refreshSnapshots(); - setLoading(false); - await refresh(); - } else { - setLoading(false); - setError(createdSnapshot.error); - setModalErrorOpened(true); - } + await snapshotAction(storageEngine.createSnapshot, studyId, true); + }; + + const handleRenameSnapshot = async () => { + setModalRenameSnapshotOpened(false); + await snapshotAction(storageEngine.createSnapshot, currentSnapshot, renameValue); }; const handleRestoreSnapshot = async (snapshot: string) => { - setLoading(true); - await storageEngine.restoreSnapshot(studyId, snapshot); - refreshSnapshots(); - setLoading(false); - await refresh(); + await snapshotAction(storageEngine.restoreSnapshot, studyId, snapshot); }; const handleDeleteSnapshot = async () => { @@ -85,19 +89,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str setDeleteValue(''); setModalDeleteSnapshotOpened(false); await storageEngine.removeSnapshotOrLive(currentSnapshot, true); - refreshSnapshots(); - setLoading(false); - await refresh(); - }; - - const handleRenameSnapshot = async () => { - setLoading(true); - setModalRenameSnapshotOpened(false); - await storageEngine.renameSnapshot(currentSnapshot, renameValue); - setRenameValue(''); - refreshSnapshots(); - setLoading(false); - await refresh(); + await finishSnapshotAction(); }; const handleDeleteLive = async () => { @@ -105,9 +97,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str setDeleteValue(''); setModalDeleteLiveOpened(false); await storageEngine.removeSnapshotOrLive(studyId, true); - refreshSnapshots(); - setLoading(false); - await refresh(); + await finishSnapshotAction(); }; const openCreateSnapshotModal = () => openConfirmModal({ @@ -309,13 +299,6 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str onClose={() => { setModalRenameSnapshotOpened(false); setRenameValue(''); }} title={Rename Snapshot} > - {/* - This will permanently remove this snapshot. This action is - {' '} - irreversible - . - */} - { const originalName = `${this.collectionPrefix}${studyId}`; // Snapshot current collection - await this.createSnapshot(studyId, true); + try { + await this.createSnapshot(studyId, true); - 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); + return { + status: 'SUCCESS', + }; + } 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 @@ -857,10 +875,14 @@ export class FirebaseStorageEngine extends StorageEngine { ); } catch (error) { console.error('Error adding collection to metadata:', error); + throw error; } } - async renameSnapshot(directoryName: string, newName: string) { + async renameSnapshot( + directoryName: string, + newName: string, + ): Promise { try { const metadataDoc = doc(this.firestore, 'metadata', 'collections'); await setDoc( @@ -868,8 +890,18 @@ export class FirebaseStorageEngine extends StorageEngine { { [directoryName]: { enabled: true, name: newName } }, { merge: true }, ); + return { + status: 'SUCCESS', + }; } 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.', + }, + }; } } From 1692119d1ce2cef775e4daebaf65b0280aee6ca3 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Wed, 7 Aug 2024 18:39:32 -0600 Subject: [PATCH 08/34] Moved Delete and restore snapshots to also use API-like statuses --- .../DataManagementAccordionItem.tsx | 8 +--- src/storage/engines/FirebaseStorageEngine.ts | 41 +++++++++++++------ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 8cc15525b..288c07b7a 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -85,19 +85,15 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str }; const handleDeleteSnapshot = async () => { - setLoading(true); setDeleteValue(''); setModalDeleteSnapshotOpened(false); - await storageEngine.removeSnapshotOrLive(currentSnapshot, true); - await finishSnapshotAction(); + await snapshotAction(storageEngine.removeSnapshotOrLive, currentSnapshot, true); }; const handleDeleteLive = async () => { - setLoading(true); setDeleteValue(''); setModalDeleteLiveOpened(false); - await storageEngine.removeSnapshotOrLive(studyId, true); - await finishSnapshotAction(); + await snapshotAction(storageEngine.removeSnapshotOrLive, studyId, true); }; const openCreateSnapshotModal = () => openConfirmModal({ diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index c9514a4b8..dc39e79dc 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -771,18 +771,35 @@ export class FirebaseStorageEngine extends StorageEngine { }; } - async removeSnapshotOrLive(targetName: string, includeMetadata: boolean) { - 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); - - if (includeMetadata) { - await this._removeNameFromMetadata(targetNameWithPrefix); + 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); + + if (includeMetadata) { + await this._removeNameFromMetadata(targetNameWithPrefix); + } + return { + status: 'SUCCESS', + }; + } 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.', + }, + }; } } From 0102d7d7c980dab519647e08c465874850cbd0fa Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Wed, 7 Aug 2024 18:50:57 -0600 Subject: [PATCH 09/34] Fixed issue with not binding correct storageEngine context. --- .../management/DataManagementAccordionItem.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 288c07b7a..478ff0d26 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -60,40 +60,40 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str await finishSnapshotAction(); } else { setLoading(false); - setError(error); + setError(response.error); setModalErrorOpened(true); } }; const handleCreateSnapshot = async () => { - await snapshotAction(storageEngine.createSnapshot, studyId, false); + await snapshotAction(storageEngine.createSnapshot.bind(storageEngine), studyId, false); }; const handleArchiveData = async () => { setDeleteValue(''); setModalArchiveOpened(false); - await snapshotAction(storageEngine.createSnapshot, studyId, true); + await snapshotAction(storageEngine.createSnapshot.bind(storageEngine), studyId, true); }; const handleRenameSnapshot = async () => { setModalRenameSnapshotOpened(false); - await snapshotAction(storageEngine.createSnapshot, currentSnapshot, renameValue); + await snapshotAction(storageEngine.createSnapshot.bind(storageEngine), currentSnapshot, renameValue); }; const handleRestoreSnapshot = async (snapshot: string) => { - await snapshotAction(storageEngine.restoreSnapshot, studyId, snapshot); + await snapshotAction(storageEngine.restoreSnapshot.bind(storageEngine), studyId, snapshot); }; const handleDeleteSnapshot = async () => { setDeleteValue(''); setModalDeleteSnapshotOpened(false); - await snapshotAction(storageEngine.removeSnapshotOrLive, currentSnapshot, true); + await snapshotAction(storageEngine.removeSnapshotOrLive.bind(storageEngine), currentSnapshot, true); }; const handleDeleteLive = async () => { setDeleteValue(''); setModalDeleteLiveOpened(false); - await snapshotAction(storageEngine.removeSnapshotOrLive, studyId, true); + await snapshotAction(storageEngine.removeSnapshotOrLive.bind(storageEngine), studyId, true); }; const openCreateSnapshotModal = () => openConfirmModal({ From 736ecf25b71d3a035c6ca4f836c30377dcad56bd Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 08:52:34 -0600 Subject: [PATCH 10/34] Testing out notifications on success --- package.json | 1 + .../DataManagementAccordionItem.tsx | 18 +++-- src/main.tsx | 6 +- src/storage/engines/FirebaseStorageEngine.ts | 13 ++++ yarn.lock | 71 +++++++++++++++++-- 5 files changed, 97 insertions(+), 12 deletions(-) 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/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 478ff0d26..adb1511bf 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -3,6 +3,7 @@ import { } from '@mantine/core'; import { useCallback, useEffect, useState } from 'react'; import { IconTrashX, IconRefresh, IconPencil } from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; import { openConfirmModal } from '@mantine/modals'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; import { @@ -45,19 +46,22 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str // eslint-disable-next-line @typescript-eslint/no-explicit-any type FirebaseAction = (...args: any[]) => Promise; - const finishSnapshotAction = async () => { - refreshSnapshots(); - setLoading(false); - await refresh(); - }; - // Generalized snapshot action handler // eslint-disable-next-line @typescript-eslint/no-explicit-any const snapshotAction = async (action: FirebaseAction, ...args: any[]) => { setLoading(true); const response: FirebaseActionResponse = await action(...args); if (response.status === 'SUCCESS') { - await finishSnapshotAction(); + refreshSnapshots(); + setLoading(false); + await refresh(); + if (response.notification) { + notifications.show({ + title: response.notification.title, + color: response.notification.color ? response.notification.color : undefined, + message: response.notification.message, + }); + } } else { setLoading(false); setError(response.error); diff --git a/src/main.tsx b/src/main.tsx index 32eb06ccb..e9a98efd7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,16 +1,20 @@ 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/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index dc39e79dc..f2de7c0e6 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -50,14 +50,22 @@ export interface FirebaseError { details?: string; } +export interface FirebaseNotification { + title: string; + message: string; + color?: string; +} + interface FirebaseActionResponseSuccess { status: 'SUCCESS'; error?: undefined; + notification?: FirebaseNotification; } interface FirebaseActionResponseFailed { status: 'FAILED'; error: FirebaseError; + notification?: FirebaseNotification; } export type FirebaseActionResponse = @@ -768,6 +776,11 @@ export class FirebaseStorageEngine extends StorageEngine { return { status: 'SUCCESS', + notification: { + message: 'Successfully created snapshot', + title: 'Success!', + color: 'green', + }, }; } 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== From 2327673bd3982595e0b78ecf447a05fd687ed317 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 08:59:47 -0600 Subject: [PATCH 11/34] More notifications adjustments, fix on main. --- .../management/DataManagementAccordionItem.tsx | 2 ++ src/main.tsx | 5 ++--- src/storage/engines/FirebaseStorageEngine.ts | 5 +++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index adb1511bf..1b7d26040 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -60,6 +60,8 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str title: response.notification.title, color: response.notification.color ? response.notification.color : undefined, message: response.notification.message, + position: 'top-center', + style: response.notification.color ? { backgroundColor: response.notification.color } : {}, }); } } else { diff --git a/src/main.tsx b/src/main.tsx index e9a98efd7..9c5915409 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,9 +12,8 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - + + , diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index f2de7c0e6..7b92e451c 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -803,6 +803,11 @@ export class FirebaseStorageEngine extends StorageEngine { } return { status: 'SUCCESS', + notification: { + message: 'Successfully deleted snapshot or live data.', + title: 'Success!', + color: 'green', + }, }; } catch (error) { return { From c175c951c38444509f7f934ba2a0c1af39cdaf99 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 09:13:53 -0600 Subject: [PATCH 12/34] Finished adding notifications for successes. --- .../DataManagementAccordionItem.tsx | 5 +++-- .../management/notify.module.css | 20 +++++++++++++++++++ src/storage/engines/FirebaseStorageEngine.ts | 10 ++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 src/analysis/individualStudy/management/notify.module.css diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 1b7d26040..277de9277 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react'; import { IconTrashX, IconRefresh, IconPencil } from '@tabler/icons-react'; import { notifications } from '@mantine/notifications'; import { openConfirmModal } from '@mantine/modals'; +import classes from './notify.module.css'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; import { FirebaseStorageEngine, FirebaseError, FirebaseActionResponse, SnapshotNameItem, @@ -58,10 +59,10 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str if (response.notification) { notifications.show({ title: response.notification.title, - color: response.notification.color ? response.notification.color : undefined, message: response.notification.message, position: 'top-center', - style: response.notification.color ? { backgroundColor: response.notification.color } : {}, + classNames: classes, + color: response.notification.color ? response.notification.color : 'blue', }); } } else { diff --git a/src/analysis/individualStudy/management/notify.module.css b/src/analysis/individualStudy/management/notify.module.css new file mode 100644 index 000000000..852544acc --- /dev/null +++ b/src/analysis/individualStudy/management/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/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index 7b92e451c..0074a793b 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -885,6 +885,11 @@ export class FirebaseStorageEngine extends StorageEngine { await this._copyCollection(snapshotName, originalName); return { status: 'SUCCESS', + notification: { + message: 'Successfully restored snapshot to live data.', + title: 'Success!', + color: 'green', + }, }; } catch (error) { console.error('Error trying to delete a snapshot', error); @@ -927,6 +932,11 @@ export class FirebaseStorageEngine extends StorageEngine { ); return { status: 'SUCCESS', + notification: { + message: 'Successfully renamed snapshot.', + title: 'Success!', + color: 'green', + }, }; } catch (error) { console.error('Error renaming collection in metadata', error); From 77e6a66e17c0e930a470524d842fa60ddbd58cc3 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 09:27:52 -0600 Subject: [PATCH 13/34] Renaming snapshot pointing to incorrect action --- .../individualStudy/management/DataManagementAccordionItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 277de9277..a5edab1e1 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -84,7 +84,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str const handleRenameSnapshot = async () => { setModalRenameSnapshotOpened(false); - await snapshotAction(storageEngine.createSnapshot.bind(storageEngine), currentSnapshot, renameValue); + await snapshotAction(storageEngine.renameSnapshot.bind(storageEngine), currentSnapshot, renameValue); }; const handleRestoreSnapshot = async (snapshot: string) => { From 155841f84db047b4a9ed36d607547e3f50120359 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 09:43:56 -0600 Subject: [PATCH 14/34] Fixed loading not covering entire scrollable area --- src/analysis/individualStudy/StudyAnalysisTabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 - + From 6f3c1373296e17f7321bea6f72e305720d92a9ba Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 09:52:16 -0600 Subject: [PATCH 15/34] Added option to add multiple notifications on response. --- .../DataManagementAccordionItem.tsx | 18 +++--- src/storage/engines/FirebaseStorageEngine.ts | 64 ++++++++++++------- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index a5edab1e1..f2f603001 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -8,7 +8,7 @@ import { openConfirmModal } from '@mantine/modals'; import classes from './notify.module.css'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; import { - FirebaseStorageEngine, FirebaseError, FirebaseActionResponse, SnapshotNameItem, + FirebaseStorageEngine, FirebaseError, FirebaseActionResponse, SnapshotNameItem, FirebaseNotification, } from '../../../storage/engines/FirebaseStorageEngine'; export function DataManagementAccordionItem({ studyId, refresh }: { studyId: string, refresh: () => Promise }) { @@ -56,13 +56,15 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str refreshSnapshots(); setLoading(false); await refresh(); - if (response.notification) { - notifications.show({ - title: response.notification.title, - message: response.notification.message, - position: 'top-center', - classNames: classes, - color: response.notification.color ? response.notification.color : 'blue', + if (response.notifications) { + response.notifications.forEach((notification: FirebaseNotification) => { + notifications.show({ + title: notification.title, + message: notification.message, + position: 'top-center', + classNames: classes, + color: notification.color ? notification.color : 'blue', + }); }); } } else { diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index 0074a793b..9c26c177c 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -59,13 +59,13 @@ export interface FirebaseNotification { interface FirebaseActionResponseSuccess { status: 'SUCCESS'; error?: undefined; - notification?: FirebaseNotification; + notifications?: FirebaseNotification[]; } interface FirebaseActionResponseFailed { status: 'FAILED'; error: FirebaseError; - notification?: FirebaseNotification; + notifications?: FirebaseNotification[]; } export type FirebaseActionResponse = @@ -776,11 +776,13 @@ export class FirebaseStorageEngine extends StorageEngine { return { status: 'SUCCESS', - notification: { - message: 'Successfully created snapshot', - title: 'Success!', - color: 'green', - }, + notifications: [ + { + message: 'Successfully created snapshot', + title: 'Success!', + color: 'green', + }, + ], }; } @@ -803,11 +805,13 @@ export class FirebaseStorageEngine extends StorageEngine { } return { status: 'SUCCESS', - notification: { - message: 'Successfully deleted snapshot or live data.', - title: 'Success!', - color: 'green', - }, + notifications: [ + { + message: 'Successfully deleted snapshot or live data.', + title: 'Success!', + color: 'green', + }, + ], }; } catch (error) { return { @@ -870,8 +874,19 @@ export class FirebaseStorageEngine extends StorageEngine { ): Promise { const originalName = `${this.collectionPrefix}${studyId}`; // Snapshot current collection + const successNotifications: FirebaseNotification[] = []; try { - await this.createSnapshot(studyId, true); + try { + await this.createSnapshot(studyId, true); + } catch (error) { + console.warn('No live data to capture.'); + successNotifications.push({ + title: 'Empty Dataset', + message: + 'Could not create a snapshot because there was no data to capture.', + color: 'yellow', + }); + } await this._copyDirectory( `${snapshotName}/configs`, @@ -883,13 +898,14 @@ export class FirebaseStorageEngine extends StorageEngine { ); 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', - notification: { - message: 'Successfully restored snapshot to live data.', - title: 'Success!', - color: 'green', - }, + notifications: successNotifications, }; } catch (error) { console.error('Error trying to delete a snapshot', error); @@ -932,11 +948,13 @@ export class FirebaseStorageEngine extends StorageEngine { ); return { status: 'SUCCESS', - notification: { - message: 'Successfully renamed snapshot.', - title: 'Success!', - color: 'green', - }, + notifications: [ + { + message: 'Successfully renamed snapshot.', + title: 'Success!', + color: 'green', + }, + ], }; } catch (error) { console.error('Error renaming collection in metadata', error); From bd483ccd3f700742ce7b922c2fd55ed9947ced6b Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 10:12:56 -0600 Subject: [PATCH 16/34] Added dual notifications for archiving data and restoring snapshots. Removed auto close for yellow or red notifications. --- .../DataManagementAccordionItem.tsx | 1 + src/storage/engines/FirebaseStorageEngine.ts | 49 +++++++++++++------ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index f2f603001..d742397f1 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -64,6 +64,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str position: 'top-center', classNames: classes, color: notification.color ? notification.color : 'blue', + autoClose: notification.color === 'red' || notification.color === 'yellow' ? false : 5000, }); }); } diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index 9c26c177c..75bc8c0cb 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -770,19 +770,34 @@ export class FirebaseStorageEngine extends StorageEngine { await this._copyCollection(sourceName, targetName); await this._addDirectoryNameToMetadata(targetName); + const createSnapshotSuccessNotifications: FirebaseNotification[] = []; 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: 'Sucess!', + message: 'Successfully deleted live data.', + color: 'green', + }); + } } - + createSnapshotSuccessNotifications.push({ + message: 'Successfully created snapshot', + title: 'Success!', + color: 'green', + }); return { status: 'SUCCESS', - notifications: [ - { - message: 'Successfully created snapshot', - title: 'Success!', - color: 'green', - }, - ], + notifications: createSnapshotSuccessNotifications, }; } @@ -876,16 +891,20 @@ export class FirebaseStorageEngine extends StorageEngine { // Snapshot current collection const successNotifications: FirebaseNotification[] = []; try { - try { - await this.createSnapshot(studyId, true); - } catch (error) { + const createSnapshotResponse = await this.createSnapshot(studyId, true); + if (createSnapshotResponse.status === 'FAILED') { console.warn('No live data to capture.'); successNotifications.push({ - title: 'Empty Dataset', - message: - 'Could not create a snapshot because there was no data to capture.', + 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( From 21ca9d6c44b5b562c50e9d1eb5559978d02f9c24 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 10:29:22 -0600 Subject: [PATCH 17/34] Testing out error notifications rather than modals --- .../DataManagementAccordionItem.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index d742397f1..e89ddc070 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -8,7 +8,7 @@ import { openConfirmModal } from '@mantine/modals'; import classes from './notify.module.css'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; import { - FirebaseStorageEngine, FirebaseError, FirebaseActionResponse, SnapshotNameItem, FirebaseNotification, + FirebaseStorageEngine, FirebaseActionResponse, SnapshotNameItem, FirebaseNotification, } from '../../../storage/engines/FirebaseStorageEngine'; export function DataManagementAccordionItem({ studyId, refresh }: { studyId: string, refresh: () => Promise }) { @@ -16,8 +16,8 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str const [modalDeleteSnapshotOpened, setModalDeleteSnapshotOpened] = useState(false); const [modalRenameSnapshotOpened, setModalRenameSnapshotOpened] = useState(false); const [modalDeleteLiveOpened, setModalDeleteLiveOpened] = useState(false); - const [modalErrorOpened, setModalErrorOpened] = useState(false); - const [error, setError] = useState(null); + // const [modalErrorOpened, setModalErrorOpened] = useState(false); + // const [error, setError] = useState(null); const [currentSnapshot, setCurrentSnapshot] = useState(''); @@ -47,6 +47,17 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str // eslint-disable-next-line @typescript-eslint/no-explicit-any type FirebaseAction = (...args: any[]) => Promise; + const showNotification = (title: string, message: string, color: string | undefined) => { + notifications.show({ + title, + message, + position: 'top-center', + classNames: classes, + color: color || 'blue', + autoClose: color === 'red' || color === 'yellow' ? false : 5000, + }); + }; + // Generalized snapshot action handler // eslint-disable-next-line @typescript-eslint/no-explicit-any const snapshotAction = async (action: FirebaseAction, ...args: any[]) => { @@ -58,20 +69,13 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str await refresh(); if (response.notifications) { response.notifications.forEach((notification: FirebaseNotification) => { - notifications.show({ - title: notification.title, - message: notification.message, - position: 'top-center', - classNames: classes, - color: notification.color ? notification.color : 'blue', - autoClose: notification.color === 'red' || notification.color === 'yellow' ? false : 5000, - }); + showNotification(notification.title, notification.message, notification.color); }); } } else { setLoading(false); - setError(response.error); - setModalErrorOpened(true); + showNotification(response.error.title, response.error.message, 'red'); + // setModalErrorOpened(true); } }; @@ -353,7 +357,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str - setModalErrorOpened(false)} title={{error?.title}} @@ -364,7 +368,7 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str Okay - + */} ); } From fe87f3a95a9a09a3bfb5943e2a4d576d3fa455c0 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 10:34:00 -0600 Subject: [PATCH 18/34] Removed error modal -- moved to notifications --- .../management/DataManagementAccordionItem.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index e89ddc070..e1a3f2702 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -16,8 +16,6 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str const [modalDeleteSnapshotOpened, setModalDeleteSnapshotOpened] = useState(false); const [modalRenameSnapshotOpened, setModalRenameSnapshotOpened] = useState(false); const [modalDeleteLiveOpened, setModalDeleteLiveOpened] = useState(false); - // const [modalErrorOpened, setModalErrorOpened] = useState(false); - // const [error, setError] = useState(null); const [currentSnapshot, setCurrentSnapshot] = useState(''); @@ -356,19 +354,6 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str - - {/* setModalErrorOpened(false)} - title={{error?.title}} - > - {error?.message} - - - - */} ); } From 8b7c6d1342f51862b5fa2028a28e8d98e49e495a Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 8 Aug 2024 10:57:55 -0600 Subject: [PATCH 19/34] Fix bug with rejecting participants --- src/storage/engines/FirebaseStorageEngine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index a40dafde6..48830cdb6 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -489,7 +489,7 @@ 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 participantRef = ref(this.storage, `${this.collectionPrefix}${studyId}/participants/${participantId}_participantData`); const participant = await this._getFromFirebaseStorageByRef(participantRef, 'participantData'); try { From a7557aebc8f358802df25a5d3fb44e52ba7f9d44 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 11:15:00 -0600 Subject: [PATCH 20/34] Moved notification to its own module --- .../DataManagementAccordionItem.tsx | 25 ++++++------------- src/storage/engines/FirebaseStorageEngine.ts | 19 ++++++-------- src/utils/notifications.ts | 20 +++++++++++++++ .../management => utils}/notify.module.css | 0 4 files changed, 35 insertions(+), 29 deletions(-) create mode 100644 src/utils/notifications.ts rename src/{analysis/individualStudy/management => utils}/notify.module.css (100%) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index e1a3f2702..9bdd6a119 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -3,12 +3,11 @@ import { } from '@mantine/core'; import { useCallback, useEffect, useState } from 'react'; import { IconTrashX, IconRefresh, IconPencil } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; import { openConfirmModal } from '@mantine/modals'; -import classes from './notify.module.css'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; +import { showNotification, RevisitNotification } from '../../../utils/notifications'; import { - FirebaseStorageEngine, FirebaseActionResponse, SnapshotNameItem, FirebaseNotification, + FirebaseStorageEngine, FirebaseActionResponse, SnapshotNameItem, } from '../../../storage/engines/FirebaseStorageEngine'; export function DataManagementAccordionItem({ studyId, refresh }: { studyId: string, refresh: () => Promise }) { @@ -45,17 +44,6 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str // eslint-disable-next-line @typescript-eslint/no-explicit-any type FirebaseAction = (...args: any[]) => Promise; - const showNotification = (title: string, message: string, color: string | undefined) => { - notifications.show({ - title, - message, - position: 'top-center', - classNames: classes, - color: color || 'blue', - autoClose: color === 'red' || color === 'yellow' ? false : 5000, - }); - }; - // Generalized snapshot action handler // eslint-disable-next-line @typescript-eslint/no-explicit-any const snapshotAction = async (action: FirebaseAction, ...args: any[]) => { @@ -65,15 +53,16 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str refreshSnapshots(); setLoading(false); await refresh(); + // Show all notifications from success. if (response.notifications) { - response.notifications.forEach((notification: FirebaseNotification) => { - showNotification(notification.title, notification.message, notification.color); + response.notifications.forEach((notification: RevisitNotification) => { + showNotification(notification); }); } } else { + // Show returned error as a notification in red. setLoading(false); - showNotification(response.error.title, response.error.message, 'red'); - // setModalErrorOpened(true); + showNotification({ title: response.error.title, message: response.error.message, color: 'red' }); } }; diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index 75bc8c0cb..95ad07cfb 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -41,6 +41,7 @@ import { } from './StorageEngine'; import { ParticipantData } from '../types'; import { ParticipantMetadata, Sequence, StoredAnswer } from '../../store/types'; +import { RevisitNotification } from '../../utils/notifications'; import { hash } from './utils'; import { StudyConfig } from '../../parser/types'; @@ -50,22 +51,18 @@ export interface FirebaseError { details?: string; } -export interface FirebaseNotification { - title: string; - message: string; - color?: 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?: FirebaseNotification[]; + notifications?: RevisitNotification[]; } +// Failed responses never take notifications, only report error. Notifications will be handled in downstream functions. interface FirebaseActionResponseFailed { status: 'FAILED'; error: FirebaseError; - notifications?: FirebaseNotification[]; + notifications?: undefined; } export type FirebaseActionResponse = @@ -770,7 +767,7 @@ export class FirebaseStorageEngine extends StorageEngine { await this._copyCollection(sourceName, targetName); await this._addDirectoryNameToMetadata(targetName); - const createSnapshotSuccessNotifications: FirebaseNotification[] = []; + const createSnapshotSuccessNotifications: RevisitNotification[] = []; if (deleteData) { const removeSnapshotResponse = await this.removeSnapshotOrLive( sourceName, @@ -784,7 +781,7 @@ export class FirebaseStorageEngine extends StorageEngine { }); } else { createSnapshotSuccessNotifications.push({ - title: 'Sucess!', + title: 'Success!', message: 'Successfully deleted live data.', color: 'green', }); @@ -889,7 +886,7 @@ export class FirebaseStorageEngine extends StorageEngine { ): Promise { const originalName = `${this.collectionPrefix}${studyId}`; // Snapshot current collection - const successNotifications: FirebaseNotification[] = []; + const successNotifications: RevisitNotification[] = []; try { const createSnapshotResponse = await this.createSnapshot(studyId, true); if (createSnapshotResponse.status === 'FAILED') { 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/analysis/individualStudy/management/notify.module.css b/src/utils/notify.module.css similarity index 100% rename from src/analysis/individualStudy/management/notify.module.css rename to src/utils/notify.module.css From 87f2fa70ea5d25b79abb73b3abf0497cd489aa24 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 13:51:33 -0600 Subject: [PATCH 21/34] Changed snapshot list to table view --- .../DataManagementAccordionItem.tsx | 100 +++++++++++------- src/storage/engines/FirebaseStorageEngine.ts | 1 - 2 files changed, 64 insertions(+), 37 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index 9bdd6a119..f5919fe15 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -1,5 +1,5 @@ import { - Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, ActionIcon, Space, + Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, ActionIcon, Space, Table, } from '@mantine/core'; import { useCallback, useEffect, useState } from 'react'; import { IconTrashX, IconRefresh, IconPencil } from '@tabler/icons-react'; @@ -121,6 +121,16 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str 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; + }; return ( <> @@ -200,41 +210,59 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str {snapshots.length > 0 - ? snapshots.map( - (snapshotItem: SnapshotNameItem) => ( - - {snapshotItem.alternateName} - - - { setModalRenameSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} - > - - - - - { openRestoreSnapshotModal(snapshotItem.originalName); }} - > - - - - - { setModalDeleteSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} - > - - - - - - ), + ? ( + + + + Snapshot Name + Date Created + Actions + + + + {snapshots.map( + (snapshotItem: SnapshotNameItem) => ( + + {snapshotItem.alternateName} + {getDateFromSnapshotName(snapshotItem.originalName)} + + + { setModalRenameSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} + > + + + + + { openRestoreSnapshotModal(snapshotItem.originalName); }} + > + + + + + + { setModalDeleteSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} + > + + + + + + ), + )} + +
) : No snapshots.}
diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index c8f33e05e..5605e88ca 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -648,7 +648,6 @@ export class FirebaseStorageEngine extends StorageEngine { } async rejectParticipant(studyId: string, participantId: string) { - const studyCollection = collection( this.firestore, `${this.collectionPrefix}${studyId}`, From 9a57e96b5619fe7bfec196ad165db5ec6528e4a7 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Thu, 8 Aug 2024 18:35:12 -0600 Subject: [PATCH 22/34] Added download buttons for snapshot items. --- .../DataManagementAccordionItem.tsx | 78 +++++++++++-------- src/components/downloader/DownloadButtons.tsx | 53 +++++++++---- 2 files changed, 83 insertions(+), 48 deletions(-) diff --git a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx index f5919fe15..74294880a 100644 --- a/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementAccordionItem.tsx @@ -1,14 +1,16 @@ import { - Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, ActionIcon, Space, Table, + Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, Space, Table, } from '@mantine/core'; import { useCallback, useEffect, useState } from 'react'; import { IconTrashX, IconRefresh, IconPencil } from '@tabler/icons-react'; import { openConfirmModal } from '@mantine/modals'; import { useStorageEngine } from '../../../storage/storageEngineHooks'; import { showNotification, RevisitNotification } from '../../../utils/notifications'; +import { DownloadButtons } from '../../../components/downloader/DownloadButtons'; import { FirebaseStorageEngine, FirebaseActionResponse, SnapshotNameItem, } from '../../../storage/engines/FirebaseStorageEngine'; +import { ParticipantData } from '../../../storage/types'; export function DataManagementAccordionItem({ studyId, refresh }: { studyId: string, refresh: () => Promise }) { const [modalArchiveOpened, setModalArchiveOpened] = useState(false); @@ -131,6 +133,12 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str } return null; }; + + const fetchParticipants = async (snapshotName: string): Promise => { + const strippedFilename = snapshotName.slice(snapshotName.indexOf('-') + 1); + const participants = await storageEngine.getAllParticipantsDataByStudy(strippedFilename); + return participants; + }; return ( <> @@ -226,37 +234,43 @@ export function DataManagementAccordionItem({ studyId, refresh }: { studyId: str {snapshotItem.alternateName} {getDateFromSnapshotName(snapshotItem.originalName)} - - { setModalRenameSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} - > - - - - - { openRestoreSnapshotModal(snapshotItem.originalName); }} - > - - - - - - { setModalDeleteSnapshotOpened(true); setCurrentSnapshot(snapshotItem.originalName); }} - > - - - + + + + + + + + + + + + fetchParticipants(snapshotItem.originalName)} studyId={studyId} gap="10px" fileName={snapshotItem.alternateName} /> + ), diff --git a/src/components/downloader/DownloadButtons.tsx b/src/components/downloader/DownloadButtons.tsx index e46012dab..d1a090836 100644 --- a/src/components/downloader/DownloadButtons.tsx +++ b/src/components/downloader/DownloadButtons.tsx @@ -2,26 +2,47 @@ import { Popover, Button, Text, Group, } from '@mantine/core'; import { IconDatabaseExport, IconTableExport } from '@tabler/icons-react'; +import { useState } from 'react'; import { useDisclosure } from '@mantine/hooks'; import { DownloadTidy, download } from './DownloadTidy'; import { ParticipantData } from '../../storage/types'; -export function DownloadButtons({ allParticipants, studyId }: { allParticipants: ParticipantData[]; studyId: string }) { +type ParticipantDataFetcher = ParticipantData[] | (() => Promise); + +export function DownloadButtons({ + allParticipants, studyId, gap, fileName, +}: { allParticipants: ParticipantDataFetcher; studyId: string, gap?: string, fileName?: string }) { const [jsonOpened, { close: closeJson, open: openJson }] = useDisclosure(false); const [csvOpened, { close: closeCsv, open: openCsv }] = useDisclosure(false); const [openDownload, { open, close }] = useDisclosure(false); + const [participants, setParticipants] = useState([]); + + const fetchParticipants = async () => { + const currParticipants = typeof allParticipants === 'function' ? await allParticipants() : allParticipants; + return currParticipants; + }; + + const handleDownloadJSON = async () => { + const currParticipants = await fetchParticipants(); + const currFileName = fileName ? `${fileName}.json` : `${studyId}_all.json`; + download(JSON.stringify(currParticipants, null, 2), currFileName); + }; + + const handleOpenTidy = async () => { + const currParticipants = await fetchParticipants(); + setParticipants(currParticipants); + open(); + }; return ( <> - + - + {/* + */} + + + + {/* Download all participants data as JSON - - - - - + */} + {/* + */} + + + + {/* Download all participants data as a tidy CSV - + */} {openDownload && participants.length > 0 && ( From 1ace659ff9e4ee4cbf50fcf656e1f133194c9e5d Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Fri, 9 Aug 2024 08:37:25 -0600 Subject: [PATCH 24/34] Changed popover to tooltip for analyze and manage study data --- src/analysis/dashboard/StudyCard.tsx | 34 ++++++++++------------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/analysis/dashboard/StudyCard.tsx b/src/analysis/dashboard/StudyCard.tsx index fe4545151..b9641221c 100644 --- a/src/analysis/dashboard/StudyCard.tsx +++ b/src/analysis/dashboard/StudyCard.tsx @@ -1,11 +1,11 @@ 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'; @@ -76,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 ( @@ -88,24 +86,16 @@ export function StudyCard({ studyId, allParticipants }: { studyId: string; allPa - - - - - - - Analyze and manage study data - - + + + From 496e0b2da920c5cd3c70cb3d33a642275ec6867d Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Fri, 9 Aug 2024 08:43:10 -0600 Subject: [PATCH 25/34] Removed commented out code. --- src/components/downloader/DownloadButtons.tsx | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/components/downloader/DownloadButtons.tsx b/src/components/downloader/DownloadButtons.tsx index 4b2301725..1b7d944e4 100644 --- a/src/components/downloader/DownloadButtons.tsx +++ b/src/components/downloader/DownloadButtons.tsx @@ -12,8 +12,6 @@ type ParticipantDataFetcher = ParticipantData[] | (() => Promise([]); @@ -37,44 +35,26 @@ export function DownloadButtons({ return ( <> - {/* - */} - {/* - - Download all participants data as JSON - - */} - {/* - */} - {/* - - Download all participants data as a tidy CSV - - */} {openDownload && participants.length > 0 && ( From 89c8284f6fa36bdb64b580987aff6f9de9cce57f Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Fri, 9 Aug 2024 09:04:31 -0600 Subject: [PATCH 26/34] Changed login page to use notifications rather than error badge --- src/Login.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Login.tsx b/src/Login.tsx index dd4f20b75..d1abdb61e 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,21 @@ 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' }); + // if (setErrorMessage) { + // setErrorMessage(error.message); + // } } finally { setLoading(false); } @@ -35,13 +37,13 @@ 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.'); + // 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 +58,8 @@ export function Login() { Revisit Logo <> To access admin settings, please sign in using your Google account. - + - {errorMessage ? {errorMessage} : null} From 81605284702c7abb7c15203d26f3e13e7171de19 Mon Sep 17 00:00:00 2001 From: Brian Bollen Date: Fri, 9 Aug 2024 09:05:03 -0600 Subject: [PATCH 27/34] Removed commented out code --- src/Login.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Login.tsx b/src/Login.tsx index d1abdb61e..aca7bc418 100644 --- a/src/Login.tsx +++ b/src/Login.tsx @@ -24,9 +24,6 @@ export async function signInWithGoogle(storageEngine: StorageEngine | undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { showNotification({ title: 'Error', message: error.message, color: 'red' }); - // if (setErrorMessage) { - // setErrorMessage(error.message); - // } } finally { setLoading(false); } @@ -42,7 +39,6 @@ export function Login() { 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]); From ce190461aef455a31426be04cf4bfe56d0768377 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Fri, 9 Aug 2024 11:25:44 -0600 Subject: [PATCH 28/34] Add trainingAttempts to component config --- src/parser/StudyConfigSchema.json | 28 ++++++++++++++++++++++++++++ src/parser/types.ts | 2 ++ 2 files changed, 30 insertions(+) diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 6d623065c..62556fe76 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -118,6 +118,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 will not show the correct answer ot the user.", + "type": "number" + }, "type": { "enum": [ "markdown", @@ -490,6 +494,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 will not show the correct answer ot the user.", + "type": "number" + }, "type": { "const": "image", "type": "string" @@ -686,6 +694,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 will not show the correct answer ot the user.", + "type": "number" + }, "type": { "enum": [ "markdown", @@ -889,6 +901,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 will not show the correct answer ot the user.", + "type": "number" + }, "type": { "const": "markdown", "type": "string" @@ -1031,6 +1047,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 will not show the correct answer ot the user.", + "type": "number" + }, "type": { "const": "questionnaire", "type": "string" @@ -1194,6 +1214,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 will not show the correct answer ot the user.", + "type": "number" + }, "type": { "const": "react-component", "type": "string" @@ -1653,6 +1677,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 will not show the correct answer ot the user.", + "type": "number" + }, "type": { "const": "website", "type": "string" diff --git a/src/parser/types.ts b/src/parser/types.ts index f1694d365..5a0d6edee 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -501,6 +501,8 @@ 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; /** 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. */ From 7ffa2ed7bfd54bb0b52244af647676b5bc253545 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Fri, 9 Aug 2024 11:25:57 -0600 Subject: [PATCH 29/34] Clean up next button --- src/components/NextButton.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 (