diff --git a/compose/neurosynth-frontend/cypress/e2e/pages/BaseStudyPage.cy.tsx b/compose/neurosynth-frontend/cypress/e2e/pages/BaseStudyPage.cy.tsx index bd1a7dfe7..942000d33 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 586d7573d..e03c08054 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 dc4a824d4..805078576 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 24b8054fa..f1fc62bc6 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 a2edb04ac..ac5706bc0 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 c25014cf6..13acbfc13 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 4a9b4e511..000000000 --- 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 62f884cdb..f713e0394 100644 --- a/compose/neurosynth-frontend/src/App.tsx +++ b/compose/neurosynth-frontend/src/App.tsx @@ -5,15 +5,16 @@ 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({ defaultOptions: { queries: { retry: 0, + refetchOnWindowFocus: false, // staleTime: 5000, // https://tkdodo.eu/blog/practical-react-query#the-defaults-explained }, }, diff --git a/compose/neurosynth-frontend/src/__mocks__/@auth0/auth0-react.ts b/compose/neurosynth-frontend/src/__mocks__/@auth0/auth0-react.ts index 9466c8fed..f23397b37 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__/notistack.ts b/compose/neurosynth-frontend/src/__mocks__/notistack.ts new file mode 100644 index 000000000..4d8418e63 --- /dev/null +++ b/compose/neurosynth-frontend/src/__mocks__/notistack.ts @@ -0,0 +1,5 @@ +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 000000000..46ac44638 --- /dev/null +++ b/compose/neurosynth-frontend/src/__mocks__/react-query.ts @@ -0,0 +1,11 @@ +const useQueryClient = jest.fn().mockReturnValue({ + invalidateQueries: jest.fn(), +}); + +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 a3a19d340..000000000 --- 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 000000000..9168b3bed --- /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 4467c7dee..aedd2e210 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', () => { @@ -103,13 +103,12 @@ describe('ConfirmationDialog', () => { onCloseDialog={mockOnClose} confirmText="confirm" rejectText="reject" - data={{ data: 'test-data' }} /> ); 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/Dialogs/ConfirmationDialog.tsx b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx index 3fa9b23a7..a0ed28cfd 100644 --- a/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx +++ b/compose/neurosynth-frontend/src/components/Dialogs/ConfirmationDialog.tsx @@ -9,16 +9,15 @@ 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, data?: any) => void; + onCloseDialog: (confirm: boolean | undefined) => void; dialogTitle: string; - dialogMessage?: JSX.Element | string; + dialogMessage?: ReactNode | 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) => { + > + {props.confirmText} + + > + {props.rejectText} + )} diff --git a/compose/neurosynth-frontend/src/components/Navbar/Navbar.spec.tsx b/compose/neurosynth-frontend/src/components/Navbar/Navbar.spec.tsx index 6828e2d1a..4886f491c 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 new file mode 100644 index 000000000..880eb979d --- /dev/null +++ b/compose/neurosynth-frontend/src/components/NeurosynthBreadcrumbs.spec.tsx @@ -0,0 +1,69 @@ +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', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render', () => { + render(); + }); + + 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 014911166..b164ed7f8 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 { hasUnsavedStudyChanges } from 'helpers/BeforeUnload.helpers'; +import React, { useState } from 'react'; +import { NavLink, useNavigate } from 'react-router-dom'; +import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; 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 ? ( @@ -20,7 +60,7 @@ const NeurosynthBreadcrumbs: React.FC<{ breadcrumbItems: INeurosynthBreadcrumbs[ color="secondary" variant="h6" sx={{ - maxWidth: '300px', + maxWidth: '200px', textOverflow: 'ellipsis', display: 'block', overflow: 'hidden', @@ -34,10 +74,14 @@ 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', - maxWidth: '300px', + maxWidth: '200px', textOverflow: 'ellipsis', display: 'block', overflow: 'hidden', diff --git a/compose/neurosynth-frontend/src/components/NeurosynthConfirmationChip.tsx b/compose/neurosynth-frontend/src/components/NeurosynthConfirmationChip.tsx index 8d452dd6b..dc49e3250 100644 --- a/compose/neurosynth-frontend/src/components/NeurosynthConfirmationChip.tsx +++ b/compose/neurosynth-frontend/src/components/NeurosynthConfirmationChip.tsx @@ -12,7 +12,7 @@ const NeurosynthConfirmationChip: React.FC setConfirmationDialogIsOpen(true); }; - const handleCloseDialog = (confirm: boolean | undefined, _data: any) => { + const handleCloseDialog = (confirm: boolean | undefined) => { if (confirm && props.onDelete) { props.onDelete(undefined); } diff --git a/compose/neurosynth-frontend/src/components/NeurosynthLoader/__mocks__/NeurosynthLoader.tsx b/compose/neurosynth-frontend/src/components/NeurosynthLoader/__mocks__/NeurosynthLoader.tsx index e7a882452..8166744bf 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 new file mode 100644 index 000000000..52864ca34 --- /dev/null +++ b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.spec.ts @@ -0,0 +1,97 @@ +import { + EUnloadStatus, + hasUnsavedChanges, + setUnloadHandler, + unsetUnloadHandler, +} from './BeforeUnload.helpers'; + +describe('BeforeUnload helpers', () => { + 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)); + + // cleanup + unsetUnloadHandler('project'); + }); + + 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)); + + // cleanup + unsetUnloadHandler('study'); + }); + + 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)); + + // cleanup + unsetUnloadHandler('annotation'); + }); + + 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 new file mode 100644 index 000000000..54aec36c2 --- /dev/null +++ b/compose/neurosynth-frontend/src/helpers/BeforeUnload.helpers.ts @@ -0,0 +1,69 @@ +export enum EUnloadStatus { + STUDYSTORE = 'study-store-unsaved-changes', + PROJECTSTORE = 'project-store-unsaved-changes', + ANNOTATIONSTORE = 'annotation-store-unsaved-changes', +} + +let eventListenerSet = false; + +const onBeforeUnloadHandler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + return 'Are you sure you want to leave?'; +}; + +const onUnloadHandler = (event: any) => { + event.preventDefault(); + window.sessionStorage.removeItem(EUnloadStatus.PROJECTSTORE); + window.sessionStorage.removeItem(EUnloadStatus.STUDYSTORE); + window.sessionStorage.removeItem(EUnloadStatus.ANNOTATIONSTORE); +}; + +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 (!eventListenerSet) { + window.addEventListener('beforeunload', onBeforeUnloadHandler); + window.addEventListener('unload', onUnloadHandler); + eventListenerSet = true; + } +}; + +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.removeEventListener('beforeunload', onBeforeUnloadHandler); + window.removeEventListener('unload', onUnloadHandler); + eventListenerSet = false; + } +}; + +export const hasUnsavedChanges = () => { + return ( + window.sessionStorage.getItem(EUnloadStatus.PROJECTSTORE) === 'true' || + window.sessionStorage.getItem(EUnloadStatus.STUDYSTORE) === 'true' || + window.sessionStorage.getItem(EUnloadStatus.ANNOTATIONSTORE) === 'true' + ); +}; + +export const hasUnsavedStudyChanges = () => { + return ( + window.sessionStorage.getItem(EUnloadStatus.STUDYSTORE) === 'true' || + window.sessionStorage.getItem(EUnloadStatus.ANNOTATIONSTORE) === 'true' // you can edit annotations via study annotations which counts as a study edit + ); +}; 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 000000000..6a836e462 --- /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 beb8d2d63..4ab826cd0 100644 --- a/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts +++ b/compose/neurosynth-frontend/src/hooks/__mocks__/index.ts @@ -1,11 +1,14 @@ +import useInputValidation from 'hooks/useInputValidation'; // don't need to mock this as it isn't making any api calls import { mockAnnotations, + mockBaseStudy, mockConditions, + mockProject, 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 const useUpdateAnalysis = jest.fn().mockReturnValue({ isLoading: false, @@ -87,6 +90,7 @@ const useUpdateStudyset = jest.fn().mockReturnValue({ isLoading: false, isError: false, mutate: jest.fn(), + mutateAsync: jest.fn().mockReturnValue(mockStudysets()), }); const useUpdateStudy = jest.fn().mockReturnValue({ @@ -119,10 +123,21 @@ const useGetExtractionSummary = jest.fn().mockReturnValue({ total: 0, }); -const useGetStudysetById = jest.fn().mockReturnValue({ +// 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 ? studysetNested : studysetNotNested, + }; +}); + +const useGetBaseStudyById = jest.fn().mockReturnValue({ isLoading: false, isError: false, - data: mockStudysetNested(), + data: mockBaseStudy(), }); const useGetFullText = jest.fn().mockReturnValue({ @@ -131,6 +146,26 @@ 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 useGetProjectById = jest.fn().mockReturnValue({ + isLoading: false, + isError: false, + data: mockProject(), +}); + const useIsMounted = () => { return { __esModule: true, @@ -144,27 +179,31 @@ 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, + useGetProjectById, }; diff --git a/compose/neurosynth-frontend/src/hooks/external/useGetFullText.tsx b/compose/neurosynth-frontend/src/hooks/external/useGetFullText.tsx index 544165ef1..22ecd996b 100644 --- a/compose/neurosynth-frontend/src/hooks/external/useGetFullText.tsx +++ b/compose/neurosynth-frontend/src/hooks/external/useGetFullText.tsx @@ -2,6 +2,8 @@ import axios from 'axios'; import { AxiosResponse } from 'axios'; import { useQuery } from 'react-query'; +const env = process.env.REACT_APP_ENV as 'DEV' | 'STAGING' | 'PROD'; + interface ISemanticScholarResponse { data: { externalIds: { @@ -41,7 +43,7 @@ const useGetFullText = (paperTitle?: string | null) => { return paperList[0].openAccessPdf.url; } }, - enabled: !!paperTitle, + enabled: !!paperTitle && env !== 'DEV' && env !== 'STAGING', } ); }; diff --git a/compose/neurosynth-frontend/src/hooks/index.ts b/compose/neurosynth-frontend/src/hooks/index.ts index d69e20912..68900a659 100644 --- a/compose/neurosynth-frontend/src/hooks/index.ts +++ b/compose/neurosynth-frontend/src/hooks/index.ts @@ -36,6 +36,8 @@ import useGetExtractionSummary from './useGetExtractionSummary'; 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, @@ -48,6 +50,7 @@ export { useGetWindowHeight, useGetFullText, useUserCanEdit, + useGetBaseStudyById, // STUDIES useGetBaseStudies, useGetStudyById, @@ -83,4 +86,5 @@ export { useCreateCondition, // project useCreateProject, + useGetProjectById, }; diff --git a/compose/neurosynth-frontend/src/pages/BaseNavigation/BaseNavigation.tsx b/compose/neurosynth-frontend/src/pages/BaseNavigation/BaseNavigation.tsx index 2951c15ab..4170bfc2d 100644 --- a/compose/neurosynth-frontend/src/pages/BaseNavigation/BaseNavigation.tsx +++ b/compose/neurosynth-frontend/src/pages/BaseNavigation/BaseNavigation.tsx @@ -15,7 +15,7 @@ import ForbiddenPage from 'pages/Forbidden/Forbidden'; import TermsAndConditions from 'pages/TermsAndConditions/TermsAndConditions'; import ProjectEditMetaAnalyses from 'pages/Project/components/ProjectEditMetaAnalyses'; import ProjectViewMetaAnalyses from 'pages/Project/components/ProjectViewMetaAnalyses'; -import ProtectedProjectRoute from 'pages/BaseNavigation/components/ProjectedProjectRoute'; +import ProtectedProjectRoute from 'pages/BaseNavigation/components/ProtectedProjectRoute'; import ProtectedRoute from './components/ProtectedRoute'; import ProtectedMetaAnalysesRoute from 'pages/BaseNavigation/components/ProtectedMetaAnalysesRoute'; diff --git a/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.spec.tsx b/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.spec.tsx new file mode 100644 index 000000000..e4da97db6 --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.spec.tsx @@ -0,0 +1,207 @@ +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( + + + +
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/ProjectedProjectRoute.tsx b/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.tsx similarity index 94% rename from compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProjectedProjectRoute.tsx rename to compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProtectedProjectRoute.tsx index 967c1975e..ce4459f9c 100644 --- a/compose/neurosynth-frontend/src/pages/BaseNavigation/components/ProjectedProjectRoute.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/ExtractionPage.tsx b/compose/neurosynth-frontend/src/pages/Extraction/ExtractionPage.tsx index 395d35939..71782251f 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'; @@ -15,7 +15,6 @@ import { useInitProjectStoreIfRequired, useProjectCurationColumns, useProjectExtractionStudysetId, - useProjectMetaAnalysisCanEdit, useProjectName, useProjectUser, } from 'pages/Project/store/ProjectStore'; @@ -40,7 +39,6 @@ const ExtractionPage: React.FC = (props) => { const columns = useProjectCurationColumns(); const loading = useGetProjectIsLoading(); const extractionSummary = useGetExtractionSummary(projectId || ''); - const canEditMetaAnalyses = useProjectMetaAnalysisCanEdit(); const projectUser = useProjectUser(); const canEdit = useUserCanEdit(projectUser || undefined); @@ -86,17 +84,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 +99,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,7 +138,6 @@ const ExtractionPage: React.FC = (props) => { - {isReadyToMoveToNextStep && ( - - )} + + + + +
{showReconcilePrompt && } - + { + if (!projectId) return null; + try { + const parsedState = JSON.parse( + window.sessionStorage.getItem(`${projectId}-extraction-table`) || '{}' + ) as IExtractionTableState | null; + + if (!parsedState?.columnFilters || !parsedState?.sorting || !parsedState?.studies) { + return null; + } else { + return parsedState; + } + } catch (e) { + return null; + } +}; + +export const updateExtractionTableStateInStorage = ( + projectId: string | undefined, + studyId: string, + newStudyId: string +) => { + if (!projectId) return; + const extractionTableState = retrieveExtractionTableState(projectId); + if (!extractionTableState) return; + + const foundIndex = extractionTableState.studies.findIndex((id) => id === studyId); + if (foundIndex < 0) return; + + extractionTableState.studies[foundIndex] = newStudyId; + + window.sessionStorage.setItem( + `${projectId}-extraction-table`, + JSON.stringify(extractionTableState) + ); +}; + +export interface IExtractionTableState { + columnFilters: ColumnFiltersState; + 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/ExtractionTable.tsx b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.tsx index 1024be828..34170a52f 100644 --- a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.tsx +++ b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTable.tsx @@ -1,5 +1,6 @@ import { Box, + Button, Chip, Pagination, Table, @@ -24,13 +25,15 @@ import { SortingState, useReactTable, } from '@tanstack/react-table'; -import { useGetStudysetById } from 'hooks'; +import { useGetStudysetById, useUserCanEdit } from 'hooks'; import { IStudyExtractionStatus } from 'hooks/projects/useGetProjects'; import { StudyReturn } from 'neurostore-typescript-sdk'; import { + useProjectExtractionSetGivenStudyStatusesAsComplete, useProjectExtractionStudysetId, useProjectExtractionStudyStatusList, useProjectId, + useProjectUser, } from 'pages/Project/store/ProjectStore'; import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -43,6 +46,8 @@ import { ExtractionTableNameCell, ExtractionTableNameHeader } from './Extraction import { ExtractionTablePMIDCell, ExtractionTablePMIDHeader } from './ExtractionTablePMID'; import { ExtractionTableStatusCell, ExtractionTableStatusHeader } from './ExtractionTableStatus'; import { ExtractionTableYearCell, ExtractionTableYearHeader } from './ExtractionTableYear'; +import { retrieveExtractionTableState } from './ExtractionTable.helpers'; +import ConfirmationDialog from 'components/Dialogs/ConfirmationDialog'; //allows us to define custom properties for our columns declare module '@tanstack/react-table' { @@ -62,24 +67,22 @@ const ExtractionTable: React.FC = () => { const navigate = useNavigate(); const studyStatusList = useProjectExtractionStudyStatusList(); const { data: studyset } = useGetStudysetById(studysetId, true); // this should already be loaded in the cache from the parent component + const setGivenStudyStatusesAsComplete = useProjectExtractionSetGivenStudyStatusesAsComplete(); + const projectUser = useProjectUser(); + const usercanEdit = useUserCanEdit(projectUser || undefined); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25, }); + const [confirmationDialogIsOpen, setConfirmationDialogIsOpen] = useState(false); useEffect(() => { - const state = sessionStorage.getItem(`${projectId}-extraction-table`); + const state = retrieveExtractionTableState(projectId); if (!state) return; - const parsedState = JSON.parse(state) as { - columnFilters: ColumnFiltersState; - sorting: SortingState; - studies: string[]; - }; - - if (parsedState.columnFilters) setColumnFilters(parsedState.columnFilters); - if (parsedState.sorting) setSorting(parsedState.sorting); + if (state.columnFilters) setColumnFilters(state.columnFilters); + if (state.sorting) setSorting(state.sorting); }, [projectId]); const [columnFilters, setColumnFilters] = useState([]); @@ -105,9 +108,9 @@ const ExtractionTable: React.FC = () => { return [ columnHelper.accessor(({ year }) => (year ? String(year) : ''), { id: 'year', - size: 70, - minSize: 70, - maxSize: 70, + size: 60, + minSize: 60, + maxSize: 60, cell: ExtractionTableYearCell, header: ExtractionTableYearHeader, enableSorting: true, @@ -133,9 +136,9 @@ const ExtractionTable: React.FC = () => { }), columnHelper.accessor('authors', { id: 'authors', - size: 300, - minSize: 300, - maxSize: 300, + size: 100, + minSize: 100, + maxSize: 100, enableSorting: true, enableColumnFilter: true, sortingFn: 'text', @@ -159,26 +162,11 @@ const ExtractionTable: React.FC = () => { filterVariant: 'journal-autocomplete', }, }), - // columnHelper.accessor('doi', { - // id: 'doi', - // size: 10, - // minSize: 10, - // maxSize: 10, - // sortingFn: 'alphanumeric', - // enableSorting: true, - // enableColumnFilter: true, - // filterFn: 'includesString', - // cell: ExtractionTableDOICell, - // header: ExtractionTableDOIHeader, - // meta: { - // filterVariant: 'text', - // }, - // }), columnHelper.accessor('pmid', { id: 'pmid', - size: 100, - minSize: 100, - maxSize: 100, + size: 80, + minSize: 80, + maxSize: 80, enableColumnFilter: true, filterFn: 'includesString', cell: ExtractionTablePMIDCell, @@ -242,6 +230,18 @@ const ExtractionTable: React.FC = () => { [] ); + const handleMarkAllAsComplete = useCallback( + (ok: boolean | undefined) => { + if (ok) { + const studies = (studyset?.studies || []) as Array; + setGivenStudyStatusesAsComplete(studies.map((x) => x.id) as string[]); + } + + setConfirmationDialogIsOpen(false); + }, + [setGivenStudyStatusesAsComplete, studyset?.studies] + ); + const handlePaginationChange = useCallback((_event: any, page: number) => { // page is 0 indexed setPagination((prev) => ({ @@ -269,11 +269,29 @@ const ExtractionTable: React.FC = () => { onChange={handlePaginationChangeMuiPaginator} page={pagination.pageIndex + 1} /> + + + + {table.getHeaderGroups().map((headerGroup) => ( @@ -330,9 +348,16 @@ const ExtractionTable: React.FC = () => { .rows.map((r) => r.original.id), }) ); - navigate( - `/projects/${projectId}/extraction/studies/${row.original.id}/edit` - ); + + if (usercanEdit) { + navigate( + `/projects/${projectId}/extraction/studies/${row.original.id}/edit` + ); + } else { + navigate( + `/projects/${projectId}/extraction/studies/${row.original.id}` + ); + } }} sx={{ '&:hover': { filter: 'brightness(0.9)', cursor: 'pointer' }, @@ -419,7 +444,7 @@ const ExtractionTable: React.FC = () => { ))} - + {columnFilters.length > 0 ? ( Viewing {table.getFilteredRowModel().rows.length} /{' '} @@ -428,7 +453,7 @@ const ExtractionTable: React.FC = () => { ) : ( Total: {data.length} studies )} - + diff --git a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx index 0636a61c9..81488e860 100644 --- a/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx +++ b/compose/neurosynth-frontend/src/pages/Extraction/components/ExtractionTableAuthor.tsx @@ -3,12 +3,18 @@ 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(); - return {value}; + const shortName = getAuthorShortName(value); + return ( + {value} : null}> + {shortName} + + ); }; export const ExtractionTableAuthorHeader: React.FC< 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 000000000..993d33835 --- /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/ProjectPage.tsx b/compose/neurosynth-frontend/src/pages/Project/ProjectPage.tsx index fdb7dda34..69312e608 100644 --- a/compose/neurosynth-frontend/src/pages/Project/ProjectPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Project/ProjectPage.tsx @@ -167,6 +167,7 @@ const ProjectPage: React.FC = (props) => { borderTopLeftRadius: '6px', borderTopRightRadius: '6px', borderColor: 'lightgray', + // borderBottomColor: 'white', borderBottom: '0px', marginBottom: '-2px', }, diff --git a/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx b/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx index fafcaf813..fdea32ba9 100644 --- a/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx +++ b/compose/neurosynth-frontend/src/pages/Project/components/ProjectViewMetaAnalyses.tsx @@ -36,8 +36,8 @@ const ProjectViewMetaAnalyses: React.FC = () => { const [createMetaAnalysisDialogIsOpen, setCreateMetaAnalysisDialogIsOpen] = useState(false); useGuard( - `/projects/${projectId}/edit`, - 'you must finish the meta-analysis creation process to view this page', + `/projects/${projectId}/project`, + 'You must finish the meta-analysis creation process to view this page', projectIdFromProject === undefined || projectId !== projectIdFromProject ? false : !canEditMetaAnalyses diff --git a/compose/neurosynth-frontend/src/pages/Project/store/ProjectStore.ts b/compose/neurosynth-frontend/src/pages/Project/store/ProjectStore.ts index 1c398bd84..c0abdf1ef 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/Project/store/__mocks__/ProjectStore.ts b/compose/neurosynth-frontend/src/pages/Project/store/__mocks__/ProjectStore.ts index 2a4bfa311..66f5f7cbe 100644 --- a/compose/neurosynth-frontend/src/pages/Project/store/__mocks__/ProjectStore.ts +++ b/compose/neurosynth-frontend/src/pages/Project/store/__mocks__/ProjectStore.ts @@ -18,6 +18,8 @@ const useProjectName = jest.fn().mockReturnValue('project-name'); const useProjectCurationColumns = jest.fn(); +const useProjectExtractionReplaceStudyListStatusId = jest.fn().mockReturnValue(jest.fn()); + export { useProjectExtractionAnnotationId, useProjectExtractionStudysetId, @@ -29,4 +31,5 @@ export { useProjectUser, useProjectName, useProjectCurationColumns, + useProjectExtractionReplaceStudyListStatusId, }; diff --git a/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx b/compose/neurosynth-frontend/src/pages/Study/EditStudyPage.tsx index 12712db22..2aa0eb87f 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 } from '@mui/material'; +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, @@ -9,8 +11,6 @@ import EditStudyAnnotations from 'pages/Study/components/EditStudyAnnotations'; 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, @@ -18,15 +18,17 @@ import { useInitStudyStore, useStudyId, } from 'pages/Study/store/StudyStore'; -import { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +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'; const EditStudyPage: React.FC = (props) => { - const { studyId } = useParams<{ studyId: string }>(); + const { projectId, studyId } = useParams<{ projectId: string; studyId: string }>(); + const navigate = useNavigate(); const annotationId = useProjectExtractionAnnotationId(); // study stuff const getStudyIsLoading = useGetStudyIsLoading(); @@ -56,6 +58,27 @@ const EditStudyPage: React.FC = (props) => { studyId, ]); + const [confirmationDialogIsOpen, setConfirmationDialogIsOpen] = useState(false); + + const handleBackToExtraction = () => { + const hasUnsavedChanges = hasUnsavedStudyChanges(); + if (hasUnsavedChanges) { + setConfirmationDialogIsOpen(true); + return; + } + + navigate(`/projects/${projectId}/extraction`); + }; + + const handleCloseConfirmationDialog = (ok: boolean | undefined) => { + setConfirmationDialogIsOpen(false); + if (!ok) return; + + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); + handleBackToExtraction(); + }; + return ( { !studyStoreId || !annotationStoreId || getStudyIsLoading || getAnnotationIsLoading } > - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); diff --git a/compose/neurosynth-frontend/src/pages/Study/ProjectStudyPage.tsx b/compose/neurosynth-frontend/src/pages/Study/ProjectStudyPage.tsx index 5bb8ee9c4..637d0a394 100644 --- a/compose/neurosynth-frontend/src/pages/Study/ProjectStudyPage.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/ProjectStudyPage.tsx @@ -15,7 +15,7 @@ import { } from 'pages/Project/store/ProjectStore'; import EditStudyToolbar from './components/EditStudyToolbar'; -const ProjectPage: React.FC = (props) => { +const ProjectStudyPage: React.FC = (props) => { const initStudyStore = useInitStudyStore(); useInitProjectStoreIfRequired(); @@ -91,4 +91,4 @@ const ProjectPage: React.FC = (props) => { ); }; -export default ProjectPage; +export default ProjectStudyPage; diff --git a/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx new file mode 100644 index 000000000..1ad4fbd60 --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/components/DisplayExtractionTableState.spec.tsx @@ -0,0 +1,130 @@ +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(() => { + jest.clearAllMocks(); + (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', () => { + 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 fb4f1cc7d..a5019dc33 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 'pages/Study/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 || 'All'}`; + return `${acc}, ${curr.id}: ${curr.value || 'All'}`; + }, ''); - 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/EditStudyAnalysisPointsHotTable.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyAnalysisPointsHotTable.tsx index 83aa450db..702008218 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} /> - + { + 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/EditStudyPageHeader.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudyPageHeader.tsx index 6c374dfd7..16073c785 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, 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(); @@ -32,7 +30,7 @@ const EditStudyPageHeader: React.FC = () => { return ( <> - + { }, { text: studyName || '', - link: `/projects/${projectId}/extraction/studies/${studyId}/edit`, + link: '', isCurrentPage: true, }, ]} /> - + {studyYear && `(${studyYear}).`} {studyName} diff --git a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.spec.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.spec.tsx new file mode 100644 index 000000000..f68697d77 --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.spec.tsx @@ -0,0 +1,100 @@ +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'); +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', () => { + 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 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); + }); + + 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 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/components/EditStudySwapVersionButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx index 4713f12de..5b52dbfab 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx +++ b/compose/neurosynth-frontend/src/pages/Study/components/EditStudySwapVersionButton.tsx @@ -1,21 +1,35 @@ 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, + ListItem, + ListItemButton, + Menu, + Tooltip, + 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'; +import { useGetStudysetById, useUpdateStudyset, useGetBaseStudyById } from 'hooks'; import { StudyReturn } from 'neurostore-typescript-sdk'; import { useSnackbar } from 'notistack'; +import { updateExtractionTableStateInStorage } from 'pages/Extraction/components/ExtractionTable.helpers'; import { useProjectExtractionReplaceStudyListStatusId, useProjectExtractionStudysetId, useProjectId, } from 'pages/Project/store/ProjectStore'; -import { useStudyBaseStudyId, useStudyId } from 'pages/Study/store/StudyStore'; -import { useMemo, useState } from 'react'; +import { + useStudyBaseStudyId, + useStudyId, + useUpdateStudyDetails, +} from 'pages/Study/store/StudyStore'; +import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAnnotationId } from 'stores/AnnotationStore.getters'; @@ -23,19 +37,21 @@ const EditStudySwapVersionButton: React.FC = (props) => { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const baseStudyId = useStudyBaseStudyId(); + const { data: baseStudy } = useGetBaseStudyById(baseStudyId || ''); const projectId = useProjectId(); const studyId = useStudyId(); - const { data: baseStudy } = useGetBaseStudyById(baseStudyId || ''); const { mutateAsync: updateStudyset } = useUpdateStudyset(); const updateStudyListStatusWithNewStudyId = useProjectExtractionReplaceStudyListStatusId(); const studysetId = useProjectExtractionStudysetId(); const { data: studyset } = useGetStudysetById(studysetId, false); + const updateStudyByField = useUpdateStudyDetails(); const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const annotationId = useAnnotationId(); const [isSwapping, setIsSwapping] = useState(false); + const [unsavedChangesConfirmationDialog, setUnsavedChangesConfirmationDialog] = useState(false); const [confirmationDialogState, setConfirmationDialogState] = useState<{ isOpen: boolean; selectedVersion?: string; @@ -56,13 +72,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; @@ -88,6 +113,9 @@ const EditStudySwapVersionButton: React.FC = (props) => { }, }); updateStudyListStatusWithNewStudyId(studyId, versionToSwapTo); + updateStudyByField('id', versionToSwapTo); + unsetUnloadHandler('study'); + updateExtractionTableStateInStorage(projectId, studyId, versionToSwapTo); await setAnalysesInAnnotationAsIncluded(annotationId); navigate(`/projects/${projectId}/extraction/studies/${versionToSwapTo}/edit`); @@ -103,16 +131,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(() => { @@ -122,22 +169,36 @@ const EditStudySwapVersionButton: React.FC = (props) => { return ( <> - } - text="Switch study version" - > + + + + + - You are switching from version {studyId} to version + You are switching from version {studyId} to version{' '} {confirmationDialogState.selectedVersion || ''} @@ -146,37 +207,63 @@ const EditStudySwapVersionButton: React.FC = (props) => { } - onCloseDialog={handleCloseConfirmationDialog} + onCloseDialog={handleUnsavedChanges} isOpen={confirmationDialogState.isOpen} rejectText="Cancel" /> + - {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 ( - - + + + + + + + + + - - - - + - - - )} - - {isLoading ? ( - - + + + + - ) : isError ? ( - - There was an error - - ) : ( - <> - + + - {/* tooltip cannot act on a disabled element so we need to add a span here */} - - + + - - - {/* tooltip cannot act on a disabled element so we need to add a span here */} - - + + - - - )} + + + - + )} ); }; diff --git a/compose/neurosynth-frontend/src/pages/Study/components/__mocks__/EditStudySwapVersionButton.tsx b/compose/neurosynth-frontend/src/pages/Study/components/__mocks__/EditStudySwapVersionButton.tsx new file mode 100644 index 000000000..2a03e4c84 --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/components/__mocks__/EditStudySwapVersionButton.tsx @@ -0,0 +1,5 @@ +const MockEditStudySwapVersionButton: React.FC<{}> = (props) => { + return ; +}; + +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 000000000..13c13f0a8 --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/__mocks__/useSaveStudy.tsx @@ -0,0 +1,7 @@ +const useSaveStudy = jest.fn().mockReturnValue({ + isLoading: false, + hasEdits: false, + handleSave: jest.fn(), +}); + +export default useSaveStudy; 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 96% rename from compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.helpers.ts rename to compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.helpers.ts index 4651388a9..d34a0c6ea 100644 --- a/compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.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 new file mode 100644 index 000000000..da08885b7 --- /dev/null +++ b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.spec.tsx @@ -0,0 +1,174 @@ +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, + 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(); + return ( +
+
{isLoading}
+
{hasEdits}
+ +
+ ); +}; + +describe('useSaveStudy hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + 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(); + + 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/components/EditStudySaveButton.tsx b/compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx similarity index 86% rename from compose/neurosynth-frontend/src/pages/Study/components/EditStudySaveButton.tsx rename to compose/neurosynth-frontend/src/pages/Study/hooks/useSaveStudy.tsx index b04308183..e6785f3b9 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,10 +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 { arrayToMetadata } from '../components/EditStudyMetadata'; +import { + useStudy, + useStudyAnalyses, + useStudyHasBeenEdited, + useStudyUser, + useUpdateStudyInDB, + useUpdateStudyIsLoading, +} 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'; -const EditStudySaveButton: React.FC = React.memo((props) => { +const useSaveStudy = () => { const { user } = useAuth0(); const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); @@ -59,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(); @@ -91,6 +90,8 @@ const EditStudySaveButton: React.FC = React.memo((props) => { }); updateAnnotationNotes(updatedNotes); await updateAnnotationInDB(); + unsetUnloadHandler('study'); + unsetUnloadHandler('annotation'); queryClient.invalidateQueries('studies'); queryClient.invalidateQueries('annotations'); @@ -105,6 +106,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 +121,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) { @@ -126,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); @@ -201,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. @@ -227,10 +233,15 @@ 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', { + enqueueSnackbar('Made a new a copy and saved succesfully', { variant: 'success', }); + + return clonedStudyId; } catch (e) { enqueueSnackbar( 'We encountered an error saving your study. Please contact the neurosynth-compose team', @@ -259,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/pages/Study/store/StudyStore.helpers.ts b/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.helpers.ts index 0a7f2bb64..6fa601e4c 100644 --- a/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.helpers.ts +++ b/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.helpers.ts @@ -114,7 +114,7 @@ export interface IStoreStudy extends Omit export type StudyDetails = Pick< StudyReturn, - 'name' | 'description' | 'publication' | 'authors' | 'doi' | 'pmid' | 'pmcid' | 'year' + 'id' | 'name' | 'description' | 'publication' | 'authors' | 'doi' | 'pmid' | 'pmcid' | 'year' >; export type IStudyVersion = Pick; diff --git a/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.ts b/compose/neurosynth-frontend/src/pages/Study/store/StudyStore.ts index d4a8a1232..aebfbcf55 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/pages/Study/store/__mocks__/StudyStore.ts b/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts index ebec882a1..4089c3663 100644 --- a/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts +++ b/compose/neurosynth-frontend/src/pages/Study/store/__mocks__/StudyStore.ts @@ -1,7 +1,37 @@ +import { mockAnalyses, mockStudy } from 'testing/mockData'; + const useStudyId = jest.fn().mockReturnValue('study-id'); 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().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, + useStudy, + useStudyUser, + useUpdateStudyIsLoading, + useStudyHasBeenEdited, + useStudyAnalyses, + useUpdateStudyInDB, +}; diff --git a/compose/neurosynth-frontend/src/stores/AnnotationStore.ts b/compose/neurosynth-frontend/src/stores/AnnotationStore.ts index 57000ef80..723bccb15 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: { @@ -159,6 +161,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 +192,7 @@ export const useAnnotationStore = create< }); }, deleteAnnotationNote: (analysisId) => { + setUnloadHandler('annotation'); set((state) => ({ ...state, annotation: { 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 000000000..8d5a81e07 --- /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 000000000..f354cf1f7 --- /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 b954e2600..7400e7993 100644 --- a/compose/neurosynth-frontend/src/testing/mockData.ts +++ b/compose/neurosynth-frontend/src/testing/mockData.ts @@ -1,12 +1,14 @@ -import { MetaAnalysisReturn } from 'neurosynth-compose-typescript-sdk'; +import { MetaAnalysisReturn, ProjectReturn } from 'neurosynth-compose-typescript-sdk'; import { StudysetReturn, StudyReturn, PointReturn, ConditionReturn, AnalysisReturn, + BaseStudyReturn, } from 'neurostore-typescript-sdk'; import { NeurostoreAnnotation } from 'utils/api'; +import { IStoreStudy } from 'pages/Study/store/StudyStore.helpers'; const mockConditions: () => ConditionReturn[] = () => [ { @@ -69,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(), @@ -280,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', @@ -455,6 +531,282 @@ 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', +}); + +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, @@ -467,4 +819,8 @@ export { mockStudies, mockStudysetNested, mockStudysetNotNested, + mockBaseStudy, + mockStorePoints, + mockStoreStudy, + mockProject, }; diff --git a/compose/neurosynth-frontend/src/utils/__mocks__/api.ts b/compose/neurosynth-frontend/src/utils/__mocks__/api.ts index 9a744bf21..01813d0ce 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(),