Skip to content

Commit a4f32c8

Browse files
authored
Feat 1193 allow users to edit exclusion labels (#1216)
* feat: added measure hook and initial ui elements * feat: normalized database, updated exclusion tags and made exclusions editable * feat: completed edit label feature and fixed tests
1 parent 19d2231 commit a4f32c8

28 files changed

+380
-187
lines changed

compose/neurosynth-frontend/cypress/e2e/pages/EditStudyPage.cy.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe(PAGE_NAME, () => {
2323
cy.login('mocked').visit(PATH);
2424
});
2525

26-
it.only('should load', () => {
26+
it('should load', () => {
2727
cy.visit(PATH)
2828
.wait('@studyFixture')
2929
.wait('@projectFixture')

compose/neurosynth-frontend/cypress/e2e/workflows/Curation/CurationAIInterface.cy.tsx

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ describe('CurationAIInterface', () => {
185185
});
186186

187187
describe('table mode', () => {
188-
it.only('should only show the basic, non-AI options in the manage columns dropdown for the identification phase', () => {
188+
it('should only show the basic, non-AI options in the manage columns dropdown for the identification phase', () => {
189189
cy.contains('button', 'Manually review').click();
190190
cy.contains('button', 'Columns').click();
191191
cy.get('.MuiPopper-root').should('exist').and('not.contain', 'AI');
@@ -288,7 +288,7 @@ describe('CurationAIInterface', () => {
288288
cy.fixture('projects/projectCurationPRISMAWithStudies').then((projectFixture: INeurosynthProjectReturn) => {
289289
// add a duplicate tag to the first identification phase stub
290290
projectFixture.provenance.curationMetadata.columns[0].stubStudies[0].exclusionTag =
291-
defaultExclusionTags.duplicate;
291+
defaultExclusionTags.duplicate.id;
292292

293293
cy.intercept('GET', '**/api/projects/*', {
294294
...projectFixture,
@@ -306,11 +306,11 @@ describe('CurationAIInterface', () => {
306306
it('should show the correct number of duplicates identified message when multiple duplicates exist project-wide', () => {
307307
cy.fixture('projects/projectCurationPRISMAWithStudies').then((projectFixture: INeurosynthProjectReturn) => {
308308
projectFixture.provenance.curationMetadata.columns[0].stubStudies[0].exclusionTag =
309-
defaultExclusionTags.duplicate;
309+
defaultExclusionTags.duplicate.id;
310310
projectFixture.provenance.curationMetadata.columns[0].stubStudies[1].exclusionTag =
311-
defaultExclusionTags.duplicate;
311+
defaultExclusionTags.duplicate.id;
312312
projectFixture.provenance.curationMetadata.columns[0].stubStudies[2].exclusionTag =
313-
defaultExclusionTags.duplicate;
313+
defaultExclusionTags.duplicate.id;
314314

315315
cy.intercept('GET', '**/api/projects/*', {
316316
...projectFixture,
@@ -329,7 +329,7 @@ describe('CurationAIInterface', () => {
329329
cy.fixture('projects/projectCurationPRISMAWithStudies').then((projectFixture: INeurosynthProjectReturn) => {
330330
// exclude all studies in the first column
331331
projectFixture.provenance.curationMetadata.columns[0].stubStudies.forEach((stub) => {
332-
stub.exclusionTag = defaultExclusionTags.duplicate;
332+
stub.exclusionTag = defaultExclusionTags.duplicate.id;
333333
});
334334

335335
cy.intercept('GET', '**/api/projects/*', {
@@ -1048,24 +1048,16 @@ describe('CurationAIInterface', () => {
10481048
cy.login('mocked').visit('/projects/abc123/curation').wait('@projectFixture');
10491049
cy.contains('li', 'Excluded').click();
10501050
cy.contains('Duplicate').click();
1051-
cy.contains('No studies for this exclusion').should('exist');
1051+
cy.contains('No studies have been marked as').should('exist');
10521052
});
10531053

10541054
it('should show excluded studies in the exclusions view', () => {
10551055
cy.fixture('projects/projectCurationSimpleWithStudies').then((projectFixture: INeurosynthProjectReturn) => {
1056-
projectFixture.provenance.curationMetadata.columns[0].stubStudies[0].exclusionTag = {
1057-
id: defaultExclusionTags.duplicate.id,
1058-
label: defaultExclusionTags.duplicate.label,
1059-
isAssignable: false,
1060-
isExclusionTag: true,
1061-
};
1062-
1063-
projectFixture.provenance.curationMetadata.columns[0].stubStudies[1].exclusionTag = {
1064-
id: defaultExclusionTags.duplicate.id,
1065-
label: defaultExclusionTags.duplicate.label,
1066-
isAssignable: false,
1067-
isExclusionTag: true,
1068-
};
1056+
projectFixture.provenance.curationMetadata.columns[0].stubStudies[0].exclusionTag =
1057+
defaultExclusionTags.duplicate.id;
1058+
1059+
projectFixture.provenance.curationMetadata.columns[0].stubStudies[1].exclusionTag =
1060+
defaultExclusionTags.duplicate.id;
10691061

10701062
cy.intercept('GET', '**/api/projects/*', {
10711063
...projectFixture,
@@ -1081,19 +1073,11 @@ describe('CurationAIInterface', () => {
10811073

10821074
it('should unexclude the study', () => {
10831075
cy.fixture('projects/projectCurationSimpleWithStudies').then((projectFixture: INeurosynthProjectReturn) => {
1084-
projectFixture.provenance.curationMetadata.columns[0].stubStudies[0].exclusionTag = {
1085-
id: defaultExclusionTags.duplicate.id,
1086-
label: defaultExclusionTags.duplicate.label,
1087-
isAssignable: false,
1088-
isExclusionTag: true,
1089-
};
1090-
1091-
projectFixture.provenance.curationMetadata.columns[0].stubStudies[1].exclusionTag = {
1092-
id: defaultExclusionTags.duplicate.id,
1093-
label: defaultExclusionTags.duplicate.label,
1094-
isAssignable: false,
1095-
isExclusionTag: true,
1096-
};
1076+
projectFixture.provenance.curationMetadata.columns[0].stubStudies[0].exclusionTag =
1077+
defaultExclusionTags.duplicate.id;
1078+
1079+
projectFixture.provenance.curationMetadata.columns[0].stubStudies[1].exclusionTag =
1080+
defaultExclusionTags.duplicate.id;
10971081

10981082
cy.intercept('GET', '**/api/projects/*', {
10991083
...projectFixture,
@@ -1109,5 +1093,31 @@ describe('CurationAIInterface', () => {
11091093
cy.get('.MuiListItem-root .MuiTypography-root').get('li:contains(Duplicate)').should('have.length', 2); // includes the Duplicate exclusion group list item
11101094
});
11111095
});
1096+
1097+
it('should allow the user to edit an exclusion', () => {
1098+
cy.fixture('projects/projectCurationSimpleWithStudies').then((projectFixture: INeurosynthProjectReturn) => {
1099+
projectFixture.user = 'auth0|62e0e6c9dd47048572613b4d'; // this user can edit the project
1100+
1101+
projectFixture.provenance.curationMetadata.columns[0].stubStudies[0].exclusionTag =
1102+
'my-custom-exclusion';
1103+
1104+
cy.intercept('GET', '**/api/projects/*', {
1105+
...projectFixture,
1106+
} as INeurosynthProjectReturn).as('projectFixture');
1107+
});
1108+
1109+
cy.login('mocked').visit('/projects/abc123/curation').wait('@projectFixture');
1110+
1111+
cy.contains('li', 'Excluded').click();
1112+
cy.contains('My Custom Exclusion').click(); // open Duplicate exclusion group
1113+
1114+
cy.contains('h4', 'My Custom Exclusion').parent().find('[data-testid="EditIcon"]').click();
1115+
1116+
cy.get('input[type="text"]').clear();
1117+
cy.get('input[type="text"]').type('New My Custom Exclusion');
1118+
1119+
cy.contains('button', 'Save').click();
1120+
cy.contains('h4', 'New My Custom Exclusion').should('exist');
1121+
});
11121122
});
11131123
});

compose/neurosynth-frontend/cypress/e2e/workflows/Curation/ImportSleuth.cy.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import baseStudiesSingleSleuthStudyResponse from '../../../fixtures/ImportSleuth
44

55
describe('ImportSleuthDialog', () => {
66
const neurostoreAPIBaseURL = Cypress.env('neurostoreAPIBaseURL');
7+
console.log('neurostoreAPIBaseURL', neurostoreAPIBaseURL);
78

89
beforeEach(() => {
910
cy.clearLocalStorage();

compose/neurosynth-frontend/cypress/e2e/workflows/Extraction/ExtractionTable.cy.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe('ExtractionTable', () => {
146146
});
147147
});
148148

149-
it.only('should remove the filter if the delete button is clicked', () => {
149+
it('should remove the filter if the delete button is clicked', () => {
150150
let studysetYear = '';
151151
// ARRANGE
152152
cy.wait('@studysetFixture').then((studysetFixture) => {
@@ -635,6 +635,7 @@ describe('ExtractionTable', () => {
635635
const parsedState = JSON.parse(state || '{}');
636636

637637
cy.wrap(parsedState).should('deep.equal', {
638+
pagination: { pageIndex: 0, pageSize: 25 },
638639
columnFilters: [{ id: 'name', value: 'Activation' }],
639640
sorting: [{ id: 'year', desc: true }],
640641
studies: ['3zutS8kyg2sy'],

compose/neurosynth-frontend/cypress/fixtures/projects/projectCurationSimpleWithStudies.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@
125125
"isAssignable": true,
126126
"isExclusionTag": true,
127127
"label": "Duplicate"
128+
},
129+
{
130+
"id": "my-custom-exclusion",
131+
"isAssignable": true,
132+
"isExclusionTag": true,
133+
"label": "My Custom Exclusion"
128134
}
129135
],
130136
"identificationSources": [

compose/neurosynth-frontend/src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import useGetToken from './useGetToken';
44
import useGuard from './useGuard';
55
import useGetTour from './useGetTour';
66
import useGetWindowHeight from './useGetWindowHeight';
7+
import useMeasure from './useMeasure';
78

89
import useCreateAlgorithmSpecification from './metaAnalyses/useCreateAlgorithmSpecification';
910
import useGetMetaAnalysesByIds from './metaAnalyses/useGetMetaAnalysesByIds';
@@ -50,6 +51,7 @@ export {
5051
useGuard,
5152
useGetTour,
5253
useGetWindowHeight,
54+
useMeasure,
5355
useGetFullText,
5456
useUserCanEdit,
5557
useGetBaseStudyById,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
interface UseMeasureResult<T extends HTMLElement> {
4+
ref: React.RefObject<T>;
5+
width: number;
6+
height: number;
7+
}
8+
9+
const useMeasure = <T extends HTMLElement>(): UseMeasureResult<T> => {
10+
const ref = useRef<T>(null);
11+
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
12+
13+
useEffect(() => {
14+
const element = ref.current;
15+
if (!element) return;
16+
17+
const resizeObserver = new ResizeObserver((entries) => {
18+
if (!entries[0]) return;
19+
const { width, height } = entries[0].contentRect;
20+
setDimensions({ width, height });
21+
});
22+
23+
resizeObserver.observe(element);
24+
25+
return () => {
26+
resizeObserver.disconnect();
27+
};
28+
}, []);
29+
30+
return { ref, ...dimensions };
31+
};
32+
33+
export default useMeasure;

compose/neurosynth-frontend/src/pages/Curation/Curation.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface ICurationStubStudy {
1818
journal: string;
1919
abstractText: string;
2020
articleLink: string;
21-
exclusionTag: ITag | null;
21+
exclusionTag: string | null;
2222
identificationSource: ISource;
2323
tags: ITag[];
2424
neurostoreId?: string;

compose/neurosynth-frontend/src/pages/Curation/components/CurationBoardAIInterfaceCuratorTableSelectedRowsActions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const CurationBoardAIInterfaceCuratorTableSelectedRowsActions: React.FC<{
3030

3131
const handleAddExclusionForRows = (exclusionTag: ITag) => {
3232
rows.forEach((stub) => {
33-
setExclusionForStub(columnIndex, stub.id, exclusionTag);
33+
setExclusionForStub(columnIndex, stub.id, exclusionTag.id);
3434
});
3535
table.resetRowSelection();
3636
};

compose/neurosynth-frontend/src/pages/Curation/components/CurationBoardAIInterfaceExclude.tsx

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { Box, Typography } from '@mui/material';
2-
import { useGetWindowHeight } from 'hooks';
3-
import { useProjectCurationColumns } from 'pages/Project/store/ProjectStore';
2+
import { useGetWindowHeight, useMeasure, useUserCanEdit } from 'hooks';
3+
import {
4+
useProjectCurationColumns,
5+
useProjectExclusionTag,
6+
useProjectUser,
7+
useUpdateExclusionTag,
8+
} from 'pages/Project/store/ProjectStore';
49
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
510
import { FixedSizeList } from 'react-window';
611
import { ICurationStubStudy } from '../Curation.types';
712
import { IGroupListItem } from './CurationBoardAIGroupsList';
813
import CurationEditableStubSummary from './CurationEditableStubSummary';
914
import CurationStubListItemVirtualizedContainer from './CurationStubListItemVirtualizedContainer';
15+
import TextEdit from 'components/TextEdit/TextEdit';
16+
import { ENeurosynthTagIds } from 'pages/Project/store/ProjectStore.types';
1017

1118
const CurationBoardAIInterfaceExclude: React.FC<{
1219
group: IGroupListItem;
@@ -16,13 +23,23 @@ const CurationBoardAIInterfaceExclude: React.FC<{
1623
const listRef = useRef<FixedSizeList>(null);
1724
const [selectedStubId, setSelectedStubId] = useState<string>();
1825
const columns = useProjectCurationColumns();
26+
const projectUser = useProjectUser();
27+
const canEdit = useUserCanEdit(projectUser || undefined);
28+
const exclusionTag = useProjectExclusionTag(group.id);
29+
const updateExclusionTag = useUpdateExclusionTag();
30+
31+
// Check if this is a default exclusion tag
32+
const isDefaultExclusion = useMemo(() => {
33+
const defaultExclusionIds = Object.values(ENeurosynthTagIds).filter((id) => id.includes('_exclusion'));
34+
return defaultExclusionIds.some((defaultExclusionId) => defaultExclusionId === exclusionTag?.id);
35+
}, [exclusionTag]);
1936

2037
const stubs = useMemo(() => {
2138
const allStudies = columns.reduce((acc, curr) => [...acc, ...curr.stubStudies], [] as ICurationStubStudy[]);
2239
return allStudies
23-
.filter((study) => study.exclusionTag && study.exclusionTag.id === group.id)
40+
.filter((study) => study.exclusionTag && study.exclusionTag === exclusionTag?.id)
2441
.sort((a, b) => (a.title || '').toLocaleLowerCase().localeCompare((b.title || '').toLocaleLowerCase()));
25-
}, [columns, group.id]);
42+
}, [columns, exclusionTag?.id]);
2643

2744
const selectedStub: ICurationStubStudy | undefined = useMemo(
2845
() => (stubs || []).find((stub) => stub.id === selectedStubId),
@@ -44,32 +61,63 @@ const CurationBoardAIInterfaceExclude: React.FC<{
4461
setSelectedStubId(nextStub.id);
4562
}, [selectedStub?.id, stubs]);
4663

47-
const pxInVh = Math.round(windowHeight - 240);
64+
const handleUpdateExclusionTag = useCallback(
65+
(newName: string) => {
66+
if (!exclusionTag?.id) return;
67+
updateExclusionTag(exclusionTag.id, newName);
68+
},
69+
[exclusionTag?.id, updateExclusionTag]
70+
);
71+
72+
const { ref: labelContainerRef, height: labelContainerHeight } = useMeasure<HTMLDivElement>();
73+
const pxInVh = Math.round(windowHeight - 220 - labelContainerHeight);
4874

75+
// when the group changes, automatically select the first stub
4976
useEffect(() => {
5077
if (stubs.length > 0) {
5178
setSelectedStubId(stubs[0]?.id);
5279
}
5380
// eslint-disable-next-line react-hooks/exhaustive-deps
5481
}, [group.id]);
5582

83+
// scroll to the selected stub when a stub is selected and the view changes to focus mode
5684
useEffect(() => {
5785
if (!listRef.current) return;
5886
const selectedItemIndex = (stubs || []).findIndex((x) => x.id === selectedStubId);
5987
listRef.current.scrollToItem(selectedItemIndex, 'smart');
6088
}, [selectedStubId, stubs]);
6189

90+
// reset scroll position of details page when the selected stub changes
6291
useEffect(() => {
6392
if (scrollableBoxRef.current) {
6493
scrollableBoxRef.current.scrollTo(0, 0);
6594
}
6695
}, [selectedStub?.id]);
6796

6897
return (
69-
<Box sx={{ display: 'flex', padding: '1rem', height: 'calc(100% - 48px - 8px - 20px)' }}>
70-
{stubs.length === 0 && <Typography color="warning.dark">No studies for this exclusion.</Typography>}
71-
{stubs.length > 0 && (
72-
<>
98+
<Box sx={{ padding: '1rem' }}>
99+
<Box mb={2} ref={labelContainerRef}>
100+
<TextEdit
101+
textFieldSx={{ input: { fontSize: '1.25rem' } }}
102+
onSave={(updatedText) => handleUpdateExclusionTag(updatedText)}
103+
label="Group Label"
104+
textToEdit={exclusionTag?.label || ''}
105+
editIconIsVisible={canEdit && !isDefaultExclusion}
106+
>
107+
<Typography variant="h4" sx={{ color: 'error.dark' }}>
108+
{exclusionTag?.label || ''}
109+
</Typography>
110+
</TextEdit>
111+
<Typography variant="body2" color="text.secondary">
112+
These studies have been excluded due to the following reason: {group?.label || ''}
113+
</Typography>
114+
</Box>
115+
{stubs.length === 0 ? (
116+
<Box sx={{ display: 'flex' }}>
117+
<Typography color="warning.dark">No studies have been marked as {group?.label || ''}.</Typography>
118+
</Box>
119+
) : (
120+
<Box sx={{ display: 'flex' }}>
73121
<Box>
74122
<FixedSizeList
75123
height={pxInVh}
@@ -89,14 +137,14 @@ const CurationBoardAIInterfaceExclude: React.FC<{
89137
{CurationStubListItemVirtualizedContainer}
90138
</FixedSizeList>
91139
</Box>
92-
<Box ref={scrollableBoxRef} sx={{ overflowY: 'auto', width: '100%' }}>
140+
<Box ref={scrollableBoxRef} sx={{ overflowY: 'auto', width: '100%', height: `${pxInVh}px` }}>
93141
<CurationEditableStubSummary
94142
onMoveToNextStub={handleMoveToNextStub}
95143
columnIndex={selectedColumnIndex || 0}
96144
stub={selectedStub}
97145
/>
98146
</Box>
99-
</>
147+
</Box>
100148
)}
101149
</Box>
102150
);

0 commit comments

Comments
 (0)