From 761bea76c4a1778ad774c7b4e7578dd935bb731a Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Mon, 9 Sep 2024 17:40:13 -0400 Subject: [PATCH 01/13] feat: add inital wireframes proposal --- .../src/pages/Study/EditStudyPage.tsx | 68 +++++++- .../Study/components/EditStudyPageHeader.tsx | 6 +- .../components/EditStudyToolbar.styles.ts | 4 +- .../Study/components/EditStudyToolbar.tsx | 149 +++++++++++------- 4 files changed, 166 insertions(+), 61 deletions(-) diff --git a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx index 88d8ef44..9cb18321 100644 --- a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx @@ -1,4 +1,5 @@ -import { Box } from '@mui/material'; +import { ArrowLeft, ArrowRight } from '@mui/icons-material'; +import { Box, Button, Typography } from '@mui/material'; import StateHandlerComponent from 'components/StateHandlerComponent/StateHandlerComponent'; import { useInitProjectStoreIfRequired, @@ -10,7 +11,6 @@ import EditStudyDetails from 'pages/Study/components/EditStudyDetails'; import EditStudyMetadata from 'pages/Study/components/EditStudyMetadata'; import EditStudyPageHeader from 'pages/Study/components/EditStudyPageHeader'; import EditStudySaveButton from 'pages/Study/components/EditStudySaveButton'; -import EditStudySwapVersionButton from 'pages/Study/components/EditStudySwapVersionButton'; import EditStudyPageStyles from 'pages/Study/EditStudyPage.styles'; import { useClearStudyStore, @@ -71,7 +71,69 @@ const EditStudyPage: React.FC = (props) => { - + {/* */} + + + + 2 of 3 + + + diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx index 6c374dfd..49b7f399 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx @@ -1,4 +1,4 @@ -import { Box, Typography } from '@mui/material'; +import { Box, Button, Typography } from '@mui/material'; import DisplayStudyChipLinks from 'components/DisplayStudyChipLinks/DisplayStudyChipLinks'; import EditStudyToolbar from 'pages/Study/components/EditStudyToolbar'; import NeurosynthBreadcrumbs from 'components/NeurosynthBreadcrumbs'; @@ -32,7 +32,7 @@ const EditStudyPageHeader: React.FC = () => { return ( <> - + { /> - + {studyYear && `(${studyYear}).`} {studyName} diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts index f44bf10e..e3e24155 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts @@ -7,9 +7,9 @@ const EditStudyToolbarStyles: Style = { }, toolbarContainer: { position: 'absolute', - right: 'calc(-8% - 18px)', + right: 'calc(-10%)', borderRadius: '4px', - border: '2px solid', + border: '1px solid', borderColor: 'primary.main', }, header: { diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx index d2f2fd7c..7588cee3 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx @@ -2,8 +2,17 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import BookmarkIcon from '@mui/icons-material/Bookmark'; import CheckIcon from '@mui/icons-material/Check'; +import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import DoneAllIcon from '@mui/icons-material/DoneAll'; -import { Box, CircularProgress, IconButton, Tooltip } from '@mui/material'; +import { + Box, + Button, + ButtonGroup, + CircularProgress, + Fab, + IconButton, + Tooltip, +} from '@mui/material'; import { useGetExtractionSummary, useGetStudysetById } from 'hooks'; import { StudyReturn } from 'neurostore-typescript-sdk'; import { EExtractionStatus } from 'pages/Extraction/ExtractionPage'; @@ -199,53 +208,90 @@ const EditStudyToolbar: React.FC<{ isViewOnly?: boolean }> = ({ isViewOnly = fal return ( - - Toolbar - - {!isViewOnly && ( - <> - - {isComplete ? ( - - - - - + {!isViewOnly && ( + + Toolbar + + + {isComplete ? ( + + + + + + + + ) : ( + + + + {percentageComplete}% - - ) : ( - - - - {percentageComplete}% - - - - - )} - - + + + + )} + + + + + + + + + + + + + + {/* = ({ isViewOnly = fal - - - )} - + */} + {/* = ({ isViewOnly = fal : `no previous ${currSelectedChipText} study` } > - {/* tooltip cannot act on a disabled element so we need to add a span here */} = ({ isViewOnly = fal : `no next ${currSelectedChipText} study` } > - {/* tooltip cannot act on a disabled element so we need to add a span here */} = ({ isViewOnly = fal + */} - + )} ); }; From f373f3118ae780587bf15dc3a21df2d1a0cf6261 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Tue, 10 Sep 2024 15:01:40 -0400 Subject: [PATCH 02/13] feat: fixes --- .../src/pages/Study/components/EditStudyToolbar.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx index 7588cee3..ed582c2f 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx @@ -1,9 +1,7 @@ -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import BookmarkIcon from '@mui/icons-material/Bookmark'; import CheckIcon from '@mui/icons-material/Check'; -import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import DoneAllIcon from '@mui/icons-material/DoneAll'; +import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import { Box, Button, @@ -13,9 +11,11 @@ import { IconButton, Tooltip, } from '@mui/material'; -import { useGetExtractionSummary, useGetStudysetById } from 'hooks'; +import GlobalStyles from 'global.styles'; +import { useGetExtractionSummary, useGetStudysetById, useUserCanEdit } from 'hooks'; import { StudyReturn } from 'neurostore-typescript-sdk'; import { EExtractionStatus } from 'pages/Extraction/ExtractionPage'; +import { IProjectPageLocationState } from 'pages/Project/ProjectPage'; import { useProjectExtractionAddOrUpdateStudyListStatus, useProjectExtractionStudyStatus, @@ -29,9 +29,6 @@ import { useStudyId } from 'pages/Study/store/StudyStore'; import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import EditStudyToolbarStyles from './EditStudyToolbar.styles'; -import { IProjectPageLocationState } from 'pages/Project/ProjectPage'; -import { useUserCanEdit } from 'hooks'; -import GlobalStyles from 'global.styles'; const getCurrSelectedChipText = (selectedChip: EExtractionStatus) => { switch (selectedChip) { From 966daa3050f38677a9bd69da32a681cd15383c6f Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Tue, 1 Oct 2024 10:16:17 -0400 Subject: [PATCH 03/13] fix: merge conflicts --- .../neurosynth-frontend/src/pages/Project/ProjectPage.tsx | 2 +- .../neurosynth-frontend/src/pages/Study/EditStudyPage.tsx | 6 ------ .../src/pages/Study/components/EditStudyToolbar.tsx | 8 +++++++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/compose/neurosynth-frontend/src/pages/Project/ProjectPage.tsx b/compose/neurosynth-frontend/src/pages/Project/ProjectPage.tsx index 1a0ae5a5..89d73514 100644 --- a/compose/neurosynth-frontend/src/pages/Project/ProjectPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Project/ProjectPage.tsx @@ -168,7 +168,7 @@ const ProjectPage: React.FC = (props) => { borderTopRightRadius: '6px', borderColor: 'lightgray', borderBottomColor: 'white', - marginBottom: '-1px', + marginBottom: '-2px', }, '.MuibuttonBase-root': {}, transition: 'none', diff --git a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx index 87e3f735..5bc3a522 100644 --- a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx @@ -97,9 +97,6 @@ const EditStudyPage: React.FC = (props) => { > Giant Panda Identification - - Back - @@ -129,9 +126,6 @@ const EditStudyPage: React.FC = (props) => { Reproducibility of fMRI results across four institutions using a spatial workign memory task - - Next - diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx index 5e2a1be4..e623780d 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx @@ -31,6 +31,7 @@ import { useStudyId } from 'pages/Study/store/StudyStore'; import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import EditStudyToolbarStyles from './EditStudyToolbar.styles'; +import SaveIcon from '@mui/icons-material/Save'; const EditStudyToolbar: React.FC<{ isViewOnly?: boolean }> = ({ isViewOnly = false }) => { const navigate = useNavigate(); @@ -201,7 +202,7 @@ const EditStudyToolbar: React.FC<{ isViewOnly?: boolean }> = ({ isViewOnly = fal - + + + + + + {/* From 8e89dcbcaa5b3b2f34f9ae55aad1e60282f04d20 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Tue, 1 Oct 2024 18:27:30 -0400 Subject: [PATCH 04/13] feat: update edit study page --- .../src/helpers/BeforeUnload.helpers.ts | 38 +++++++ .../Extraction/components/ExtractionTable.tsx | 4 +- .../src/pages/Project/store/ProjectStore.ts | 11 +- .../src/pages/Study/EditStudyPage.tsx | 22 +++- .../Study/components/EditStudySaveButton.tsx | 16 ++- .../components/EditStudyToolbar.styles.ts | 3 +- .../Study/components/EditStudyToolbar.tsx | 102 ++++++++++++------ .../src/pages/Study/store/StudyStore.ts | 25 +++-- .../src/stores/AnnotationStore.ts | 5 + 9 files changed, 171 insertions(+), 55 deletions(-) create mode 100644 compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts diff --git a/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts new file mode 100644 index 00000000..22890077 --- /dev/null +++ b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts @@ -0,0 +1,38 @@ +enum EUnloadStatus { + STUDYSTORE = 'study-store-unsaved-changes', + PROJECTSTORE = 'project-store-unsaved-changes', + ANNOTATIONSTORE = 'annotation-store-unsaved-changes', +} + +const onUnloadHandler = (event: BeforeUnloadEvent) => { + return (event.returnValue = 'Are you sure you want to leave?'); +}; + +export const setUnloadHandler = (store: 'project' | 'study' | 'annotation') => { + if (store === 'project') { + window.sessionStorage.setItem(EUnloadStatus.PROJECTSTORE, 'true'); + } else if (store === 'study') { + window.sessionStorage.setItem(EUnloadStatus.STUDYSTORE, 'true'); + } else if (store === 'annotation') { + window.sessionStorage.setItem(EUnloadStatus.ANNOTATIONSTORE, 'true'); + } + if (!window.onbeforeunload) window.onbeforeunload = onUnloadHandler; +}; + +export const unsetUnloadHandler = (store: 'project' | 'study' | 'annotation') => { + if (store === 'project') { + window.sessionStorage.removeItem(EUnloadStatus.PROJECTSTORE); + } else if (store === 'study') { + window.sessionStorage.removeItem(EUnloadStatus.STUDYSTORE); + } else if (store === 'annotation') { + window.sessionStorage.removeItem(EUnloadStatus.ANNOTATIONSTORE); + } + + if ( + window.sessionStorage.getItem(EUnloadStatus.PROJECTSTORE) === null && + window.sessionStorage.getItem(EUnloadStatus.STUDYSTORE) === null && + window.sessionStorage.getItem(EUnloadStatus.ANNOTATIONSTORE) === null + ) { + window.onbeforeunload = null; + } +}; diff --git a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.tsx b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.tsx index 1024be82..fd0f20f1 100644 --- a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.tsx +++ b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.tsx @@ -419,7 +419,7 @@ const ExtractionTable: React.FC = () => { ))} - + {columnFilters.length > 0 ? ( Viewing {table.getFilteredRowModel().rows.length} /{' '} @@ -428,7 +428,7 @@ const ExtractionTable: React.FC = () => { ) : ( Total: {data.length} studies )} - + diff --git a/compose/neurosynth-frontend/src/pages/Project/store/ProjectStore.ts b/compose/neurosynth-frontend/src/pages/Project/store/ProjectStore.ts index 1c398bd8..c0abdf1e 100644 --- a/compose/neurosynth-frontend/src/pages/Project/store/ProjectStore.ts +++ b/compose/neurosynth-frontend/src/pages/Project/store/ProjectStore.ts @@ -29,10 +29,7 @@ import { useParams } from 'react-router-dom'; import API from 'utils/api'; import { create } from 'zustand'; import { TProjectStore } from './ProjectStore.types'; - -const onUnloadHandler = (event: BeforeUnloadEvent) => { - return (event.returnValue = 'Are you sure you want to leave?'); -}; +import { setUnloadHandler, unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; const useProjectStore = create()((set, get) => { return { @@ -148,7 +145,7 @@ const useProjectStore = create()((set, get) => { if (existingTimeout && oldDebouncedStoreData.id === prevId) clearTimeout(existingTimeout); - window.addEventListener('beforeunload', onUnloadHandler); + setUnloadHandler('project'); const newTimeout = setTimeout(async () => { const { data } = await API.NeurosynthServices.ProjectsService.projectsIdGet( @@ -177,7 +174,7 @@ const useProjectStore = create()((set, get) => { { variant: 'error', persist: true } ); } - window.removeEventListener('beforeunload', onUnloadHandler); + unsetUnloadHandler('project'); return; } @@ -248,7 +245,7 @@ const useProjectStore = create()((set, get) => { } }, onSettled: () => { - window.removeEventListener('beforeunload', onUnloadHandler); + unsetUnloadHandler('project'); }, } ); diff --git a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx index 5bc3a522..93536921 100644 --- a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx @@ -73,7 +73,19 @@ const EditStudyPage: React.FC = (props) => { {/* */} - + + + + - - + {/* + + */} + + + ); diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx index b0430818..18170eef 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx @@ -38,6 +38,7 @@ import { storeNotesToDBNotes } from 'stores/AnnotationStore.helpers'; import API from 'utils/api'; import { arrayToMetadata } from './EditStudyMetadata'; import { hasDuplicateStudyAnalysisNames, hasEmptyStudyPoints } from './EditStudySaveButton.helpers'; +import { unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; const EditStudySaveButton: React.FC = React.memo((props) => { const { user } = useAuth0(); @@ -91,6 +92,8 @@ const EditStudySaveButton: React.FC = React.memo((props) => { }); updateAnnotationNotes(updatedNotes); await updateAnnotationInDB(); + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); queryClient.invalidateQueries('studies'); queryClient.invalidateQueries('annotations'); @@ -105,6 +108,8 @@ const EditStudySaveButton: React.FC = React.memo((props) => { const handleUpdateStudyInDB = async () => { try { await updateStudyInDB(annotationId as string); + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); queryClient.invalidateQueries('studies'); queryClient.invalidateQueries('annotations'); @@ -118,6 +123,8 @@ const EditStudySaveButton: React.FC = React.memo((props) => { const handleUpdateAnnotationInDB = async () => { try { await updateAnnotationInDB(); + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); queryClient.invalidateQueries('annotations'); enqueueSnackbar('Annotation saved', { variant: 'success' }); } catch (e) { @@ -227,6 +234,9 @@ const EditStudySaveButton: React.FC = React.memo((props) => { }, }); + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); + navigate(`/projects/${projectId}/extraction/studies/${clonedStudyId}/edit`); enqueueSnackbar('Saved successfully. You are now the owner of this study', { variant: 'success', @@ -271,13 +281,13 @@ const EditStudySaveButton: React.FC = React.memo((props) => { return ( ); diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts index e3e24155..6d4615da 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.styles.ts @@ -7,7 +7,8 @@ const EditStudyToolbarStyles: Style = { }, toolbarContainer: { position: 'absolute', - right: 'calc(-10%)', + right: '-9%', + transform: 'translateX(-8px)', borderRadius: '4px', border: '1px solid', borderColor: 'primary.main', diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx index e623780d..3c7bf072 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx @@ -32,6 +32,12 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import EditStudyToolbarStyles from './EditStudyToolbar.styles'; import SaveIcon from '@mui/icons-material/Save'; +import { + ArrowBack, + ArrowForward, + KeyboardArrowLeft, + KeyboardArrowRight, +} from '@mui/icons-material'; const EditStudyToolbar: React.FC<{ isViewOnly?: boolean }> = ({ isViewOnly = false }) => { const navigate = useNavigate(); @@ -197,48 +203,82 @@ const EditStudyToolbar: React.FC<{ isViewOnly?: boolean }> = ({ isViewOnly = fal )} - - + + + + + + {/* + */} - + + {/* + */} + + + - - - - - {/* diff --git a/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.ts b/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.ts index d4a8a123..aebfbcf5 100644 --- a/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.ts +++ b/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.ts @@ -1,14 +1,9 @@ import { AxiosResponse } from 'axios'; import { IMetadataRowModel } from 'components/EditMetadata/EditMetadata.types'; -import { arrayToMetadata, metadataToArray } from 'pages/Study/components/EditStudyMetadata'; -import { AnalysisReturn, StudyReturn } from 'neurostore-typescript-sdk'; import { setAnalysesInAnnotationAsIncluded } from 'helpers/Annotation.helpers'; -import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; -import API from 'utils/api'; -import { v4 as uuid } from 'uuid'; -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { setUnloadHandler } from 'helpers/BeforeUnload.helpers'; +import { AnalysisReturn, StudyReturn } from 'neurostore-typescript-sdk'; +import { arrayToMetadata, metadataToArray } from 'pages/Study/components/EditStudyMetadata'; import { IStoreAnalysis, IStoreCondition, @@ -19,6 +14,12 @@ import { storeAnalysesToStudyAnalyses, studyAnalysesToStoreAnalyses, } from 'pages/Study/store/StudyStore.helpers'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import API from 'utils/api'; +import { v4 as uuid } from 'uuid'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; export type StudyStoreActions = { initStudyStore: (studyId?: string) => void; @@ -138,6 +139,7 @@ const useStudyStore = create< })); }, updateStudy: (fieldName, value) => { + setUnloadHandler('study'); set((state) => ({ ...state, study: { @@ -222,6 +224,7 @@ const useStudyStore = create< } }, addOrUpdateStudyMetadataRow: (row) => { + setUnloadHandler('study'); set((state) => { const metadataUpdate = [...state.study.metadata]; const foundRowIndex = metadataUpdate.findIndex( @@ -252,6 +255,7 @@ const useStudyStore = create< }); }, deleteStudyMetadataRow: (id) => { + setUnloadHandler('study'); set((state) => { const metadataUpdate = [...state.study.metadata]; const foundRowIndex = metadataUpdate.findIndex((x) => x.metadataKey === id); @@ -273,6 +277,7 @@ const useStudyStore = create< }); }, addOrUpdateAnalysis: (analysis) => { + setUnloadHandler('study'); let createdOrUpdatedAnalysis: IStoreAnalysis; // we do this outside the set func here so that we can return the updated or created analysis @@ -330,6 +335,7 @@ const useStudyStore = create< return createdOrUpdatedAnalysis; }, deleteAnalysis: (analysisId) => { + setUnloadHandler('study'); set((state) => { const updatedAnalyses = [ ...state.study.analyses.filter((x) => x.id !== analysisId), @@ -464,6 +470,7 @@ const useStudyStore = create< }); }, createAnalysisPoints: (analysisId, points, index) => { + setUnloadHandler('study'); set((state) => { const updatedAnalyses = [...state.study.analyses]; const foundAnalysisIndex = updatedAnalyses.findIndex( @@ -505,6 +512,7 @@ const useStudyStore = create< }); }, deleteAnalysisPoints: (analysisId, ids) => { + setUnloadHandler('study'); set((state) => { const updatedAnalyses = [...state.study.analyses]; const foundAnalysisIndex = updatedAnalyses.findIndex( @@ -547,6 +555,7 @@ const useStudyStore = create< }); }, updateAnalysisPoints: (analysisId, pointsToUpdate) => { + setUnloadHandler('study'); set((state) => { const updatedAnalyses = [...state.study.analyses]; const foundAnalysisIndex = updatedAnalyses.findIndex( diff --git a/compose/neurosynth-frontend/src/stores/AnnotationStore.ts b/compose/neurosynth-frontend/src/stores/AnnotationStore.ts index 57000ef8..c9e4bbe6 100644 --- a/compose/neurosynth-frontend/src/stores/AnnotationStore.ts +++ b/compose/neurosynth-frontend/src/stores/AnnotationStore.ts @@ -13,6 +13,7 @@ import { IStoreAnnotation, IStoreNoteCollectionReturn, } from 'stores/AnnotationStore.types'; +import { setUnloadHandler } from 'helpers/BeforeUnload.helpers'; export const useAnnotationStore = create< { @@ -137,6 +138,7 @@ export const useAnnotationStore = create< })); }, updateNotes: (updatedNotes) => { + setUnloadHandler('annotation'); set((state) => ({ ...state, annotation: { @@ -150,6 +152,7 @@ export const useAnnotationStore = create< })); }, updateAnnotationNoteName: (note) => { + setUnloadHandler('annotation'); set((state) => ({ ...state, annotation: { @@ -159,6 +162,7 @@ export const useAnnotationStore = create< })); }, createAnnotationNote: (analysisId, studyId, analysisName) => { + setUnloadHandler('annotation'); set((state) => { if (!state.annotation.notes || !state.annotation.note_keys) return state; @@ -189,6 +193,7 @@ export const useAnnotationStore = create< }); }, deleteAnnotationNote: (analysisId) => { + setUnloadHandler('annotation'); set((state) => ({ ...state, annotation: { From 3c5f1aee17bc03b632bb7c877ad50f1974071da9 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Fri, 4 Oct 2024 15:02:53 -0400 Subject: [PATCH 05/13] feat: finished edit study page --- .../Dialogs/ConfirmationDialog.spec.tsx | 1 - .../components/Dialogs/ConfirmationDialog.tsx | 11 +- .../components/NeurosynthConfirmationChip.tsx | 2 +- .../src/helpers/BeforeUnload.helpers.ts | 39 +- .../src/hooks/external/useGetFullText.tsx | 4 +- .../components/ExtractionTable.helpers.ts | 44 +++ .../Extraction/components/ExtractionTable.tsx | 28 +- .../components/ProjectViewMetaAnalyses.tsx | 11 +- .../src/pages/Study/EditStudyPage.tsx | 123 +++--- .../DisplayExtractionTableState.tsx | 279 ++++++++++--- .../components/EditStudyCompleteButton.tsx | 54 +++ .../components/EditStudySwapVersionButton.tsx | 48 ++- .../Study/components/EditStudyToolbar.tsx | 368 +++++++++--------- .../useSaveStudy.helpers.ts} | 0 .../useSaveStudy.tsx} | 83 ++-- .../src/stores/AnnotationStore.ts | 1 - 16 files changed, 679 insertions(+), 417 deletions(-) create mode 100644 compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.helpers.ts create mode 100644 compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx rename compose/neurosynth-frontend/src/pages/Study/{components/EditStudySaveButton.helpers.ts => hooks/useSaveStudy.helpers.ts} (100%) rename compose/neurosynth-frontend/src/pages/Study/{components/EditStudySaveButton.tsx => hooks/useSaveStudy.tsx} (90%) diff --git a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx index 4467c7de..6b79c70e 100644 --- a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx +++ b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx @@ -103,7 +103,6 @@ describe('ConfirmationDialog', () => { onCloseDialog={mockOnClose} confirmText="confirm" rejectText="reject" - data={{ data: 'test-data' }} /> ); diff --git a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx index 3fa9b23a..be9bbcf5 100644 --- a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx +++ b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx @@ -13,12 +13,11 @@ import React, { useMemo } from 'react'; export interface IConfirmationDialog { isOpen: boolean; - onCloseDialog: (confirm: boolean | undefined, data?: any) => void; + onCloseDialog: (confirm: boolean | undefined) => void; dialogTitle: string; dialogMessage?: JSX.Element | string; confirmText?: string; rejectText?: string; - data?: any; } const ConfirmationDialog: React.FC = (props) => { @@ -33,13 +32,13 @@ const ConfirmationDialog: React.FC = (props) => { }, [props.dialogMessage]); return ( - props.onCloseDialog(undefined, props.data)}> + props.onCloseDialog(undefined)}> {props.dialogTitle} - props.onCloseDialog(undefined, props.data)}> + props.onCloseDialog(undefined)}> @@ -49,7 +48,7 @@ const ConfirmationDialog: React.FC = (props) => { - - - 2 of 3 - - - - {/* - */} - - + + + diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx index fb4f1cc7..fe88e675 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx @@ -1,70 +1,233 @@ -import { Box, Chip, Typography } from '@mui/material'; -import { ColumnFiltersState, SortingState } from '@tanstack/react-table'; -import { useGetStudysetById } from 'hooks'; -import { useProjectExtractionStudysetId, useProjectId } from 'pages/Project/store/ProjectStore'; -import { useMemo } from 'react'; +import { ArrowLeft, ArrowRight } from '@mui/icons-material'; +import { Box, Button, Tooltip, Typography } from '@mui/material'; +import { useGetStudyById, useGetStudysetById, useUserCanEdit } from 'hooks'; +import { retrieveExtractionTableState } from 'pages/Extraction/components/ExtractionTable.helpers'; +import { + useProjectExtractionStudysetId, + useProjectId, + useProjectUser, +} from 'pages/Project/store/ProjectStore'; +import { useStudyId } from '../store/StudyStore'; +import { hasUnsavedStudyChanges, unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; const DisplayExtractionTableState: React.FC = (props) => { const projectId = useProjectId(); + const studyId = useStudyId(); const studysetId = useProjectExtractionStudysetId(); - const { data } = useGetStudysetById(studysetId); - const { columnFilters, sorting, studies } = useMemo(() => { - try { - const state = window.sessionStorage.getItem(`${projectId}-extraction-table`); - const parsedState = JSON.parse(state || '{}') as { - columnFilters: ColumnFiltersState; - sorting: SortingState; - studies: string[]; - }; - if (!state) { - return { - columnFilters: [], - sorting: [], - studies: [], - }; + const { data } = useGetStudysetById(studysetId, false); + const extractionTableState = retrieveExtractionTableState(projectId); + const thisStudyIndex = (extractionTableState?.studies || []).indexOf(studyId || ''); + const prevStudyId = extractionTableState?.studies[thisStudyIndex - 1]; + const nextStudyId = extractionTableState?.studies[thisStudyIndex + 1]; + + const { data: prevStudy, isLoading: prevStudyIsLoading } = useGetStudyById(prevStudyId); + const { data: nextStudy, isLoading: nextStudyIsLoading } = useGetStudyById(nextStudyId); + + const [confirmationDialogState, setConfirmationDialogState] = useState<{ + isOpen: boolean; + action: 'PREV' | 'NEXT' | undefined; + }>({ + isOpen: false, + action: undefined, + }); + + const navigate = useNavigate(); + + const user = useProjectUser(); + const canEdit = useUserCanEdit(user ?? undefined); + + const handleMoveToPreviousStudy = () => { + if (!prevStudyId) throw new Error('no previous study'); + + const hasUnsavedChanges = hasUnsavedStudyChanges(); + if (hasUnsavedChanges) { + setConfirmationDialogState({ + isOpen: true, + action: 'PREV', + }); + return; + } + + canEdit + ? navigate(`/projects/${projectId}/extraction/studies/${prevStudyId}/edit`) + : navigate(`/projects/${projectId}/extraction/studies/${prevStudyId}`); + }; + + const handleMoveToNextStudy = () => { + if (!nextStudyId) throw new Error('no next study'); + + const hasUnsavedChanges = hasUnsavedStudyChanges(); + if (hasUnsavedChanges) { + setConfirmationDialogState({ + isOpen: true, + action: 'NEXT', + }); + return; + } + + canEdit + ? navigate(`/projects/${projectId}/extraction/studies/${nextStudyId}/edit`) + : navigate(`/projects/${projectId}/extraction/studies/${nextStudyId}`); + }; + + const handleConfirmationDialogClose = (ok: boolean | undefined) => { + if (!ok) { + setConfirmationDialogState({ + isOpen: false, + action: undefined, + }); + } else { + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); + switch (confirmationDialogState.action) { + case 'PREV': + handleMoveToPreviousStudy(); + break; + case 'NEXT': + handleMoveToNextStudy(); + break; } + setConfirmationDialogState({ + isOpen: false, + action: undefined, + }); + } + }; + + const filterStr = (extractionTableState?.columnFilters || []).reduce((acc, curr, index) => { + if (index === 0) { + return `Filtering by: ${curr.id}: ${curr.value}`; + } + return `${acc}, ${curr.id}: ${curr.value}`; + }, ''); - return { - columnFilters: parsedState.columnFilters, - sorting: parsedState.sorting, - studies: parsedState.studies, - }; - } catch (e) { - return { - columnFilters: [], - sorting: [], - studies: [], - }; + const sortingStr = (extractionTableState?.sorting || []).reduce((acc, curr, index) => { + if (index === 0) { + return `Sorting by ${curr.id}: ${curr.desc ? 'desc' : 'asc'}`; } - }, [projectId]); + return `${acc}, ${curr.id}: ${curr.desc ? 'desc' : 'asc'}`; + }, ''); return ( - - {columnFilters - .filter((filter) => !!filter.value) - .map((filter) => ( - - ))} - {sorting.map((sort) => ( - - ))} - - ({studies.length} / {data?.studies?.length || 0} studies) - + + + {prevStudyId ? ( + + + + ) : ( + + )} + + {filterStr && ( + + {filterStr} + + )} + {sortingStr && ( + + {sortingStr} + + )} + + ) + } + > + + + {thisStudyIndex + 1} of {(extractionTableState?.studies || []).length} + + ({data?.studies?.length || 0} total) + + + + {(extractionTableState?.columnFilters || []).length > 0 && ( + <>{(extractionTableState?.columnFilters || []).length} filters + )} + {(extractionTableState?.sorting || []).map((sorting) => ( + <> + {(extractionTableState?.columnFilters || []).length > 0 ? ', ' : ''} + sorting by {sorting.id} + + ))} + + + + {nextStudyId ? ( + + + + ) : ( + + )} ); }; diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx new file mode 100644 index 00000000..837c9fba --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx @@ -0,0 +1,54 @@ +import { Box, Button } from '@mui/material'; +import { EExtractionStatus } from 'pages/Extraction/ExtractionPage'; +import { + useProjectExtractionAddOrUpdateStudyListStatus, + useProjectExtractionStudyStatus, +} from 'pages/Project/store/ProjectStore'; +import React from 'react'; +import { useParams } from 'react-router-dom'; +import useSaveStudy from '../hooks/useSaveStudy'; +import LoadingButton from 'components/Buttons/LoadingButton'; + +const EditStudyCompleteButton: React.FC = React.memo((props) => { + const { studyId } = useParams<{ studyId: string }>(); + const { isLoading, hasEdits, handleSave } = useSaveStudy(); + const extractionStatus = useProjectExtractionStudyStatus(studyId || ''); + const updateStudyListStatus = useProjectExtractionAddOrUpdateStudyListStatus(); + + const handleSaveAndComplete = async () => { + let clonedId: string | undefined; + if (hasEdits) { + clonedId = await handleSave(); // this will only save if there are changes + } + if (extractionStatus?.status !== EExtractionStatus.COMPLETED) { + updateStudyListStatus(clonedId || studyId || '', EExtractionStatus.COMPLETED); + } + }; + + return ( + + + + ); +}); + +export default EditStudyCompleteButton; diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx index 4713f12d..640a325b 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx @@ -1,7 +1,16 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; -import { Button, ButtonGroup, ListItem, ListItemButton, Menu, Typography } from '@mui/material'; -import LoadingButton from 'components/Buttons/LoadingButton'; +import { + Box, + Button, + ButtonGroup, + CircularProgress, + ListItem, + ListItemButton, + Menu, + Tooltip, + Typography, +} from '@mui/material'; import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; import { setAnalysesInAnnotationAsIncluded } from 'helpers/Annotation.helpers'; import { lastUpdatedAtSortFn } from 'helpers/utils'; @@ -9,6 +18,7 @@ import { useGetStudysetById, useUpdateStudyset } from 'hooks'; import useGetBaseStudyById from 'hooks/studies/useGetBaseStudyById'; import { StudyReturn } from 'neurostore-typescript-sdk'; import { useSnackbar } from 'notistack'; +import { updateExtractionTableStateInStorage } from 'pages/Extraction/components/ExtractionTable.helpers'; import { useProjectExtractionReplaceStudyListStatusId, useProjectExtractionStudysetId, @@ -88,6 +98,7 @@ const EditStudySwapVersionButton: React.FC = (props) => { }, }); updateStudyListStatusWithNewStudyId(studyId, versionToSwapTo); + updateExtractionTableStateInStorage(projectId, studyId, versionToSwapTo); await setAnalysesInAnnotationAsIncluded(annotationId); navigate(`/projects/${projectId}/extraction/studies/${versionToSwapTo}/edit`); @@ -122,16 +133,20 @@ const EditStudySwapVersionButton: React.FC = (props) => { return ( <> - } - text="Switch study version" - > + + + + + { open={open} onClose={handleCloseNavMenu} anchorEl={anchorEl} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'right' }} > {baseStudyVersions.map((baseStudyVersion) => { const isCurrentlySelected = baseStudyVersion.id === studyId; @@ -165,7 +180,10 @@ const EditStudySwapVersionButton: React.FC = (props) => { return ( - + - - - - {/* - */} + - - {/* - */} + + + + - - - - - - {/* - - + - - - - + - */} - {/* - - - + + + + - - - - - - - - - + + + + - - - - - */} + {/* need this box as a wrapper because tooltip will not act on a disabled element */} + + + + + + )} diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.helpers.ts b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.helpers.ts similarity index 100% rename from compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.helpers.ts rename to compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.helpers.ts diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx similarity index 90% rename from compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx rename to compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx index 18170eef..336c0b84 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx @@ -1,14 +1,5 @@ -import LoadingButton from 'components/Buttons/LoadingButton'; -import { AnalysisReturn, StudyRequest } from 'neurostore-typescript-sdk'; -import { useSnackbar } from 'notistack'; -import { - useProjectExtractionAnnotationId, - useProjectExtractionReplaceStudyListStatusId, - useProjectExtractionStudysetId, - useProjectId, -} from 'pages/Project/store/ProjectStore'; - import { useAuth0 } from '@auth0/auth0-react'; +import { unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; import { useCreateStudy, useGetStudysetById, @@ -16,16 +7,15 @@ import { useUpdateStudyset, } from 'hooks'; import { STUDYSET_QUERY_STRING } from 'hooks/studysets/useGetStudysets'; +import { AnalysisReturn, StudyRequest } from 'neurostore-typescript-sdk'; +import { useSnackbar } from 'notistack'; import { - useStudy, - useStudyAnalyses, - useStudyHasBeenEdited, - useStudyUser, - useUpdateStudyInDB, - useUpdateStudyIsLoading, -} from 'pages/Study/store/StudyStore'; -import { storeAnalysesToStudyAnalyses } from 'pages/Study/store/StudyStore.helpers'; -import React, { useState } from 'react'; + useProjectExtractionAnnotationId, + useProjectExtractionReplaceStudyListStatusId, + useProjectExtractionStudysetId, + useProjectId, +} from 'pages/Project/store/ProjectStore'; +import { useState } from 'react'; import { useQueryClient } from 'react-query'; import { useNavigate } from 'react-router-dom'; import { useUpdateAnnotationInDB, useUpdateAnnotationNotes } from 'stores/AnnotationStore.actions'; @@ -36,11 +26,20 @@ import { } from 'stores/AnnotationStore.getters'; import { storeNotesToDBNotes } from 'stores/AnnotationStore.helpers'; import API from 'utils/api'; -import { arrayToMetadata } from './EditStudyMetadata'; -import { hasDuplicateStudyAnalysisNames, hasEmptyStudyPoints } from './EditStudySaveButton.helpers'; -import { unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; +import { arrayToMetadata } from '../components/EditStudyMetadata'; +import { + useStudy, + useStudyAnalyses, + useStudyHasBeenEdited, + useStudyUser, + useUpdateStudyInDB, + useUpdateStudyIsLoading, +} from '../store/StudyStore'; +import { storeAnalysesToStudyAnalyses } from '../store/StudyStore.helpers'; +import { hasDuplicateStudyAnalysisNames, hasEmptyStudyPoints } from './useSaveStudy.helpers'; +import { updateExtractionTableStateInStorage } from 'pages/Extraction/components/ExtractionTable.helpers'; -const EditStudySaveButton: React.FC = React.memo((props) => { +const useSaveStudy = () => { const { user } = useAuth0(); const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); @@ -60,7 +59,6 @@ const EditStudySaveButton: React.FC = React.memo((props) => { const updateStudyInDB = useUpdateStudyInDB(); // annotation stuff const updateAnnotationIsLoading = useUpdateAnnotationIsLoading(); - const annotationHasBeenEdited = useAnnotationIsEdited(); const notes = useAnnotationNotes(); const annotationIsEdited = useAnnotationIsEdited(); const updateAnnotationNotes = useUpdateAnnotationNotes(); @@ -133,14 +131,14 @@ const EditStudySaveButton: React.FC = React.memo((props) => { } }; - const handleUpdateDB = () => { + const handleUpdateDB = async () => { try { if (studyHasBeenEdited && annotationIsEdited) { - handleUpdateBothInDB(); + await handleUpdateBothInDB(); } else if (studyHasBeenEdited) { - handleUpdateStudyInDB(); + await handleUpdateStudyInDB(); } else if (annotationIsEdited) { - handleUpdateAnnotationInDB(); + await handleUpdateAnnotationInDB(); } } catch (e) { console.error(e); @@ -208,6 +206,7 @@ const EditStudySaveButton: React.FC = React.memo((props) => { // 4. update the project as this keeps track of completion status of studies replaceStudyWithNewClonedStudy(storeStudy.id, clonedStudyId); + updateExtractionTableStateInStorage(projectId, storeStudy.id, clonedStudyId); // 5. as this is a completely new study, that we've just created, the annotations are cleared. // We need to update the annotations with our latest changes, and associate newly created analyses with their corresponding analysis changes. @@ -241,6 +240,8 @@ const EditStudySaveButton: React.FC = React.memo((props) => { enqueueSnackbar('Saved successfully. You are now the owner of this study', { variant: 'success', }); + + return clonedStudyId; } catch (e) { enqueueSnackbar( 'We encountered an error saving your study. Please contact the neurosynth-compose team', @@ -269,28 +270,20 @@ const EditStudySaveButton: React.FC = React.memo((props) => { const currentUserOwnsThisStudy = (studyOwnerUser || null) === (user?.sub || undefined); if (currentUserOwnsThisStudy) { - handleUpdateDB(); + await handleUpdateDB(); } else { if (studyHasBeenEdited) { - handleClone(); + return await handleClone(); } else { - handleUpdateDB(); + await handleUpdateDB(); } } }; - return ( - - ); -}); + const isLoading = updateStudyIsLoading || updateAnnotationIsLoading || isCloning; + const hasEdits = studyHasBeenEdited || annotationIsEdited; + + return { isLoading, hasEdits, handleSave }; +}; -export default EditStudySaveButton; +export default useSaveStudy; diff --git a/compose/neurosynth-frontend/src/stores/AnnotationStore.ts b/compose/neurosynth-frontend/src/stores/AnnotationStore.ts index c9e4bbe6..723bccb1 100644 --- a/compose/neurosynth-frontend/src/stores/AnnotationStore.ts +++ b/compose/neurosynth-frontend/src/stores/AnnotationStore.ts @@ -152,7 +152,6 @@ export const useAnnotationStore = create< })); }, updateAnnotationNoteName: (note) => { - setUnloadHandler('annotation'); set((state) => ({ ...state, annotation: { From c65d0d18fabc1e32215f71726e697573747feb90 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Mon, 7 Oct 2024 13:27:28 -0400 Subject: [PATCH 06/13] feat: added feedback --- .../DisplayExtractionTableState.tsx | 6 ++--- .../components/EditStudyCompleteButton.tsx | 19 ++++++-------- .../components/EditStudySwapVersionButton.tsx | 4 +-- .../Study/components/EditStudyToolbar.tsx | 25 ++++++++++++++++--- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx index fe88e675..9774614a 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx @@ -98,10 +98,8 @@ const DisplayExtractionTableState: React.FC = (props) => { }; const filterStr = (extractionTableState?.columnFilters || []).reduce((acc, curr, index) => { - if (index === 0) { - return `Filtering by: ${curr.id}: ${curr.value}`; - } - return `${acc}, ${curr.id}: ${curr.value}`; + if (index === 0) return `Filtering by: ${curr.id}: ${curr.value || 'All'}`; + return `${acc}, ${curr.id}: ${curr.value || 'All'}`; }, ''); const sortingStr = (extractionTableState?.sorting || []).reduce((acc, curr, index) => { diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx index 837c9fba..70478b8a 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx @@ -1,4 +1,5 @@ -import { Box, Button } from '@mui/material'; +import { Box } from '@mui/material'; +import LoadingButton from 'components/Buttons/LoadingButton'; import { EExtractionStatus } from 'pages/Extraction/ExtractionPage'; import { useProjectExtractionAddOrUpdateStudyListStatus, @@ -7,7 +8,6 @@ import { import React from 'react'; import { useParams } from 'react-router-dom'; import useSaveStudy from '../hooks/useSaveStudy'; -import LoadingButton from 'components/Buttons/LoadingButton'; const EditStudyCompleteButton: React.FC = React.memo((props) => { const { studyId } = useParams<{ studyId: string }>(); @@ -28,23 +28,18 @@ const EditStudyCompleteButton: React.FC = React.memo((props) => { return ( diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx index 640a325b..527760b8 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx @@ -137,13 +137,13 @@ const EditStudySwapVersionButton: React.FC = (props) => { diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx index fd6e44e9..5fd751ed 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx @@ -1,4 +1,4 @@ -import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material'; +import { Check, KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material'; import BookmarkIcon from '@mui/icons-material/Bookmark'; import DoneAllIcon from '@mui/icons-material/DoneAll'; import QuestionMark from '@mui/icons-material/QuestionMark'; @@ -259,11 +259,11 @@ const EditStudyToolbar: React.FC<{ isViewOnly?: boolean }> = ({ isViewOnly = fal > + + + From 73e00448f6230e38b809cb92b5a7fd1174a02f52 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Mon, 14 Oct 2024 19:59:29 -0400 Subject: [PATCH 07/13] fix: feedback updats --- .../components/Dialogs/ConfirmationDialog.tsx | 4 +- .../src/components/NeurosynthBreadcrumbs.tsx | 48 +++++++- .../src/pages/Extraction/ExtractionPage.tsx | 72 +++++++----- .../components/ProjectExtractionStepCard.tsx | 2 +- .../components/ProjectViewMetaAnalyses.tsx | 20 ++-- .../src/pages/Study/EditStudyPage.tsx | 6 +- .../Study/components/EditStudyPageHeader.tsx | 12 +- .../components/EditStudySwapVersionButton.tsx | 106 ++++++++++++++---- .../Study/components/EditStudyToolbar.tsx | 48 ++++---- 9 files changed, 224 insertions(+), 94 deletions(-) diff --git a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx index be9bbcf5..a0ed28cf 100644 --- a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx +++ b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx @@ -9,13 +9,13 @@ import { IconButton, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; -import React, { useMemo } from 'react'; +import React, { ReactNode, useMemo } from 'react'; export interface IConfirmationDialog { isOpen: boolean; onCloseDialog: (confirm: boolean | undefined) => void; dialogTitle: string; - dialogMessage?: JSX.Element | string; + dialogMessage?: ReactNode | string; confirmText?: string; rejectText?: string; } diff --git a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx index 01491116..ecc7d5f3 100644 --- a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx +++ b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx @@ -1,6 +1,8 @@ import { Box, Breadcrumbs, Link, Typography } from '@mui/material'; -import React from 'react'; -import { NavLink } from 'react-router-dom'; +import React, { useState } from 'react'; +import { NavLink, useNavigate } from 'react-router-dom'; +import ConfirmationDialog from './Dialogs/ConfirmationDialog'; +import { hasUnsavedChanges, hasUnsavedStudyChanges } from 'helpers/BeforeUnload.helpers'; interface INeurosynthBreadcrumbs { link: string; @@ -10,8 +12,46 @@ interface INeurosynthBreadcrumbs { const NeurosynthBreadcrumbs: React.FC<{ breadcrumbItems: INeurosynthBreadcrumbs[] }> = React.memo( (props) => { + const [confirmationDialogState, setConfirmationDialogState] = useState({ + isOpen: false, + navigationLink: '', + }); + const navigate = useNavigate(); + + const handleNavigate = (link: string) => { + const hasUnsavedChanges = hasUnsavedStudyChanges(); + if (hasUnsavedChanges) { + setConfirmationDialogState({ + isOpen: true, + navigationLink: link, + }); + } else { + navigate(link); + } + }; + + const handleCloseConfirmationDialog = (ok: boolean | undefined) => { + if (ok) { + navigate(confirmationDialogState.navigationLink); + } + + setConfirmationDialogState({ + isOpen: false, + navigationLink: '', + }); + }; + return ( + + {props.breadcrumbItems.map((breadcrumb, index) => breadcrumb.isCurrentPage ? ( @@ -34,6 +74,10 @@ const NeurosynthBreadcrumbs: React.FC<{ breadcrumbItems: INeurosynthBreadcrumbs[ key={index} component={NavLink} to={breadcrumb.link} + onClick={(e) => { + e.preventDefault(); + handleNavigate(breadcrumb.link); + }} sx={{ fontSize: '1.25rem', cursor: 'pointer', diff --git a/compose/neurosynth-frontend/src/pages/Extraction/ExtractionPage.tsx b/compose/neurosynth-frontend/src/pages/Extraction/ExtractionPage.tsx index 395d3593..0b487b33 100644 --- a/compose/neurosynth-frontend/src/pages/Extraction/ExtractionPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Extraction/ExtractionPage.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Typography } from '@mui/material'; +import { Box, Button, Tooltip, Typography } from '@mui/material'; import NeurosynthBreadcrumbs from 'components/NeurosynthBreadcrumbs'; import ProjectIsLoadingText from 'components/ProjectIsLoadingText'; import StateHandlerComponent from 'components/StateHandlerComponent/StateHandlerComponent'; @@ -86,17 +86,13 @@ const ExtractionPage: React.FC = (props) => { }; const handleMoveToSpecificationPhase = () => { - if (canEditMetaAnalyses) { - navigate(`/projects/${projectId}/meta-analyses`); - } else { - navigate(`/projects/${projectId}/project`, { - state: { - projectPage: { - scrollToMetaAnalysisProceed: true, - }, - } as IProjectPageLocationState, - }); - } + navigate(`/projects/${projectId}/project`, { + state: { + projectPage: { + scrollToMetaAnalysisProceed: true, + }, + } as IProjectPageLocationState, + }); }; const isReadyToMoveToNextStep = useMemo( @@ -105,6 +101,17 @@ const ExtractionPage: React.FC = (props) => { [extractionSummary] ); + const percentageCompleteString = useMemo((): string => { + if (extractionSummary.total === 0) return '0 / 0'; + return `${extractionSummary.completed} / ${extractionSummary.total}`; + }, [extractionSummary.completed, extractionSummary.total]); + + const percentageComplete = useMemo((): number => { + if (extractionSummary.total === 0) return 0; + const percentageComplete = (extractionSummary.completed / extractionSummary.total) * 100; + return Math.floor(percentageComplete); + }, [extractionSummary.completed, extractionSummary.total]); + return ( @@ -133,28 +140,43 @@ const ExtractionPage: React.FC = (props) => { - {isReadyToMoveToNextStep && ( - - )} + + + + + + {showReconcilePrompt && } diff --git a/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx b/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx index a27442e6..5acb968e 100644 --- a/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx +++ b/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx @@ -141,7 +141,7 @@ const ProjectExtractionStepCard: React.FC<{ disabled: boolean }> = ({ disabled } display: allStudiesAreComplete ? 'none' : 'block', }} onClick={() => setMarkAllAsCompleteConfirmationDialogIsOpen(true)} - color="success" + color="info" disabled={disabled} > Mark all as complete diff --git a/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx b/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx index 5692d6e2..fdea32ba 100644 --- a/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx +++ b/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx @@ -1,21 +1,22 @@ import { Add } from '@mui/icons-material'; import { Box, Button, Typography } from '@mui/material'; +import CreateMetaAnalysisSpecificationDialogBase from 'pages/MetaAnalysis/components/CreateMetaAnalysisSpecificationDialogBase'; import StateHandlerComponent from 'components/StateHandlerComponent/StateHandlerComponent'; import { useGetMetaAnalysesByIds, useGuard } from 'hooks'; -import useUserCanEdit from 'hooks/useUserCanEdit'; -import { MetaAnalysisReturn } from 'neurosynth-compose-typescript-sdk'; -import CreateMetaAnalysisSpecificationDialogBase from 'pages/MetaAnalysis/components/CreateMetaAnalysisSpecificationDialogBase'; -import ProjectViewMetaAnalysis from 'pages/Project/components/ProjectViewMetaAnalysis'; import { useProjectId, useProjectMetaAnalyses, useProjectMetaAnalysisCanEdit, useProjectUser, } from 'pages/Project/store/ProjectStore'; +import { MetaAnalysisReturn } from 'neurosynth-compose-typescript-sdk'; import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import ProjectViewMetaAnalysis from 'pages/Project/components/ProjectViewMetaAnalysis'; +import useUserCanEdit from 'hooks/useUserCanEdit'; const ProjectViewMetaAnalyses: React.FC = () => { - const projectId = useProjectId(); + const { projectId } = useParams<{ projectId: string }>(); const projectUser = useProjectUser(); const canEdit = useUserCanEdit(projectUser || undefined); const projectMetaAnalyses = useProjectMetaAnalyses() || []; @@ -31,12 +32,15 @@ const ProjectViewMetaAnalyses: React.FC = () => { } const { data = [], isLoading, isError } = useGetMetaAnalysesByIds(metaAnalysisIds); const canEditMetaAnalyses = useProjectMetaAnalysisCanEdit(); + const projectIdFromProject = useProjectId(); const [createMetaAnalysisDialogIsOpen, setCreateMetaAnalysisDialogIsOpen] = useState(false); useGuard( - `/projects/${projectId}/edit`, - 'you must finish the meta-analysis creation process to view this page', - projectId !== undefined ? false : !canEditMetaAnalyses + `/projects/${projectId}/project`, + 'You must finish the meta-analysis creation process to view this page', + projectIdFromProject === undefined || projectId !== projectIdFromProject + ? false + : !canEditMetaAnalyses ); return ( diff --git a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx index aee488be..7509af29 100644 --- a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx @@ -1,5 +1,7 @@ import { Box, Button } from '@mui/material'; +import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; import StateHandlerComponent from 'components/StateHandlerComponent/StateHandlerComponent'; +import { hasUnsavedStudyChanges, unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; import { useInitProjectStoreIfRequired, useProjectExtractionAnnotationId, @@ -17,13 +19,11 @@ import { useStudyId, } from 'pages/Study/store/StudyStore'; import { useEffect, useState } from 'react'; -import { Link, useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useClearAnnotationStore, useInitAnnotationStore } from 'stores/AnnotationStore.actions'; import { useAnnotationId, useGetAnnotationIsLoading } from 'stores/AnnotationStore.getters'; import DisplayExtractionTableState from './components/DisplayExtractionTableState'; import EditStudyCompleteButton from './components/EditStudyCompleteButton'; -import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; -import { hasUnsavedStudyChanges, unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; const EditStudyPage: React.FC = (props) => { const { projectId, studyId } = useParams<{ projectId: string; studyId: string }>(); diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx index 49b7f399..16073c78 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx @@ -1,21 +1,19 @@ -import { Box, Button, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import DisplayStudyChipLinks from 'components/DisplayStudyChipLinks/DisplayStudyChipLinks'; -import EditStudyToolbar from 'pages/Study/components/EditStudyToolbar'; import NeurosynthBreadcrumbs from 'components/NeurosynthBreadcrumbs'; import ProjectIsLoadingText from 'components/ProjectIsLoadingText'; import { useProjectId, useProjectName } from 'pages/Project/store/ProjectStore'; +import EditStudyToolbar from 'pages/Study/components/EditStudyToolbar'; import { - useStudyId, + useStudyAuthors, useStudyLastUpdated, useStudyName, - useStudyYear, - useStudyAuthors, useStudyUsername, + useStudyYear, } from 'pages/Study/store/StudyStore'; import { useMemo } from 'react'; const EditStudyPageHeader: React.FC = () => { - const studyId = useStudyId(); const projectId = useProjectId(); const studyName = useStudyName(); const studyYear = useStudyYear(); @@ -53,7 +51,7 @@ const EditStudyPageHeader: React.FC = () => { }, { text: studyName || '', - link: `/projects/${projectId}/extraction/studies/${studyId}/edit`, + link: '', isCurrentPage: true, }, ]} diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx index 527760b8..598e1b3e 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx @@ -4,7 +4,6 @@ import { Box, Button, ButtonGroup, - CircularProgress, ListItem, ListItemButton, Menu, @@ -12,7 +11,9 @@ import { Typography, } from '@mui/material'; import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; +import ProgressLoader from 'components/ProgressLoader'; import { setAnalysesInAnnotationAsIncluded } from 'helpers/Annotation.helpers'; +import { hasUnsavedStudyChanges, unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; import { lastUpdatedAtSortFn } from 'helpers/utils'; import { useGetStudysetById, useUpdateStudyset } from 'hooks'; import useGetBaseStudyById from 'hooks/studies/useGetBaseStudyById'; @@ -25,7 +26,7 @@ import { useProjectId, } from 'pages/Project/store/ProjectStore'; import { useStudyBaseStudyId, useStudyId } from 'pages/Study/store/StudyStore'; -import { useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAnnotationId } from 'stores/AnnotationStore.getters'; @@ -46,6 +47,7 @@ const EditStudySwapVersionButton: React.FC = (props) => { const annotationId = useAnnotationId(); const [isSwapping, setIsSwapping] = useState(false); + const [unsavedChangesConfirmationDialog, setUnsavedChangesConfirmationDialog] = useState(false); const [confirmationDialogState, setConfirmationDialogState] = useState<{ isOpen: boolean; selectedVersion?: string; @@ -66,13 +68,22 @@ const EditStudySwapVersionButton: React.FC = (props) => { if (confirm) { handleSwapStudy(confirmationDialogState.selectedVersion); } - setConfirmationDialogState((prev) => ({ - ...prev, + setConfirmationDialogState({ isOpen: false, selectedVersion: undefined, - })); + }); }; + /** + * Handle swapping the current study being edited with another version. + * The selected version is confirmed by the user in a confirmation dialog. + * If confirmed, the studyset is updated to replace the current study with the selected version. + * The studylist status is updated to reflect the new study version. + * The extraction table state in storage is updated to point to the new study version. + * The analyses in the annotation are set to be included. + * The user is redirected to the edit page of the new study version. + * @param {string} versionToSwapTo - the id of the version to swap to + */ const handleSwapStudy = async (versionToSwapTo?: string) => { if (!annotationId || !studyId || !studysetId || !versionToSwapTo || !studyset?.studies) return; @@ -114,16 +125,35 @@ const EditStudySwapVersionButton: React.FC = (props) => { } }; - const handleSelectVersion = (versionId: string | undefined) => { + const handleCloseUnsavedChangesDialog = (ok: boolean | undefined) => { + if (ok) { + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); + } + setUnsavedChangesConfirmationDialog(false); + handleCloseConfirmationDialog(ok); + }; + + const handleUnsavedChanges = (ok: boolean | undefined) => { + if (ok) { + const hasUnsavedChanges = hasUnsavedStudyChanges(); + if (hasUnsavedChanges) { + setConfirmationDialogState((prev) => ({ ...prev, isOpen: false })); + setUnsavedChangesConfirmationDialog(true); + return; + } + } + handleCloseConfirmationDialog(ok); + }; + + const handleSwitchVersion = (versionId: string | undefined) => { if (!versionId) return; if (versionId === studyId) { handleCloseNavMenu(); return; } - setConfirmationDialogState({ - isOpen: true, - selectedVersion: versionId, - }); + + setConfirmationDialogState({ isOpen: true, selectedVersion: versionId }); }; const baseStudyVersions = useMemo(() => { @@ -141,9 +171,19 @@ const EditStudySwapVersionButton: React.FC = (props) => { onClick={handleButtonPress} size="small" variant="outlined" - sx={{ width: '40px', minWidth: '40px', height: '40px' }} + sx={{ + width: '40px', + maxWidth: '40px', + minWidth: '40px', + height: '40px', + padding: 0, + }} > - {isSwapping ? : } + {isSwapping ? ( + + ) : ( + + )} @@ -152,7 +192,7 @@ const EditStudySwapVersionButton: React.FC = (props) => { dialogMessage={ <> - You are switching from version {studyId} to version + You are switching from version {studyId} to version{' '} {confirmationDialogState.selectedVersion || ''} @@ -161,10 +201,17 @@ const EditStudySwapVersionButton: React.FC = (props) => { } - onCloseDialog={handleCloseConfirmationDialog} + onCloseDialog={handleUnsavedChanges} isOpen={confirmationDialogState.isOpen} rejectText="Cancel" /> + { anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'right' }} > - {baseStudyVersions.map((baseStudyVersion) => { - const isCurrentlySelected = baseStudyVersion.id === studyId; - const username = baseStudyVersion.username - ? baseStudyVersion.username - : 'neurosynth'; + {baseStudyVersions.map((version) => { + const isCurrentlySelected = version.id === studyId; + const username = version.username ? version.username : 'neurosynth'; + const lastUpdated = new Date( + version.updated_at || version.created_at || '' + ).toLocaleString(); return ( - + - + {table.getHeaderGroups().map((headerGroup) => ( diff --git a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx index 0636a61c..57dd1e2c 100644 --- a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx +++ b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx @@ -8,7 +8,16 @@ export const ExtractionTableAuthorCell: React.FC { const value = props.getValue(); - return {value}; + let shortName = value; + const authorsList = (value || '').split(','); + if (authorsList.length > 1) { + shortName = `${authorsList[0]}., et al.`; + } + return ( + {value} : null}> + {shortName} + + ); }; export const ExtractionTableAuthorHeader: React.FC< diff --git a/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx b/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx index 5acb968e..a27442e6 100644 --- a/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx +++ b/compose/neurosynth-frontend/src/pages/Project/components/ProjectExtractionStepCard.tsx @@ -141,7 +141,7 @@ const ProjectExtractionStepCard: React.FC<{ disabled: boolean }> = ({ disabled } display: allStudiesAreComplete ? 'none' : 'block', }} onClick={() => setMarkAllAsCompleteConfirmationDialogIsOpen(true)} - color="info" + color="success" disabled={disabled} > Mark all as complete diff --git a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx index 7509af29..2aa0eb87 100644 --- a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx @@ -87,44 +87,47 @@ const EditStudyPage: React.FC = (props) => { !studyStoreId || !annotationStoreId || getStudyIsLoading || getAnnotationIsLoading } > - - - - - - - - - - - + + + + + + + - - - - - + + + + + + + + + + + diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx index 9774614a..982bfa91 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx @@ -135,8 +135,9 @@ const DisplayExtractionTableState: React.FC = (props) => { { ) : ( - + )} { alignItems: 'center', }} > - + {thisStudyIndex + 1} of {(extractionTableState?.studies || []).length} ({data?.studies?.length || 0} total) - + {(extractionTableState?.columnFilters || []).length > 0 && ( <>{(extractionTableState?.columnFilters || []).length} filters )} @@ -211,8 +212,9 @@ const DisplayExtractionTableState: React.FC = (props) => { { ) : ( - + )} ); diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyAnalysisPointsHotTable.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyAnalysisPointsHotTable.tsx index 83aa450d..70200821 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyAnalysisPointsHotTable.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyAnalysisPointsHotTable.tsx @@ -243,7 +243,7 @@ const EditStudyAnalysisPointsHotTable: React.FC<{ analysisId?: string; readOnly? onDeleteRows={handleDeleteRows} /> - + { return ( { const updateStudyListStatusWithNewStudyId = useProjectExtractionReplaceStudyListStatusId(); const studysetId = useProjectExtractionStudysetId(); const { data: studyset } = useGetStudysetById(studysetId, false); + const updateStudyByField = useUpdateStudyDetails(); const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); @@ -109,6 +114,8 @@ const EditStudySwapVersionButton: React.FC = (props) => { }, }); updateStudyListStatusWithNewStudyId(studyId, versionToSwapTo); + updateStudyByField('id', versionToSwapTo); + unsetUnloadHandler('study'); updateExtractionTableStateInStorage(projectId, studyId, versionToSwapTo); await setAnalysesInAnnotationAsIncluded(annotationId); @@ -230,24 +237,27 @@ const EditStudySwapVersionButton: React.FC = (props) => { ; +}; + +export default MockEditStudySwapVersionButton; diff --git a/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx new file mode 100644 index 00000000..a0a5dadd --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx @@ -0,0 +1,7 @@ +const useSaveStudy = jest.fn().mockReturnValue({ + isLoading: false, + hasEditse: false, + handleSave: jest.fn(), +}); + +export default useSaveStudy; diff --git a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx new file mode 100644 index 00000000..745cf30a --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx @@ -0,0 +1,21 @@ +import { render } from '@testing-library/react'; +import useSaveStudy from './useSaveStudy'; + +// Using a dummy component in order to test a custom hook +const DummyComponent = () => { + const { isLoading, hasEdits, handleSave } = useSaveStudy(); + return ( +
+
{isLoading}
+
{hasEdits}
+ +
+ ); +}; + +describe('useSaveStudy hook', () => { + it('should work', () => { + render(); + expect(true).toBeFalsy(); + }); +}); diff --git a/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts b/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts index ebec882a..b092937b 100644 --- a/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts +++ b/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts @@ -4,4 +4,8 @@ const useStudyName = jest.fn().mockResolvedValue('test-study-name'); const useProjectId = jest.fn().mockReturnValue('project-id'); -export { useStudyId, useStudyName, useProjectId }; +const useStudyBaseStudyId = jest.fn().mockReturnValue('base-study-id'); + +const useUpdateStudyDetails = jest.fn(); + +export { useStudyId, useStudyName, useProjectId, useStudyBaseStudyId, useUpdateStudyDetails }; diff --git a/compose/neurosynth-frontend/src/testing/mockData.ts b/compose/neurosynth-frontend/src/testing/mockData.ts index b954e260..17782a6f 100644 --- a/compose/neurosynth-frontend/src/testing/mockData.ts +++ b/compose/neurosynth-frontend/src/testing/mockData.ts @@ -5,6 +5,7 @@ import { PointReturn, ConditionReturn, AnalysisReturn, + BaseStudyReturn, } from 'neurostore-typescript-sdk'; import { NeurostoreAnnotation } from 'utils/api'; @@ -455,6 +456,34 @@ const mockStudies: () => StudyReturn[] = () => [ }, ]; +const mockBaseStudy: () => BaseStudyReturn = () => ({ + id: '3V8TUXsUAMna', + user: null, + username: null, + created_at: '2023-06-21T22:17:27.973390+00:00', + updated_at: '2023-08-24T14:30:22.320233+00:00', + metadata: null, + versions: [ + { + id: 'LhVcFRWQnYnm', + user: null, + username: null, + created_at: '2023-05-20T00:26:49.948975+00:00', + updated_at: '2023-06-21T22:17:27.973390+00:00', + source: 'neuroquery', + }, + ], + name: 'Abnormal regional homogeneity as potential imaging biomarker for psychosis risk syndrome: a resting-state fMRI study and support vector machine analysis', + description: null, + publication: null, + doi: null, + pmid: '27272341', + pmcid: null, + authors: null, + year: null, + level: 'group', +}); + export { mockConditions, mockWeights, @@ -467,4 +496,5 @@ export { mockStudies, mockStudysetNested, mockStudysetNotNested, + mockBaseStudy, }; From cb60f3c3221d71f916ce63f4c1969f28189e2161 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Wed, 16 Oct 2024 23:01:43 -0400 Subject: [PATCH 10/13] fix: finished tests --- .../src/__mocks__/notistack.ts | 8 +- .../src/__mocks__/react-query.ts | 5 + .../Dialogs/__mocks__/ConfirmationDialog.tsx | 10 +- .../components/NeurosynthBreadcrumbs.spec.tsx | 61 ++++++- .../src/components/NeurosynthBreadcrumbs.tsx | 2 +- .../src/helpers/BeforeUnload.helpers.spec.ts | 87 +++++++++- .../src/helpers/BeforeUnload.helpers.ts | 2 +- .../helpers/__mocks__/Annotation.helpers.tsx | 2 + .../src/hooks/__mocks__/index.ts | 66 +++++--- .../__mocks__/ExtractionTable.helpers.ts | 3 + .../Project/store/__mocks__/ProjectStore.ts | 2 +- .../DisplayExtractionTableState.spec.tsx | 127 +++++++++++++- .../DisplayExtractionTableState.tsx | 2 +- .../EditStudySwapVersionButton.spec.tsx | 87 +++++++++- .../pages/Study/hooks/useSaveStudy.helpers.ts | 4 +- .../pages/Study/hooks/useSaveStudy.spec.tsx | 157 +++++++++++++++++- .../src/pages/Study/hooks/useSaveStudy.tsx | 2 +- .../pages/Study/store/__mocks__/StudyStore.ts | 30 +++- .../__mocks__/AnnotationStore.actions.ts | 4 + .../__mocks__/AnnotationStore.getters.ts | 19 +++ .../src/testing/mockData.ts | 77 +++++++++ .../src/utils/__mocks__/api.ts | 2 +- 22 files changed, 705 insertions(+), 54 deletions(-) create mode 100644 compose/neurosynth-frontend/src/__mocks__/react-query.ts create mode 100644 compose/neurosynth-frontend/src/helpers/__mocks__/Annotation.helpers.tsx create mode 100644 compose/neurosynth-frontend/src/pages/Extraction/components/__mocks__/ExtractionTable.helpers.ts create mode 100644 compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.actions.ts create mode 100644 compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.getters.ts diff --git a/compose/neurosynth-frontend/src/__mocks__/notistack.ts b/compose/neurosynth-frontend/src/__mocks__/notistack.ts index 2e356903..4d8418e6 100644 --- a/compose/neurosynth-frontend/src/__mocks__/notistack.ts +++ b/compose/neurosynth-frontend/src/__mocks__/notistack.ts @@ -1,7 +1,5 @@ -const useSnackbar = () => { - return { - enqueueSnackbar: jest.fn(), - }; -}; +const useSnackbar = jest.fn().mockReturnValue({ + enqueueSnackbar: jest.fn(), +}); export { useSnackbar }; diff --git a/compose/neurosynth-frontend/src/__mocks__/react-query.ts b/compose/neurosynth-frontend/src/__mocks__/react-query.ts new file mode 100644 index 00000000..53063b50 --- /dev/null +++ b/compose/neurosynth-frontend/src/__mocks__/react-query.ts @@ -0,0 +1,5 @@ +const useQueryClient = jest.fn().mockReturnValue({ + invalidateQueries: jest.fn(), +}); + +export { useQueryClient }; diff --git a/compose/neurosynth-frontend/src/components/Dialogs/__mocks__/ConfirmationDialog.tsx b/compose/neurosynth-frontend/src/components/Dialogs/__mocks__/ConfirmationDialog.tsx index 8dc70b81..01e1f501 100644 --- a/compose/neurosynth-frontend/src/components/Dialogs/__mocks__/ConfirmationDialog.tsx +++ b/compose/neurosynth-frontend/src/components/Dialogs/__mocks__/ConfirmationDialog.tsx @@ -5,10 +5,14 @@ const mockConfirmationDialog: React.FC = (props) => { <> {props.isOpen && (
+

{props.dialogTitle}

+
{props.dialogMessage}
+ > + {props.confirmText} + + > + {props.rejectText} +
)} diff --git a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx index 07a251d8..1d53b724 100644 --- a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx +++ b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx @@ -1,9 +1,66 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import NeurosynthBreadcrumbs from './NeurosynthBreadcrumbs'; +import userEvent from '@testing-library/user-event'; +import { useNavigate } from 'react-router-dom'; +import { setUnloadHandler } from 'helpers/BeforeUnload.helpers'; + +jest.mock('react-router-dom'); +jest.mock('components/Dialogs/ConfirmationDialog'); describe('NeurosynthBreadcrumbs Component', () => { it('should render', () => { render(); - expect(true).toBeFalsy(); // TODO finish these tests + }); + + it('should navigate when clicked', () => { + render( + + ); + + userEvent.click(screen.getByText('Page')); + expect(useNavigate()).toHaveBeenCalledWith('/page'); + }); + + it('should open the confirmation dialog', () => { + setUnloadHandler('study'); + render( + + ); + + userEvent.click(screen.getByText('Page')); + + expect(screen.getByTestId('mock-confirmation-dialog')).toBeInTheDocument(); + }); + + it('should not route when the dialog is cancelled', () => { + setUnloadHandler('annotation'); + render( + + ); + + userEvent.click(screen.getByText('Page')); + expect(screen.getByTestId('mock-confirmation-dialog')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('deny-close-confirmation')); + expect(useNavigate()).not.toHaveBeenCalled(); + }); + + it('should route when the dialog is accepted', () => { + setUnloadHandler('study'); + render( + + ); + + userEvent.click(screen.getByText('Page')); + expect(screen.getByTestId('mock-confirmation-dialog')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('accept-close-confirmation')); + expect(useNavigate()).toHaveBeenCalledWith('/page'); }); }); diff --git a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx index a568adb4..b164ed7f 100644 --- a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx +++ b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.tsx @@ -2,7 +2,7 @@ import { Box, Breadcrumbs, Link, Typography } from '@mui/material'; import { hasUnsavedStudyChanges } from 'helpers/BeforeUnload.helpers'; import React, { useState } from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; -import ConfirmationDialog from './Dialogs/ConfirmationDialog'; +import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; interface INeurosynthBreadcrumbs { link: string; diff --git a/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts index 41a945a4..48208ded 100644 --- a/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts +++ b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts @@ -1,5 +1,88 @@ +import { + EUnloadStatus, + hasUnsavedChanges, + setUnloadHandler, + unsetUnloadHandler, +} from './BeforeUnload.helpers'; + describe('BeforeUnload helpers', () => { - it('should set unload handler', () => { - expect(true).toBeFalsy(); + beforeEach(() => { + jest.clearAllMocks(); + window.sessionStorage.clear(); + }); + + it('should set the unload handler for the project store', () => { + const spy = jest.spyOn(window, 'addEventListener'); + setUnloadHandler('project'); + + expect(window.sessionStorage.getItem(EUnloadStatus.PROJECTSTORE)).toBe('true'); + expect(spy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('unload', expect.any(Function)); + }); + + it('should set the unload handler for the study store', () => { + const spy = jest.spyOn(window, 'addEventListener'); + setUnloadHandler('study'); + + expect(window.sessionStorage.getItem(EUnloadStatus.STUDYSTORE)).toBe('true'); + expect(spy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('unload', expect.any(Function)); + }); + + it('should set the unload handler for the annotation store', () => { + const spy = jest.spyOn(window, 'addEventListener'); + setUnloadHandler('annotation'); + + expect(window.sessionStorage.getItem(EUnloadStatus.ANNOTATIONSTORE)).toBe('true'); + expect(spy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('unload', expect.any(Function)); + }); + + it('should remove the unload handler when all stores are cleared', () => { + const spy = jest.spyOn(window, 'removeEventListener'); + setUnloadHandler('project'); + setUnloadHandler('study'); + setUnloadHandler('annotation'); + + unsetUnloadHandler('project'); + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); + + expect(window.sessionStorage.getItem(EUnloadStatus.PROJECTSTORE)).toBe(null); + expect(window.sessionStorage.getItem(EUnloadStatus.STUDYSTORE)).toBe(null); + expect(window.sessionStorage.getItem(EUnloadStatus.ANNOTATIONSTORE)).toBe(null); + expect(spy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('unload', expect.any(Function)); + }); + + it('should not remove the unload handler if there are still unsaved changes', () => { + const spy = jest.spyOn(window, 'removeEventListener'); + setUnloadHandler('project'); + setUnloadHandler('study'); + + unsetUnloadHandler('project'); + + expect(window.sessionStorage.getItem(EUnloadStatus.PROJECTSTORE)).toBe(null); + expect(window.sessionStorage.getItem(EUnloadStatus.STUDYSTORE)).toBe('true'); + expect(spy).not.toHaveBeenCalled(); // The handler should not be removed + }); + + it('should return true when there are unsaved changes in the project store', () => { + setUnloadHandler('project'); + expect(hasUnsavedChanges()).toBe(true); + }); + + it('should return true when there are unsaved study changes', () => { + setUnloadHandler('study'); + expect(hasUnsavedChanges()).toBe(true); + }); + + it('should return true when there are unsaved annotation changes', () => { + setUnloadHandler('annotation'); + expect(hasUnsavedChanges()).toBe(true); + }); + + it('should return false when there are no unsaved changes', () => { + expect(hasUnsavedChanges()).toBe(false); }); }); diff --git a/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts index 6eefd2a4..54aec36c 100644 --- a/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts +++ b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts @@ -1,4 +1,4 @@ -enum EUnloadStatus { +export enum EUnloadStatus { STUDYSTORE = 'study-store-unsaved-changes', PROJECTSTORE = 'project-store-unsaved-changes', ANNOTATIONSTORE = 'annotation-store-unsaved-changes', diff --git a/compose/neurosynth-frontend/src/helpers/__mocks__/Annotation.helpers.tsx b/compose/neurosynth-frontend/src/helpers/__mocks__/Annotation.helpers.tsx new file mode 100644 index 00000000..6a836e46 --- /dev/null +++ b/compose/neurosynth-frontend/src/helpers/__mocks__/Annotation.helpers.tsx @@ -0,0 +1,2 @@ +const setAnalysesInAnnotationAsIncluded = jest.fn(); +export { setAnalysesInAnnotationAsIncluded }; diff --git a/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts b/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts index e622528f..3efb1d9d 100644 --- a/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts +++ b/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts @@ -1,12 +1,13 @@ +import useInputValidation from 'hooks/useInputValidation'; // don't need to mock this as it isn't making any api calls import { mockAnnotations, + mockBaseStudy, mockConditions, mockStudy, mockStudysetNested, + mockStudysetNotNested, mockStudysets, } from 'testing/mockData'; -import useInputValidation from 'hooks/useInputValidation'; // don't need to mock this as it isn't making any api calls -import { isError } from 'cypress/types/lodash'; const useUpdateAnalysis = jest.fn().mockReturnValue({ isLoading: false, @@ -88,6 +89,7 @@ const useUpdateStudyset = jest.fn().mockReturnValue({ isLoading: false, isError: false, mutate: jest.fn(), + mutateAsync: jest.fn().mockReturnValue(mockStudysets()), }); const useUpdateStudy = jest.fn().mockReturnValue({ @@ -120,16 +122,18 @@ const useGetExtractionSummary = jest.fn().mockReturnValue({ total: 0, }); -const useGetStudysetById = jest.fn().mockReturnValue({ - isLoading: false, - isError: false, - data: mockStudysetNested(), +const useGetStudysetById = jest.fn().mockImplementation((studysetId: string, isNested: boolean) => { + return { + isLoading: false, + isError: false, + data: isNested ? mockStudysetNested() : mockStudysetNotNested(), + }; }); const useGetBaseStudyById = jest.fn().mockReturnValue({ isLoading: false, isError: false, - data: mockStudy(), + data: mockBaseStudy(), }); const useGetFullText = jest.fn().mockReturnValue({ @@ -138,6 +142,20 @@ const useGetFullText = jest.fn().mockReturnValue({ data: '', }); +const useCreateStudy = jest.fn().mockReturnValue({ + isLoading: false, + mutate: jest.fn(), + mutateAsync: jest.fn().mockReturnValue({ + data: mockStudy(), + }), +}); + +const useUpdateAnnotationById = jest.fn().mockReturnValue({ + isLoading: false, + mutate: jest.fn(), + mutateAsync: jest.fn(), +}); + const useIsMounted = () => { return { __esModule: true, @@ -151,28 +169,30 @@ const useIsMounted = () => { const useUserCanEdit = jest.fn().mockReturnValue(true); export { + useCreateAnalysis, + useCreateCondition, useCreateMetaAnalysis, + useCreatePoint, + useCreateProject, + useCreateStudy, + useCreateStudyset, useDeleteAnalysis, - useUpdateAnalysis, - useCreateCondition, + useDeletePoint, + useDeleteProject, + useGetAnnotationsByStudysetId, + useGetBaseStudyById, useGetConditions, + useGetExtractionSummary, + useGetFullText, useGetStudyById, - useCreatePoint, - useUpdatePoint, - useDeletePoint, - useCreateAnalysis, + useGetStudysetById, + useGetStudysets, useInputValidation, useIsMounted, - useUpdateStudyset, - useCreateStudyset, - useGetStudysets, + useUpdateAnalysis, + useUpdateAnnotationById, + useUpdatePoint, useUpdateStudy, - useGetAnnotationsByStudysetId, - useCreateProject, - useDeleteProject, - useGetExtractionSummary, - useGetStudysetById, - useGetFullText, + useUpdateStudyset, useUserCanEdit, - useGetBaseStudyById, }; diff --git a/compose/neurosynth-frontend/src/pages/Extraction/components/__mocks__/ExtractionTable.helpers.ts b/compose/neurosynth-frontend/src/pages/Extraction/components/__mocks__/ExtractionTable.helpers.ts new file mode 100644 index 00000000..993d3383 --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Extraction/components/__mocks__/ExtractionTable.helpers.ts @@ -0,0 +1,3 @@ +const retrieveExtractionTableState = jest.fn(); +const updateExtractionTableStateInStorage = jest.fn(); +export { retrieveExtractionTableState, updateExtractionTableStateInStorage }; diff --git a/compose/neurosynth-frontend/src/pages/Project/store/__mocks__/ProjectStore.ts b/compose/neurosynth-frontend/src/pages/Project/store/__mocks__/ProjectStore.ts index 4200d678..66f5f7cb 100644 --- a/compose/neurosynth-frontend/src/pages/Project/store/__mocks__/ProjectStore.ts +++ b/compose/neurosynth-frontend/src/pages/Project/store/__mocks__/ProjectStore.ts @@ -18,7 +18,7 @@ const useProjectName = jest.fn().mockReturnValue('project-name'); const useProjectCurationColumns = jest.fn(); -const useProjectExtractionReplaceStudyListStatusId = jest.fn(); +const useProjectExtractionReplaceStudyListStatusId = jest.fn().mockReturnValue(jest.fn()); export { useProjectExtractionAnnotationId, diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx index 1a0197c6..c9584be2 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx @@ -1,6 +1,129 @@ +import { render, screen } from '@testing-library/react'; +import DisplayExtractionTableState from './DisplayExtractionTableState'; +import { retrieveExtractionTableState } from 'pages/Extraction/components/ExtractionTable.helpers'; +import { useStudyId } from 'pages/Study/store/StudyStore'; +import { useGetStudyById } from 'hooks'; +import userEvent from '@testing-library/user-event'; +import { useNavigate } from 'react-router-dom'; +import { setUnloadHandler } from 'helpers/BeforeUnload.helpers'; + +jest.mock('pages/Project/store/ProjectStore'); +jest.mock('pages/study/store/StudyStore'); +jest.mock('components/Dialogs/ConfirmationDialog'); +jest.mock('hooks'); +jest.mock('pages/Extraction/components/ExtractionTable.helpers'); +jest.mock('react-router-dom'); + describe('DisplayExtractionTableState Component', () => { + beforeEach(() => { + (retrieveExtractionTableState as jest.Mock).mockReturnValue({ + columnFilters: [], + studies: ['study-1', 'study-2', 'study-3'], + sorting: [], + }); + + (useGetStudyById as jest.Mock).mockImplementation((studyId: string) => ({ + isLoading: false, + data: { + name: studyId, + }, + })); + }); + it('should render', () => { - // placeholder test - expect(true).toBeFalsy(); + render(); + }); + + it('should render the previous navigation button', () => { + (useStudyId as jest.Mock).mockReturnValue('study-2'); + + render(); + + expect(screen.getByText('study-1')).toBeInTheDocument(); + }); + + it('should render the next navigation button', () => { + (useStudyId as jest.Mock).mockReturnValue('study-2'); + + render(); + + expect(screen.getByText('study-3')).toBeInTheDocument(); + }); + + it('navigates to the previous study', () => { + (useStudyId as jest.Mock).mockReturnValue('study-2'); + + render(); + + expect(screen.getByText('study-1')).toBeInTheDocument(); + userEvent.click(screen.getByText('study-1')); + + expect(useNavigate()).toHaveBeenCalledWith( + '/projects/project-id/extraction/studies/study-1/edit' + ); + }); + + it('navigates to the next study', () => { + (useStudyId as jest.Mock).mockReturnValue('study-2'); + + render(); + + expect(screen.getByText('study-3')).toBeInTheDocument(); + userEvent.click(screen.getByText('study-3')); + + expect(useNavigate()).toHaveBeenCalledWith( + '/projects/project-id/extraction/studies/study-3/edit' + ); + }); + + it('should not show the previous button if there is no previous study', () => { + (useStudyId as jest.Mock).mockReturnValue('study-1'); + + render(); + + expect(screen.getAllByRole('button').length).toEqual(1); + }); + + it('should not show the next button if there is no previous study', () => { + (useStudyId as jest.Mock).mockReturnValue('study-3'); + + render(); + + expect(screen.getAllByRole('button').length).toEqual(1); + }); + + it('shows the confirmation dialog', () => { + (useStudyId as jest.Mock).mockReturnValue('study-1'); + setUnloadHandler('study'); + render(); + + userEvent.click(screen.getByText('study-2')); + expect(screen.getByText('You have unsaved changes')).toBeInTheDocument(); + }); + + it('should navigate after confirming the dialog', () => { + (useStudyId as jest.Mock).mockReturnValue('study-1'); + setUnloadHandler('study'); + render(); + + userEvent.click(screen.getByText('study-2')); + expect(screen.getByText('You have unsaved changes')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('accept-close-confirmation')); + expect(useNavigate()).toHaveBeenCalledWith( + '/projects/project-id/extraction/studies/study-2/edit' + ); + }); + + it('should not navigate after cancelling the dialog', () => { + (useStudyId as jest.Mock).mockReturnValue('study-1'); + setUnloadHandler('annotation'); + render(); + + userEvent.click(screen.getByText('study-2')); + expect(screen.getByText('You have unsaved changes')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('deny-close-confirmation')); + expect(useNavigate()).not.toHaveBeenCalled(); }); }); diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx index 982bfa91..a5019dc3 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.tsx @@ -7,7 +7,7 @@ import { useProjectId, useProjectUser, } from 'pages/Project/store/ProjectStore'; -import { useStudyId } from '../store/StudyStore'; +import { useStudyId } from 'pages/Study/store/StudyStore'; import { hasUnsavedStudyChanges, unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.spec.tsx index c0db9af1..f68697d7 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.spec.tsx @@ -1,5 +1,13 @@ -import { render } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useUpdateStudyset } from 'hooks'; +import { StudyReturn } from 'neurostore-typescript-sdk'; +import { useProjectExtractionReplaceStudyListStatusId } from 'pages/Project/store/ProjectStore'; import EditStudySwapVersionButton from 'pages/Study/components/EditStudySwapVersionButton'; +import { useNavigate } from 'react-router-dom'; +import { mockBaseStudy, mockStudysetNotNested } from 'testing/mockData'; +import { useStudyId } from 'pages/Study/store/StudyStore'; +import { setUnloadHandler, unsetUnloadHandler } from 'helpers/BeforeUnload.helpers'; jest.mock('react-router-dom'); jest.mock('hooks'); @@ -7,17 +15,86 @@ jest.mock('pages/Project/store/ProjectStore'); jest.mock('pages/Study/store/StudyStore'); jest.mock('components/Dialogs/ConfirmationDialog'); jest.mock('notistack'); +jest.mock('helpers/Annotation.helpers'); +jest.mock('stores/AnnotationStore.getters'); describe('EditStudySwapVersionButton Component', () => { it('should render', () => { render(); }); - it('should open the menu when clicked', () => {}); + it('should open the menu when clicked', () => { + render(); + const button = screen.getByRole('button'); + userEvent.click(button); + + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('should show the base study versions', () => { + render(); + const baseStudy = mockBaseStudy(); + const button = screen.getByRole('button'); + userEvent.click(button); + + baseStudy.versions?.forEach((version) => { + expect( + screen.getByText(`Switch to version: ${(version as StudyReturn).id as string}`) + ).toBeInTheDocument(); + }); + }); - it('should show the base study versions', () => {}); + it('should switch the study version', async () => { + const studyset = mockStudysetNotNested(); + (useStudyId as jest.Mock).mockReturnValue(studyset.studies?.[0]); + const baseStudy = mockBaseStudy(); + render(); + const button = screen.getByRole('button'); + await act(async () => { + userEvent.click(button); + }); + const swapButton = screen.getByText( + `Switch to version: ${(baseStudy.versions as StudyReturn[])[0].id}` + ); + await act(async () => { + userEvent.click(swapButton); + }); + expect(screen.getByText('Are you sure you want to switch the study version?')); + + const confirmButton = screen.getByTestId('accept-close-confirmation'); + await act(async () => { + userEvent.click(confirmButton); + }); - it('should switch the study version', () => {}); + expect(useUpdateStudyset().mutateAsync).toHaveBeenCalled(); + expect(useProjectExtractionReplaceStudyListStatusId()).toHaveBeenCalled(); + expect(useNavigate()).toHaveBeenCalledWith( + `/projects/project-id/extraction/studies/${ + (baseStudy.versions as StudyReturn[])[0].id + }/edit` + ); + }); - it('should show the dialog is there are unsaved changes', () => {}); + it('should show the dialog if there are unsaved changes', async () => { + const baseStudy = mockBaseStudy(); + setUnloadHandler('study'); + render(); + const button = screen.getByRole('button'); + await act(async () => { + userEvent.click(button); + }); + const swapButton = screen.getByText( + `Switch to version: ${(baseStudy.versions as StudyReturn[])[0].id}` + ); + await act(async () => { + userEvent.click(swapButton); + }); + + const confirmButton = screen.getByTestId('accept-close-confirmation'); + await act(async () => { + userEvent.click(confirmButton); + }); + + expect(screen.getByText('Unsaved Changes')).toBeInTheDocument(); + }); }); diff --git a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.helpers.ts b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.helpers.ts index 4651388a..d34a0c6e 100644 --- a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.helpers.ts +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.helpers.ts @@ -37,12 +37,12 @@ export const hasEmptyStudyPoints = ( subpeak === undefined && cluster_size === undefined ); + if (isDefaultSinglePoint) continue; const hasEmptyPoint = analysis.points.some( (xyz) => xyz.x === undefined || xyz.y === undefined || xyz.z === undefined ); - - if (!isDefaultSinglePoint && hasEmptyPoint) + if (hasEmptyPoint) return { errorMessage: `Analysis ${analysis.name} has empty coordinates. Please add coordinatesa and try again.`, isError: true, diff --git a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx index 745cf30a..5a9425e9 100644 --- a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx @@ -1,6 +1,37 @@ -import { render } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useCreateStudy, useUpdateAnnotationById, useUpdateStudyset } from 'hooks'; +import { useSnackbar } from 'notistack'; +import { + useStudy, + useStudyAnalyses, + useStudyHasBeenEdited, + useStudyUser, + useUpdateStudyInDB, +} from 'pages/Study/store/StudyStore'; +import { useUpdateAnnotationInDB } from 'stores/AnnotationStore.actions'; +import { useAnnotationIsEdited, useAnnotationNotes } from 'stores/AnnotationStore.getters'; +import { + mockAnalyses, + mockAnnotations, + mockStorePoints, + mockStoreStudy, + mockStudy, + mockStudysetNotNested, +} from 'testing/mockData'; import useSaveStudy from './useSaveStudy'; +jest.mock('react-query'); +jest.mock('@auth0/auth0-react'); +jest.mock('notistack'); +jest.mock('react-router-dom'); +jest.mock('pages/Project/store/ProjectStore'); +jest.mock('pages/Study/store/StudyStore'); +jest.mock('stores/AnnotationStore.getters'); +jest.mock('stores/AnnotationStore.actions'); +jest.mock('hooks'); +jest.mock('utils/api'); + // Using a dummy component in order to test a custom hook const DummyComponent = () => { const { isLoading, hasEdits, handleSave } = useSaveStudy(); @@ -14,8 +45,128 @@ const DummyComponent = () => { }; describe('useSaveStudy hook', () => { - it('should work', () => { + it('should render', () => { + render(); + }); + + it('should throw an error for duplicate analyses', async () => { + const mockAnalysesWithDuplicates = mockAnalyses(); + (useStudyAnalyses as jest.Mock).mockReturnValue([ + ...mockAnalysesWithDuplicates, + mockAnalysesWithDuplicates[0], + ]); + + render(); + + await act(async () => { + userEvent.click(screen.getByTestId('save-study-button')); + }); + + expect(useSnackbar().enqueueSnackbar).toHaveBeenCalled(); + expect(useUpdateAnnotationInDB()).not.toHaveBeenCalled(); + expect(useUpdateStudyInDB()).not.toHaveBeenCalled(); + }); + + it('should throw an error for empty study points', async () => { + const mockAnalysesWithoutPoints = mockAnalyses(); + mockAnalysesWithoutPoints[0].points = [ + { + x: undefined, + y: undefined, + z: undefined, + }, + { + x: undefined, + y: undefined, + z: undefined, + }, + ]; + + (useStudyAnalyses as jest.Mock).mockReturnValue(mockAnalysesWithoutPoints); + + render(); + + await act(async () => { + userEvent.click(screen.getByTestId('save-study-button')); + }); + + expect(useSnackbar().enqueueSnackbar).toHaveBeenCalled(); + expect(useUpdateAnnotationInDB()).not.toHaveBeenCalled(); + expect(useUpdateStudyInDB()).not.toHaveBeenCalled(); + }); + + it('should save the study and annotation when both have been edited', async () => { + const mockAnalysesWithXYZ = mockAnalyses(); + mockAnalysesWithXYZ[0].points = mockStorePoints(); + (useStudyAnalyses as jest.Mock).mockReturnValue(mockAnalysesWithXYZ); + (useStudyHasBeenEdited as jest.Mock).mockReturnValue(true); + (useAnnotationIsEdited as jest.Mock).mockReturnValue(true); + + render(); + + await act(async () => { + userEvent.click(screen.getByTestId('save-study-button')); + }); + + expect(useUpdateStudyInDB()).toHaveBeenCalled(); + expect(useUpdateAnnotationInDB()).toHaveBeenCalled(); + }); + + it('should only save the study if the annotation has not been edited', async () => { + const mockAnalysesWithXYZ = mockAnalyses(); + mockAnalysesWithXYZ[0].points = mockStorePoints(); + (useStudyAnalyses as jest.Mock).mockReturnValue(mockAnalysesWithXYZ); + (useStudyHasBeenEdited as jest.Mock).mockReturnValue(true); + (useAnnotationIsEdited as jest.Mock).mockReturnValue(false); + render(); - expect(true).toBeFalsy(); + + await act(async () => { + userEvent.click(screen.getByTestId('save-study-button')); + }); + + expect(useUpdateStudyInDB()).toHaveBeenCalled(); + expect(useUpdateAnnotationInDB()).not.toHaveBeenCalled(); + }); + + it('should only save the annotation if the study has not been edited', async () => { + const mockAnalysesWithXYZ = mockAnalyses(); + mockAnalysesWithXYZ[0].points = mockStorePoints(); + (useStudyAnalyses as jest.Mock).mockReturnValue(mockAnalysesWithXYZ); + (useStudyHasBeenEdited as jest.Mock).mockReturnValue(false); + (useAnnotationIsEdited as jest.Mock).mockReturnValue(true); + + render(); + + await act(async () => { + userEvent.click(screen.getByTestId('save-study-button')); + }); + + expect(useUpdateStudyInDB()).not.toHaveBeenCalled(); + expect(useUpdateAnnotationInDB()).toHaveBeenCalled(); + }); + + it('should clone the study if user does not own the study and it has been edited', async () => { + const nestedMockStudyset = mockStudysetNotNested(); + const mockStudyWithSameIdInStudyset = mockStoreStudy(); + mockStudyWithSameIdInStudyset.id = (nestedMockStudyset.studies as string[])[0]; + (useStudy as jest.Mock).mockReturnValue(mockStudyWithSameIdInStudyset); + (useStudyHasBeenEdited as jest.Mock).mockReturnValue(true); + (useStudyUser as jest.Mock).mockReturnValue('different-user'); + (useAnnotationNotes as jest.Mock).mockReturnValue(mockAnnotations()[0].notes); + const mockAnalysesWithXYZ = mockAnalyses(); + mockAnalysesWithXYZ[0].points = mockStorePoints(); + (useStudyAnalyses as jest.Mock).mockReturnValue(mockAnalysesWithXYZ); + + render(); + + await act(async () => { + userEvent.click(screen.getByTestId('save-study-button')); + }); + + expect(useUpdateStudyInDB()).not.toHaveBeenCalled(); + expect(useCreateStudy().mutateAsync).toHaveBeenCalled(); + expect(useUpdateStudyset().mutateAsync).toHaveBeenCalled(); + expect(useUpdateAnnotationById('').mutateAsync).toHaveBeenCalled(); // arg doesnt matter as it is a mock }); }); diff --git a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx index 4f7c8f1e..e6785f3b 100644 --- a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx @@ -34,7 +34,7 @@ import { useStudyUser, useUpdateStudyInDB, useUpdateStudyIsLoading, -} from '../store/StudyStore'; +} from 'pages/Study/store/StudyStore'; import { storeAnalysesToStudyAnalyses } from '../store/StudyStore.helpers'; import { hasDuplicateStudyAnalysisNames, hasEmptyStudyPoints } from './useSaveStudy.helpers'; import { updateExtractionTableStateInStorage } from 'pages/Extraction/components/ExtractionTable.helpers'; diff --git a/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts b/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts index b092937b..4089c366 100644 --- a/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts +++ b/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts @@ -1,3 +1,5 @@ +import { mockAnalyses, mockStudy } from 'testing/mockData'; + const useStudyId = jest.fn().mockReturnValue('study-id'); const useStudyName = jest.fn().mockResolvedValue('test-study-name'); @@ -6,6 +8,30 @@ const useProjectId = jest.fn().mockReturnValue('project-id'); const useStudyBaseStudyId = jest.fn().mockReturnValue('base-study-id'); -const useUpdateStudyDetails = jest.fn(); +const useUpdateStudyDetails = jest.fn().mockReturnValue(jest.fn()); + +const useStudy = jest.fn().mockReturnValue(mockStudy()); + +const useStudyUser = jest.fn().mockReturnValue('some-github-user'); + +const useUpdateStudyIsLoading = jest.fn().mockReturnValue(false); + +const useStudyHasBeenEdited = jest.fn().mockReturnValue(false); + +const useStudyAnalyses = jest.fn().mockReturnValue(mockAnalyses()); + +const useUpdateStudyInDB = jest.fn().mockReturnValue(jest.fn()); -export { useStudyId, useStudyName, useProjectId, useStudyBaseStudyId, useUpdateStudyDetails }; +export { + useStudyId, + useStudyName, + useProjectId, + useStudyBaseStudyId, + useUpdateStudyDetails, + useStudy, + useStudyUser, + useUpdateStudyIsLoading, + useStudyHasBeenEdited, + useStudyAnalyses, + useUpdateStudyInDB, +}; diff --git a/compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.actions.ts b/compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.actions.ts new file mode 100644 index 00000000..8d5a81e0 --- /dev/null +++ b/compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.actions.ts @@ -0,0 +1,4 @@ +const useUpdateAnnotationInDB = jest.fn().mockReturnValue(jest.fn()); +const useUpdateAnnotationNotes = jest.fn().mockReturnValue(jest.fn()); + +export { useUpdateAnnotationInDB, useUpdateAnnotationNotes }; diff --git a/compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.getters.ts b/compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.getters.ts new file mode 100644 index 00000000..f354cf1f --- /dev/null +++ b/compose/neurosynth-frontend/src/stores/__mocks__/AnnotationStore.getters.ts @@ -0,0 +1,19 @@ +import { mockAnnotations } from 'testing/mockData'; + +const useAnnotationName = jest.fn().mockReturnValue('annotation-test-name'); +const useAnnotationNotes = jest.fn().mockReturnValue(mockAnnotations()[0].notes); +const useGetAnnotationIsLoading = jest.fn().mockReturnValue(false); +const useUpdateAnnotationIsLoading = jest.fn().mockReturnValue(false); +const useAnnotationIsEdited = jest.fn().mockReturnValue(false); +const useAnnotationIsError = jest.fn().mockReturnValue(false); +const useAnnotationId = jest.fn().mockReturnValue('test-id'); + +export { + useAnnotationName, + useAnnotationNotes, + useGetAnnotationIsLoading, + useUpdateAnnotationIsLoading, + useAnnotationIsEdited, + useAnnotationIsError, + useAnnotationId, +}; diff --git a/compose/neurosynth-frontend/src/testing/mockData.ts b/compose/neurosynth-frontend/src/testing/mockData.ts index 17782a6f..e97ef0f9 100644 --- a/compose/neurosynth-frontend/src/testing/mockData.ts +++ b/compose/neurosynth-frontend/src/testing/mockData.ts @@ -8,6 +8,7 @@ import { BaseStudyReturn, } from 'neurostore-typescript-sdk'; import { NeurostoreAnnotation } from 'utils/api'; +import { IStoreStudy } from 'pages/Study/store/StudyStore.helpers'; const mockConditions: () => ConditionReturn[] = () => [ { @@ -70,6 +71,56 @@ const mockPoints: () => PointReturn[] = () => [ }, ]; +const mockStorePoints: () => PointReturn[] = () => [ + { + analysis: '3MXg8tfRq2sh', + created_at: '2021-11-10T19:46:43.510565+00:00', + id: '7vVqmHtGtnkQ', + image: null, + kind: 'unknown', + label_id: null, + space: 'MNI', + user: 'some-user', + value: '', + entities: [], + x: 12, + y: -18, + z: 22, + }, + { + analysis: '3MXg8tfRq2sh', + coordinates: [-40.0, -68.0, -20.0], + created_at: '2021-11-10T19:46:43.510565+00:00', + id: '3fZJuzbqti5v', + image: null, + kind: 'unknown', + label_id: null, + space: 'MNI', + user: 'some-user', + value: '', + entities: [], + x: -40, + y: -68, + z: -20, + }, + { + analysis: '3MXg8tfRq2sh', + coordinates: [-10.0, -60.0, 18.0], + created_at: '2021-11-10T19:46:43.510565+00:00', + id: '47aqyStcBEsC', + image: null, + kind: 'unknown', + label_id: null, + space: 'MNI', + user: 'some-user', + value: '', + entities: [], + x: -10, + y: -60, + z: 18, + }, +]; + const mockAnalyses: () => AnalysisReturn[] = () => [ { conditions: mockConditions(), @@ -281,6 +332,30 @@ const mockStudy: (studyPropOverride?: Partial) => StudyReturn = ( ...(studyPropOverride || {}), }); +const mockStoreStudy: (studyPropOverride?: Partial) => IStoreStudy = ( + studyPropOverride +) => ({ + source: 'neurostore', + source_id: '7f66YLxzjPKk', + doi: 'NaN', + name: 'Amygdala-hippocampal involvement in human aversive trace conditioning revealed through event-related functional magnetic resonance imaging.', + authors: 'Buchel C, Dolan RJ, Armony JL, Friston KJ', + id: '4ZhkLTH8k2P6', + user: 'github|26612023', + updated_at: null, + source_updated_at: '2022-04-28T16:23:11.548030+00:00', + publication: + 'The Journal of neuroscience : the official journal of the Society for Neuroscience', + created_at: '2022-05-18T19:38:15.262996+00:00', + analyses: [], + description: null, + year: 1999, + metadata: [], + pmid: '10594068', + studysets: [], + ...(studyPropOverride || {}), +}); + const mockMetaAnalyses: () => MetaAnalysisReturn[] = () => [ { annotation: '6M3PvaWEmcWf', @@ -497,4 +572,6 @@ export { mockStudysetNested, mockStudysetNotNested, mockBaseStudy, + mockStorePoints, + mockStoreStudy, }; diff --git a/compose/neurosynth-frontend/src/utils/__mocks__/api.ts b/compose/neurosynth-frontend/src/utils/__mocks__/api.ts index 9a744bf2..01813d0c 100644 --- a/compose/neurosynth-frontend/src/utils/__mocks__/api.ts +++ b/compose/neurosynth-frontend/src/utils/__mocks__/api.ts @@ -5,7 +5,7 @@ const MockAPI = { NeurostoreServices: { StudiesService: { studiesGet: jest.fn(), - studiesIdGet: jest.fn(), + studiesIdGet: jest.fn().mockReturnValue(Promise.resolve({ data: mockStudy() })), studiesIdPut: jest.fn().mockReturnValue(Promise.resolve(mockStudy())), studiesPost: jest.fn(), studiesIdDelete: jest.fn(), From a6764c10b8d5a633c3fb2917f34744143a26a368 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Thu, 17 Oct 2024 14:02:25 -0400 Subject: [PATCH 11/13] fix: tests --- .../cypress/e2e/pages/BaseStudyPage.cy.tsx | 1 - .../cypress/e2e/pages/EditStudyPage.cy.tsx | 4 +- .../e2e/pages/PublicStudiesPage.cy.tsx | 4 +- .../Extraction/ExtractionTable.cy.tsx | 15 +- .../CreateSpecificationDialog.cy.tsx | 2 +- .../SleuthImport/DoSleuthImport.cy.tsx | 2 +- compose/neurosynth-frontend/src/App.spec.tsx | 17 -- compose/neurosynth-frontend/src/App.tsx | 4 +- .../src/__mocks__/@auth0/auth0-react.ts | 1 + .../src/__mocks__/react-query.ts | 8 +- .../src/__mocks__/react-router-dom.ts | 9 - .../src/__mocks__/react-router-dom.tsx | 25 ++ .../Dialogs/ConfirmationDialog.spec.tsx | 10 +- .../src/components/Navbar/Navbar.spec.tsx | 21 +- .../components/NeurosynthBreadcrumbs.spec.tsx | 3 + .../__mocks__/NeurosynthLoader.tsx | 2 +- .../src/helpers/BeforeUnload.helpers.spec.ts | 9 + .../src/hooks/__mocks__/index.ts | 13 +- .../neurosynth-frontend/src/hooks/index.ts | 2 + .../components/ProtectedProjectRoute.spec.tsx | 204 +++++++++++++- .../components/ProtectedProjectRoute.tsx | 2 +- .../components/ExtractionTable.helpers.ts | 9 + .../components/ExtractionTableAuthor.tsx | 7 +- .../DisplayExtractionTableState.spec.tsx | 1 + .../EditStudyCompleteButton.spec.tsx | 6 - .../components/EditStudyToolbar.spec.tsx | 18 +- .../Study/hooks/__mocks__/useSaveStudy.tsx | 2 +- .../pages/Study/hooks/useSaveStudy.spec.tsx | 4 +- .../src/testing/mockData.ts | 251 +++++++++++++++++- 29 files changed, 565 insertions(+), 91 deletions(-) delete mode 100644 compose/neurosynth-frontend/src/App.spec.tsx delete mode 100644 compose/neurosynth-frontend/src/__mocks__/react-router-dom.ts create mode 100644 compose/neurosynth-frontend/src/__mocks__/react-router-dom.tsx delete mode 100644 compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.spec.tsx diff --git a/compose/neurosynth-frontend/cypress/e2e/pages/BaseStudyPage.cy.tsx b/compose/neurosynth-frontend/cypress/e2e/pages/BaseStudyPage.cy.tsx index bd1a7dfe..942000d3 100644 --- a/compose/neurosynth-frontend/cypress/e2e/pages/BaseStudyPage.cy.tsx +++ b/compose/neurosynth-frontend/cypress/e2e/pages/BaseStudyPage.cy.tsx @@ -22,7 +22,6 @@ describe(PAGE_NAME, () => { cy.intercept('GET', `**/api/base-studies/**`, { fixture: 'study', }).as('studyFixture'); - cy.visit(PATH).wait('@semanticScholarFixture').wait('@studyFixture'); // .get('tr') // .eq(2) // .click() diff --git a/compose/neurosynth-frontend/cypress/e2e/pages/EditStudyPage.cy.tsx b/compose/neurosynth-frontend/cypress/e2e/pages/EditStudyPage.cy.tsx index 586d7573..e03c0805 100644 --- a/compose/neurosynth-frontend/cypress/e2e/pages/EditStudyPage.cy.tsx +++ b/compose/neurosynth-frontend/cypress/e2e/pages/EditStudyPage.cy.tsx @@ -36,7 +36,6 @@ describe(PAGE_NAME, () => { .wait('@studyFixture') .wait('@projectFixture') .wait('@annotationFixture') - .wait('@semanticScholarFixture') .wait('@studysetFixture'); }); @@ -48,7 +47,6 @@ describe(PAGE_NAME, () => { .wait('@studyFixture') .wait('@projectFixture') .wait('@annotationFixture') - .wait('@semanticScholarFixture') .wait('@studysetFixture'); // ACT @@ -56,7 +54,7 @@ describe(PAGE_NAME, () => { cy.contains('label', 'doi').next().clear(); cy.contains('label', 'pmid').next().clear(); cy.contains('label', 'pmcid').next().clear(); - cy.contains('button', 'save').click(); + cy.get('[data-testid="SaveIcon"]').click(); // ASSERT cy.get('@editStudy').its('request.body').should('not.have.a.property', 'doi'); diff --git a/compose/neurosynth-frontend/cypress/e2e/pages/PublicStudiesPage.cy.tsx b/compose/neurosynth-frontend/cypress/e2e/pages/PublicStudiesPage.cy.tsx index dc4a824d..80507857 100644 --- a/compose/neurosynth-frontend/cypress/e2e/pages/PublicStudiesPage.cy.tsx +++ b/compose/neurosynth-frontend/cypress/e2e/pages/PublicStudiesPage.cy.tsx @@ -7,7 +7,7 @@ export {}; const PATH = '/base-studies'; const PAGE_NAME = 'StudiesPage'; -describe.skip(PAGE_NAME, () => { +describe(PAGE_NAME, () => { beforeEach(() => { cy.clearLocalStorage(); cy.intercept('GET', 'https://api.appzi.io/**', { fixture: 'appzi' }).as('appziFixture'); @@ -16,7 +16,7 @@ describe.skip(PAGE_NAME, () => { it('should load successfully', () => { cy.intercept('GET', `**/api/projects*`).as('realProjectsRequest'); cy.intercept('GET', `**/api/base-studies/**`).as('realStudiesRequest'); - cy.visit(PATH).wait('@realStudiesRequest'); + cy.visit(PATH); }); // describe('Search', () => { diff --git a/compose/neurosynth-frontend/cypress/e2e/workflows/Extraction/ExtractionTable.cy.tsx b/compose/neurosynth-frontend/cypress/e2e/workflows/Extraction/ExtractionTable.cy.tsx index 24b8054f..f1fc62bc 100644 --- a/compose/neurosynth-frontend/cypress/e2e/workflows/Extraction/ExtractionTable.cy.tsx +++ b/compose/neurosynth-frontend/cypress/e2e/workflows/Extraction/ExtractionTable.cy.tsx @@ -3,6 +3,7 @@ import { INeurosynthProjectReturn } from 'hooks/projects/useGetProjects'; import { StudyReturn, StudysetReturn } from 'neurostore-typescript-sdk'; import { IExtractionTableStudy } from 'pages/Extraction/components/ExtractionTable'; +import { getAuthorShortName } from 'pages/Extraction/components/ExtractionTable.helpers'; describe('ExtractionTable', () => { beforeEach(() => { @@ -303,7 +304,12 @@ describe('ExtractionTable', () => { cy.get('tbody > tr').each((tr, index) => { cy.wrap(tr).within(() => { - cy.get('td').eq(2).should('have.text', sortedStudies[index].authors); + cy.get('td') + .eq(2) + .should( + 'have.text', + getAuthorShortName(sortedStudies?.[index]?.authors || '') + ); }); }); }); @@ -324,7 +330,12 @@ describe('ExtractionTable', () => { cy.get('tbody > tr').each((tr, index) => { cy.wrap(tr).within(() => { - cy.get('td').eq(2).should('have.text', sortedStudies[index].authors); + cy.get('td') + .eq(2) + .should( + 'have.text', + getAuthorShortName(sortedStudies?.[index]?.authors || '') + ); }); }); }); diff --git a/compose/neurosynth-frontend/cypress/e2e/workflows/MetaAnalyses/CreateSpecificationDialog.cy.tsx b/compose/neurosynth-frontend/cypress/e2e/workflows/MetaAnalyses/CreateSpecificationDialog.cy.tsx index a2edb04a..ac5706bc 100644 --- a/compose/neurosynth-frontend/cypress/e2e/workflows/MetaAnalyses/CreateSpecificationDialog.cy.tsx +++ b/compose/neurosynth-frontend/cypress/e2e/workflows/MetaAnalyses/CreateSpecificationDialog.cy.tsx @@ -40,7 +40,7 @@ describe('CreateSpecificationDialog', () => { cy.contains('FDRCorrector').should('exist'); }); - it.only('should step through the wizard', () => { + it('should step through the wizard', () => { cy.intercept('POST', '**/api/specifications', { id: 'mockedSpecificationId', }).as('createSpecificationFixture'); diff --git a/compose/neurosynth-frontend/cypress/e2e/workflows/SleuthImport/DoSleuthImport.cy.tsx b/compose/neurosynth-frontend/cypress/e2e/workflows/SleuthImport/DoSleuthImport.cy.tsx index c25014cf..13acbfc1 100644 --- a/compose/neurosynth-frontend/cypress/e2e/workflows/SleuthImport/DoSleuthImport.cy.tsx +++ b/compose/neurosynth-frontend/cypress/e2e/workflows/SleuthImport/DoSleuthImport.cy.tsx @@ -433,7 +433,7 @@ describe('DoSleuthImport', () => { }); describe('edge cases', () => { - it.only('should apply the pubmed details to the study if a matching pubmed study is found', () => { + it('should apply the pubmed details to the study if a matching pubmed study is found', () => { // this stuff exists just to make sure cypress doesnt send any real requests. They are not under test // synth API responses cy.intercept('POST', `${neurostoreAPIBaseURL}/analyses/**`, { diff --git a/compose/neurosynth-frontend/src/App.spec.tsx b/compose/neurosynth-frontend/src/App.spec.tsx deleted file mode 100644 index 4a9b4e51..00000000 --- a/compose/neurosynth-frontend/src/App.spec.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; -import App from './App'; - -jest.mock('./components/Navbar/Navbar'); -jest.mock('./pages/BaseNavigation/BaseNavigation'); -jest.mock('@auth0/auth0-react'); - -test('renders main app', async () => { - await act(async () => { - render(); - }); - const mockNavbar = screen.getByText('mock navbar'); - const mockNavigation = screen.getByText('mock base navigation'); - expect(mockNavbar).toBeInTheDocument(); - expect(mockNavigation).toBeInTheDocument(); -}); diff --git a/compose/neurosynth-frontend/src/App.tsx b/compose/neurosynth-frontend/src/App.tsx index fbf151cf..f713e039 100644 --- a/compose/neurosynth-frontend/src/App.tsx +++ b/compose/neurosynth-frontend/src/App.tsx @@ -5,9 +5,9 @@ import useGoogleAnalytics from 'hooks/useGoogleAnalytics'; import { SnackbarKey, SnackbarProvider } from 'notistack'; import { useEffect, useRef } from 'react'; import { QueryCache, QueryClient, QueryClientProvider } from 'react-query'; -import Navbar from './components/Navbar/Navbar'; +import Navbar from 'components/Navbar/Navbar'; import useGetToken from './hooks/useGetToken'; -import BaseNavigation from './pages/BaseNavigation/BaseNavigation'; +import BaseNavigation from 'pages/BaseNavigation/BaseNavigation'; import { useLocation } from 'react-router-dom'; const queryClient = new QueryClient({ diff --git a/compose/neurosynth-frontend/src/__mocks__/@auth0/auth0-react.ts b/compose/neurosynth-frontend/src/__mocks__/@auth0/auth0-react.ts index 9466c8fe..f23397b3 100644 --- a/compose/neurosynth-frontend/src/__mocks__/@auth0/auth0-react.ts +++ b/compose/neurosynth-frontend/src/__mocks__/@auth0/auth0-react.ts @@ -5,6 +5,7 @@ const useAuth0 = jest.fn().mockReturnValue({ loginWithPopup: jest.fn(), logout: jest.fn(), isAuthenticated: false, + isLoading: false, user: { sub: 'some-github-user', }, diff --git a/compose/neurosynth-frontend/src/__mocks__/react-query.ts b/compose/neurosynth-frontend/src/__mocks__/react-query.ts index 53063b50..46ac4463 100644 --- a/compose/neurosynth-frontend/src/__mocks__/react-query.ts +++ b/compose/neurosynth-frontend/src/__mocks__/react-query.ts @@ -2,4 +2,10 @@ const useQueryClient = jest.fn().mockReturnValue({ invalidateQueries: jest.fn(), }); -export { useQueryClient }; +const useQuery = jest.fn().mockReturnValue({ + data: null, + isLoading: false, + isError: false, +}); + +export { useQueryClient, useQuery }; diff --git a/compose/neurosynth-frontend/src/__mocks__/react-router-dom.ts b/compose/neurosynth-frontend/src/__mocks__/react-router-dom.ts deleted file mode 100644 index a3a19d34..00000000 --- a/compose/neurosynth-frontend/src/__mocks__/react-router-dom.ts +++ /dev/null @@ -1,9 +0,0 @@ -const useNavigate = jest.fn().mockReturnValue(jest.fn()); - -const useLocation = jest.fn().mockReturnValue({ - location: { - search: '', - }, -}); - -export { useNavigate, useLocation }; diff --git a/compose/neurosynth-frontend/src/__mocks__/react-router-dom.tsx b/compose/neurosynth-frontend/src/__mocks__/react-router-dom.tsx new file mode 100644 index 00000000..9168b3be --- /dev/null +++ b/compose/neurosynth-frontend/src/__mocks__/react-router-dom.tsx @@ -0,0 +1,25 @@ +import { NavigateProps } from 'react-router-dom'; + +const useParams = jest.fn().mockReturnValue({ + projectId: 'test-project-id', +}); + +const useNavigate = jest.fn().mockReturnValue(jest.fn()); + +const useLocation = jest.fn().mockReturnValue({ + location: { + search: '', + }, +}); + +const Navigate = ({ to, replace, state }: NavigateProps) => { + return ( + <> +
{to}
+
{replace}
+
{JSON.stringify(state)}
+ + ); +}; + +export { useNavigate, useLocation, useParams, Navigate }; diff --git a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx index 6b79c70e..aedd2e21 100644 --- a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx +++ b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.spec.tsx @@ -41,7 +41,7 @@ describe('ConfirmationDialog', () => { const rejectButton = screen.getByRole('button', { name: 'reject' }); userEvent.click(rejectButton); - expect(mockOnClose).toBeCalledWith(false, undefined); + expect(mockOnClose).toBeCalledWith(false); }); it('should signal true when confirm is clicked', () => { @@ -57,7 +57,7 @@ describe('ConfirmationDialog', () => { const confirmButton = screen.getByRole('button', { name: 'confirm' }); userEvent.click(confirmButton); - expect(mockOnClose).toBeCalledWith(true, undefined); + expect(mockOnClose).toBeCalledWith(true); }); it('should signal undefined when clicked away', async () => { @@ -77,7 +77,7 @@ describe('ConfirmationDialog', () => { // we need to trigger a click away by clicking the backdrop. For some reason, // the second presentation div accomplishes this userEvent.click(screen.getAllByRole('presentation')[1]); - expect(mockOnClose).toBeCalledWith(undefined, undefined); + expect(mockOnClose).toBeCalledWith(undefined); }); it('should close when close icon button is clicked', () => { @@ -92,7 +92,7 @@ describe('ConfirmationDialog', () => { ); userEvent.click(screen.getByTestId('CloseIcon')); - expect(mockOnClose).toHaveBeenCalledWith(undefined, undefined); + expect(mockOnClose).toHaveBeenCalledWith(undefined); }); it('should be called with the data', () => { @@ -109,6 +109,6 @@ describe('ConfirmationDialog', () => { const confirmButton = screen.getByRole('button', { name: 'confirm' }); userEvent.click(confirmButton); - expect(mockOnClose).toHaveBeenCalledWith(true, { data: 'test-data' }); + expect(mockOnClose).toHaveBeenCalledWith(true); }); }); diff --git a/compose/neurosynth-frontend/src/components/Navbar/Navbar.spec.tsx b/compose/neurosynth-frontend/src/components/Navbar/Navbar.spec.tsx index 6828e2d1..4886f491 100644 --- a/compose/neurosynth-frontend/src/components/Navbar/Navbar.spec.tsx +++ b/compose/neurosynth-frontend/src/components/Navbar/Navbar.spec.tsx @@ -1,7 +1,6 @@ import { useAuth0 } from '@auth0/auth0-react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { QueryClient, QueryClientProvider } from 'react-query'; import Navbar from './Navbar'; jest.mock('@auth0/auth0-react'); @@ -11,25 +10,15 @@ jest.mock('components/Navbar/NavToolbar.tsx'); jest.mock('hooks'); describe('Navbar', () => { - const queryClient = new QueryClient(); - it('should render', () => { - render( - - - - ); + render(); expect(screen.getByTestId('mock-nav-drawer')).toBeInTheDocument(); expect(screen.getByTestId('mock-nav-toolbar')).toBeInTheDocument(); }); it('should call the auth0 login method when logging in', () => { - render( - - - - ); + render(); userEvent.click(screen.getByTestId('toolbar-trigger-login')); @@ -37,11 +26,7 @@ describe('Navbar', () => { }); it('should call the auth0 logout method when logging out', () => { - render( - - - - ); + render(); userEvent.click(screen.getByTestId('toolbar-trigger-logout')); diff --git a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx index 1d53b724..880eb979 100644 --- a/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx +++ b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx @@ -8,6 +8,9 @@ jest.mock('react-router-dom'); jest.mock('components/Dialogs/ConfirmationDialog'); describe('NeurosynthBreadcrumbs Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('should render', () => { render(); }); diff --git a/compose/neurosynth-frontend/src/components/NeurosynthLoader/__mocks__/NeurosynthLoader.tsx b/compose/neurosynth-frontend/src/components/NeurosynthLoader/__mocks__/NeurosynthLoader.tsx index e7a88245..8166744b 100644 --- a/compose/neurosynth-frontend/src/components/NeurosynthLoader/__mocks__/NeurosynthLoader.tsx +++ b/compose/neurosynth-frontend/src/components/NeurosynthLoader/__mocks__/NeurosynthLoader.tsx @@ -1,7 +1,7 @@ import { INeurosynthLoader } from '../NeurosynthLoader'; const MockNeurosynthLoader: React.FC = (props) => { - return
{props.children}
; + return
{props.children}
; }; export default MockNeurosynthLoader; diff --git a/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts index 48208ded..52864ca3 100644 --- a/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts +++ b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts @@ -18,6 +18,9 @@ describe('BeforeUnload helpers', () => { expect(window.sessionStorage.getItem(EUnloadStatus.PROJECTSTORE)).toBe('true'); expect(spy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); expect(spy).toHaveBeenCalledWith('unload', expect.any(Function)); + + // cleanup + unsetUnloadHandler('project'); }); it('should set the unload handler for the study store', () => { @@ -27,6 +30,9 @@ describe('BeforeUnload helpers', () => { expect(window.sessionStorage.getItem(EUnloadStatus.STUDYSTORE)).toBe('true'); expect(spy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); expect(spy).toHaveBeenCalledWith('unload', expect.any(Function)); + + // cleanup + unsetUnloadHandler('study'); }); it('should set the unload handler for the annotation store', () => { @@ -36,6 +42,9 @@ describe('BeforeUnload helpers', () => { expect(window.sessionStorage.getItem(EUnloadStatus.ANNOTATIONSTORE)).toBe('true'); expect(spy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); expect(spy).toHaveBeenCalledWith('unload', expect.any(Function)); + + // cleanup + unsetUnloadHandler('annotation'); }); it('should remove the unload handler when all stores are cleared', () => { diff --git a/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts b/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts index 3efb1d9d..4ab826cd 100644 --- a/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts +++ b/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts @@ -3,6 +3,7 @@ import { mockAnnotations, mockBaseStudy, mockConditions, + mockProject, mockStudy, mockStudysetNested, mockStudysetNotNested, @@ -122,11 +123,14 @@ const useGetExtractionSummary = jest.fn().mockReturnValue({ total: 0, }); +// need to do this to prevent an infinite loop +const studysetNested = mockStudysetNested(); +const studysetNotNested = mockStudysetNotNested(); const useGetStudysetById = jest.fn().mockImplementation((studysetId: string, isNested: boolean) => { return { isLoading: false, isError: false, - data: isNested ? mockStudysetNested() : mockStudysetNotNested(), + data: isNested ? studysetNested : studysetNotNested, }; }); @@ -156,6 +160,12 @@ const useUpdateAnnotationById = jest.fn().mockReturnValue({ mutateAsync: jest.fn(), }); +const useGetProjectById = jest.fn().mockReturnValue({ + isLoading: false, + isError: false, + data: mockProject(), +}); + const useIsMounted = () => { return { __esModule: true, @@ -195,4 +205,5 @@ export { useUpdateStudy, useUpdateStudyset, useUserCanEdit, + useGetProjectById, }; diff --git a/compose/neurosynth-frontend/src/hooks/index.ts b/compose/neurosynth-frontend/src/hooks/index.ts index 6b169cdf..68900a65 100644 --- a/compose/neurosynth-frontend/src/hooks/index.ts +++ b/compose/neurosynth-frontend/src/hooks/index.ts @@ -37,6 +37,7 @@ import useGetCurationSummary from './useGetCurationSummary'; import useGetFullText from './external/useGetFullText'; import useUserCanEdit from './useUserCanEdit'; import useGetBaseStudyById from './studies/useGetBaseStudyById'; +import useGetProjectById from './projects/useGetProjectById'; export { useGetCurationSummary, @@ -85,4 +86,5 @@ export { useCreateCondition, // project useCreateProject, + useGetProjectById, }; diff --git a/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.spec.tsx b/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.spec.tsx index a414b757..e4da97db 100644 --- a/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.spec.tsx @@ -1,9 +1,207 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import ProtectedProjectRoute from './ProtectedProjectRoute'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { useAuth0 } from '@auth0/auth0-react'; +import { useGetProjectById } from 'hooks'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), +})); +jest.mock('hooks'); describe('ProtectedProjectRoute Component', () => { it('should render', () => { - render(); - expect(true).toBeFalsy(); // TODO finish these tests + render( + + + +
test
+
+ } + /> + forbidden} /> + + + ); + }); + + it('should allow access if the user is the owner', () => { + useAuth0().isAuthenticated = true; + render( + + + +
allowed
+ + } + /> + forbidden} /> +
+
+ ); + + expect(screen.getByText('allowed')).toBeInTheDocument(); + }); + + it('should allow access if the user is the owner and the onlyOwnerCanAccess flag is set', () => { + useAuth0().isAuthenticated = true; + render( + + + +
allowed
+ + } + /> + forbidden} /> +
+
+ ); + + expect(screen.getByText('allowed')).toBeInTheDocument(); + }); + + it('should allow access if public and the user is not the owner', () => { + useAuth0().isAuthenticated = true; + useAuth0().user = { sub: 'other-user' }; + (useGetProjectById as jest.Mock).mockReturnValue({ + data: { public: true }, + isLoading: false, + }); + + render( + + + +
allowed
+ + } + /> + forbidden} /> +
+
+ ); + + expect(screen.getByText('allowed')).toBeInTheDocument(); + }); + + it('should allow access if public and the user is not authenticated', () => { + useAuth0().isAuthenticated = false; + useAuth0().user = undefined; + (useGetProjectById as jest.Mock).mockReturnValue({ + data: { public: true }, + isLoading: false, + }); + + render( + + + +
allowed
+ + } + /> + forbidden} /> +
+
+ ); + + expect(screen.getByText('allowed')).toBeInTheDocument(); + }); + + it('should not allow access if its not public and the user is not the owner', () => { + useAuth0().isAuthenticated = true; + useAuth0().user = { sub: 'other-user' }; + (useGetProjectById as jest.Mock).mockReturnValue({ + data: { public: false }, + isLoading: false, + }); + + render( + + + +
allowed
+ + } + /> + forbidden} /> +
+
+ ); + + expect(screen.getByText('forbidden')).toBeInTheDocument(); + }); + + it('should not allow access if its not public and the user is not authenticated', () => { + useAuth0().isAuthenticated = false; + useAuth0().user = undefined; + (useGetProjectById as jest.Mock).mockReturnValue({ + data: { public: false }, + isLoading: false, + }); + + render( + + + +
allowed
+ + } + /> + forbidden} /> +
+
+ ); + + expect(screen.getByText('forbidden')).toBeInTheDocument(); + }); + + it('should not allow access if the onlyOwnerCanAccess flag is set and the user is not the owner', () => { + useAuth0().isAuthenticated = true; + useAuth0().user = { sub: 'other-user' }; + (useGetProjectById as jest.Mock).mockReturnValue({ + data: { public: false }, + isLoading: false, + }); + + render( + + + +
allowed
+ + } + /> + forbidden} /> +
+
+ ); + + expect(screen.getByText('forbidden')).toBeInTheDocument(); }); }); diff --git a/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.tsx b/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.tsx index 967c1975..ce4459f9 100644 --- a/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.tsx +++ b/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.tsx @@ -1,6 +1,6 @@ import { useAuth0 } from '@auth0/auth0-react'; import NeurosynthLoader from 'components/NeurosynthLoader/NeurosynthLoader'; -import useGetProjectById from 'hooks/projects/useGetProjectById'; +import { useGetProjectById } from 'hooks'; import useUserCanEdit from 'hooks/useUserCanEdit'; import { Navigate, useLocation, useParams } from 'react-router-dom'; const ProtectedProjectRoute: React.FC<{ onlyOwnerCanAccess?: boolean; errorMessage?: string }> = ({ diff --git a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.helpers.ts b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.helpers.ts index 535d7df3..63ca7469 100644 --- a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.helpers.ts +++ b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.helpers.ts @@ -42,3 +42,12 @@ export interface IExtractionTableState { sorting: SortingState; studies: string[]; } + +export const getAuthorShortName = (authors: string) => { + let shortName = authors; + const authorsList = (authors || '').split(','); + if (authorsList.length > 1) { + shortName = `${authorsList[0]}., et al.`; + } + return shortName; +}; diff --git a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx index 57dd1e2c..81488e86 100644 --- a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx +++ b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx @@ -3,16 +3,13 @@ import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import { Box, IconButton, Tooltip, Typography } from '@mui/material'; import { CellContext, HeaderContext } from '@tanstack/react-table'; import { IExtractionTableStudy } from './ExtractionTable'; +import { getAuthorShortName } from './ExtractionTable.helpers'; export const ExtractionTableAuthorCell: React.FC> = ( props ) => { const value = props.getValue(); - let shortName = value; - const authorsList = (value || '').split(','); - if (authorsList.length > 1) { - shortName = `${authorsList[0]}., et al.`; - } + const shortName = getAuthorShortName(value); return ( {value} : null}> {shortName} diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx index c9584be2..d806b746 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx @@ -16,6 +16,7 @@ jest.mock('react-router-dom'); describe('DisplayExtractionTableState Component', () => { beforeEach(() => { + jest.clearAllMocks(); (retrieveExtractionTableState as jest.Mock).mockReturnValue({ columnFilters: [], studies: ['study-1', 'study-2', 'study-3'], diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.spec.tsx deleted file mode 100644 index 1bbc80ae..00000000 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.spec.tsx +++ /dev/null @@ -1,6 +0,0 @@ -describe('EditStudyCompleteButton Component', () => { - it('should render', () => { - // placeholder test - expect(true).toBeFalsy(); - }); -}); diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.spec.tsx index 126d8df0..ab023808 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.spec.tsx @@ -164,9 +164,9 @@ describe('EditStudyToolbar Component', () => { (useProjectId as jest.Mock).mockReturnValue('projectid'); (useUserCanEdit as jest.Mock).mockReturnValue(true); - useGetStudysetById().data = { - studies: [{ id: 'study-2' }, { id: 'study-3' }, { id: 'study-4' }], - }; + (useGetStudysetById as jest.Mock).mockReturnValue({ + data: { studies: [{ id: 'study-2' }, { id: 'study-3' }, { id: 'study-4' }] }, + }); render(); // ACT @@ -184,9 +184,9 @@ describe('EditStudyToolbar Component', () => { (useProjectId as jest.Mock).mockReturnValue('projectid'); (useUserCanEdit as jest.Mock).mockReturnValue(true); - useGetStudysetById().data = { - studies: [{ id: 'study-2' }, { id: 'study-3' }, { id: 'study-4' }], - }; + (useGetStudysetById as jest.Mock).mockReturnValue({ + data: { studies: [{ id: 'study-2' }, { id: 'study-3' }, { id: 'study-4' }] }, + }); render(); // ACT @@ -254,9 +254,9 @@ describe('EditStudyToolbar Component', () => { (useProjectId as jest.Mock).mockReturnValue('projectid'); (useUserCanEdit as jest.Mock).mockReturnValue(true); - useGetStudysetById().data = { - studies: [{ id: 'study-2' }, { id: 'study-3' }, { id: 'study-4' }], - }; + (useGetStudysetById as jest.Mock).mockReturnValue({ + data: { studies: [{ id: 'study-2' }, { id: 'study-3' }, { id: 'study-4' }] }, + }); render(); // ACT diff --git a/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx index a0a5dadd..13c13f0a 100644 --- a/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx @@ -1,6 +1,6 @@ const useSaveStudy = jest.fn().mockReturnValue({ isLoading: false, - hasEditse: false, + hasEdits: false, handleSave: jest.fn(), }); diff --git a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx index 5a9425e9..da08885b 100644 --- a/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx @@ -16,7 +16,6 @@ import { mockAnnotations, mockStorePoints, mockStoreStudy, - mockStudy, mockStudysetNotNested, } from 'testing/mockData'; import useSaveStudy from './useSaveStudy'; @@ -45,6 +44,9 @@ const DummyComponent = () => { }; describe('useSaveStudy hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('should render', () => { render(); }); diff --git a/compose/neurosynth-frontend/src/testing/mockData.ts b/compose/neurosynth-frontend/src/testing/mockData.ts index e97ef0f9..7400e799 100644 --- a/compose/neurosynth-frontend/src/testing/mockData.ts +++ b/compose/neurosynth-frontend/src/testing/mockData.ts @@ -1,4 +1,4 @@ -import { MetaAnalysisReturn } from 'neurosynth-compose-typescript-sdk'; +import { MetaAnalysisReturn, ProjectReturn } from 'neurosynth-compose-typescript-sdk'; import { StudysetReturn, StudyReturn, @@ -559,6 +559,254 @@ const mockBaseStudy: () => BaseStudyReturn = () => ({ level: 'group', }); +const mockProject: () => ProjectReturn = () => ({ + created_at: '2024-05-17T17:32:41.215440+00:00', + description: 'New project generated from files: nback-Owen-ALL-Updated copy.txt', + draft: true, + id: '5WSN3nu6hMjj', + meta_analyses: [], + name: 'Untitled sleuth project', + neurostore_study: { + created_at: '2024-05-17T17:32:41.224351+00:00', + exception: null, + neurostore_id: 'RZAu78WPLgay', + status: 'PENDING', + traceback: null, + updated_at: '2024-05-17T17:32:41.229632+00:00', + }, + neurostore_url: 'https://neurostore.org/api/studies/RZAu78WPLgay', + provenance: { + curationMetadata: { + columns: [ + { + id: 'ad1cbff6-d95d-4576-ac99-8ea2f7e1b395', + name: 'not included', + stubStudies: [], + }, + { + id: '8525c03a-f47b-4ee0-9a59-9cbde5ab0690', + name: 'included', + stubStudies: [ + { + abstractText: '', + articleLink: '', + articleYear: '2', + authors: 'Ragland J D,', + doi: '10.1037/0894-4105.16.3.370', + exclusionTag: null, + id: '277d4844-60e1-48c8-9a42-3b46ff4c9e64', + identificationSource: { + id: 'neurosynth_sleuth_id_source', + label: 'Sleuth', + }, + journal: '', + keywords: '', + neurostoreId: '6Lz4BqniENMA', + pmcid: '', + pmid: '', + searchTerm: '', + tags: [ + { + id: 'd82a9992-640f-45f7-b81b-ce2d7b7a49ee', + isAssignable: true, + isExclusionTag: false, + label: 'nback-Owen-ALL-Updated copy.txt', + }, + ], + title: '', + }, + { + abstractText: '', + articleLink: '', + articleYear: '2', + authors: 'Rama P,', + doi: '10.1006/nimg.2001.0777', + exclusionTag: null, + id: 'b9f6b007-65c2-46f4-8f54-b59c759de152', + identificationSource: { + id: 'neurosynth_sleuth_id_source', + label: 'Sleuth', + }, + journal: '', + keywords: '', + neurostoreId: '4QC9ff3c4seH', + pmcid: '', + pmid: '', + searchTerm: '', + tags: [ + { + id: 'd82a9992-640f-45f7-b81b-ce2d7b7a49ee', + isAssignable: true, + isExclusionTag: false, + label: 'nback-Owen-ALL-Updated copy.txt', + }, + ], + title: '', + }, + { + abstractText: '', + articleLink: '', + articleYear: '1', + authors: 'Schumacher E H,', + doi: '10.1006/nimg.1996.0009', + exclusionTag: null, + id: 'e10e6c55-095b-4a90-9032-ea86ccf93fb1', + identificationSource: { + id: 'neurosynth_sleuth_id_source', + label: 'Sleuth', + }, + journal: '', + keywords: '', + neurostoreId: '7VhHUNpiyvxN', + pmcid: '', + pmid: '', + searchTerm: '', + tags: [ + { + id: 'd82a9992-640f-45f7-b81b-ce2d7b7a49ee', + isAssignable: true, + isExclusionTag: false, + label: 'nback-Owen-ALL-Updated copy.txt', + }, + ], + title: '', + }, + { + abstractText: '', + articleLink: '', + articleYear: '1', + authors: 'Smith E E,', + doi: '10.1093/cercor/6.1.11', + exclusionTag: null, + id: 'a4c7e222-9b0e-49a9-aca1-8fe70dc50645', + identificationSource: { + id: 'neurosynth_sleuth_id_source', + label: 'Sleuth', + }, + journal: '', + keywords: '', + neurostoreId: '4puK5CWhPf3n', + pmcid: '', + pmid: '', + searchTerm: '', + tags: [ + { + id: 'd82a9992-640f-45f7-b81b-ce2d7b7a49ee', + isAssignable: true, + isExclusionTag: false, + label: 'nback-Owen-ALL-Updated copy.txt', + }, + ], + title: '', + }, + ], + }, + ], + exclusionTags: [ + { + id: 'neurosynth_exclude_exclusion', + isAssignable: true, + isExclusionTag: true, + label: 'Exclude', + }, + { + id: 'neurosynth_duplicate_exclusion', + isAssignable: true, + isExclusionTag: true, + label: 'Duplicate', + }, + ], + identificationSources: [ + { + id: 'neurosynth_neurostore_id_source', + label: 'Neurostore', + }, + { + id: 'neurosynth_pubmed_id_source', + label: 'PubMed', + }, + { + id: 'neurosynth_scopus_id_source', + label: 'Scopus', + }, + { + id: 'neurosynth_web_of_science_id_source', + label: 'Web of Science', + }, + { + id: 'neurosynth_psycinfo_id_source', + label: 'PsycInfo', + }, + { + id: 'neurosynth_sleuth_id_source', + label: 'Sleuth', + }, + ], + infoTags: [ + { + id: 'neurosynth_untagged_tag', + isAssignable: false, + isExclusionTag: false, + label: 'Untagged studies', + }, + { + id: 'neurosynth_uncategorized_tag', + isAssignable: false, + isExclusionTag: false, + label: 'Uncategorized Studies', + }, + { + id: 'neurosynth_needs_review_tag', + isAssignable: false, + isExclusionTag: false, + label: 'Needs Review', + }, + ], + prismaConfig: { + eligibility: { + exclusionTags: [], + }, + identification: { + exclusionTags: [], + }, + isPrisma: false, + screening: { + exclusionTags: [], + }, + }, + }, + extractionMetadata: { + annotationId: '3EkUXiRFc7sL', + studyStatusList: [ + { + id: '6YFH5BnHRDeR', + status: 'completed', + }, + { + id: '5U3FRksVuHZ8', + status: 'completed', + }, + { + id: '6yiES7GyNLH3', + status: 'completed', + }, + { + id: '4XqQxkeQ8bH7', + status: 'completed', + }, + ], + studysetId: '3jTnjw8EiJMs', + }, + metaAnalysisMetadata: { + canEditMetaAnalyses: true, + }, + }, + public: true, + updated_at: null, + user: 'some-github-user', + username: 'Nicholas Lee', +}); + export { mockConditions, mockWeights, @@ -574,4 +822,5 @@ export { mockBaseStudy, mockStorePoints, mockStoreStudy, + mockProject, }; From 337488b3bdcade1bbc7bb5fa4a723f132f708c09 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Thu, 17 Oct 2024 14:35:24 -0400 Subject: [PATCH 12/13] fix: lowercase to uppercase --- .../pages/Study/components/DisplayExtractionTableState.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx index d806b746..1ad4fbd6 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx @@ -8,7 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { setUnloadHandler } from 'helpers/BeforeUnload.helpers'; jest.mock('pages/Project/store/ProjectStore'); -jest.mock('pages/study/store/StudyStore'); +jest.mock('pages/Study/store/StudyStore'); jest.mock('components/Dialogs/ConfirmationDialog'); jest.mock('hooks'); jest.mock('pages/Extraction/components/ExtractionTable.helpers'); From cb4a0f95eb2d2a89c5726014039213cf7f92c5d5 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Tue, 22 Oct 2024 09:05:38 -0400 Subject: [PATCH 13/13] replace mark with set --- .../src/pages/Study/components/EditStudyCompleteButton.tsx | 2 +- .../src/pages/Study/components/EditStudyToolbar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx index 7241548e..fda4c966 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyCompleteButton.tsx @@ -40,7 +40,7 @@ const EditStudyCompleteButton: React.FC = React.memo((props) => { text={ extractionStatus?.status === EExtractionStatus.COMPLETED ? 'Completed' - : 'Set as Complete' + : 'Mark as Complete' } />
diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx index 94acb9b7..1c1d9436 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyToolbar.tsx @@ -289,7 +289,7 @@ const EditStudyToolbar: React.FC<{ isViewOnly?: boolean }> = ({ isViewOnly = fal orientation="vertical" sx={{ minWidth: '0px', marginBottom: '1rem' }} > - +