diff --git a/src/Routes.jsx b/src/Routes.jsx index 5ffd352e7..a25b37733 100644 --- a/src/Routes.jsx +++ b/src/Routes.jsx @@ -44,6 +44,7 @@ import {checkEnv, envGroups} from './utils/EnvironmentUtils'; import { DatasetUpdateForm } from './pages/DatasetUpdateForm'; import DatasetSearch from './pages/DatasetSearch'; import { StudyUpdateForm } from './pages/StudyUpdateForm'; +import EditDac from './pages/manage_dac/EditDac'; const Routes = (props) => ( @@ -72,6 +73,9 @@ const Routes = (props) => ( + + {checkEnv(envGroups.DEV) && } + {checkEnv(envGroups.DEV) && } diff --git a/src/assets/DUOS_Uniform_Data_Access_Agreement.pdf b/src/assets/DUOS_Uniform_Data_Access_Agreement.pdf new file mode 100644 index 000000000..f09d59658 Binary files /dev/null and b/src/assets/DUOS_Uniform_Data_Access_Agreement.pdf differ diff --git a/src/components/SimpleTable.jsx b/src/components/SimpleTable.jsx index 800e33642..989bfba08 100644 --- a/src/components/SimpleTable.jsx +++ b/src/components/SimpleTable.jsx @@ -96,7 +96,7 @@ const DataRows = ({rowData, baseStyle, columnHeaders, rowWrapper = ({renderedRow const id = rowData[index][0].id; const mapKey = id || `noId-index-${index}`; if (rowData[index][0].striped) { - baseStyle.backgroundColor = index % 2 === 0 ? 'white' : '#e2e8f4'; + baseStyle.backgroundColor = index % 2 === 0 ? 'white' : '#F7F8F9'; } const renderedRow = (
diff --git a/src/components/manage_dac_table/ManageDacTable.jsx b/src/components/manage_dac_table/ManageDacTable.jsx index cc0a10535..34ea96118 100644 --- a/src/components/manage_dac_table/ManageDacTable.jsx +++ b/src/components/manage_dac_table/ManageDacTable.jsx @@ -149,7 +149,6 @@ export const ManageDacTable = function ManageDacTable(props) { setShowDatasetsModal, setShowMembersModal, setShowConfirmationModal, - setIsEditMode, setSelectedDac, setSelectedDatasets } = props; @@ -157,14 +156,12 @@ export const ManageDacTable = function ManageDacTable(props) { const editDac = useCallback((selectedDac) => { setShowEditPage(true); setSelectedDac(selectedDac); - setIsEditMode(true); - }, [setShowEditPage, setSelectedDac, setIsEditMode]); + }, [setShowEditPage, setSelectedDac]); const deleteDac = useCallback((selectedDac) => { setShowConfirmationModal(true); setSelectedDac(selectedDac); - setIsEditMode(false); - }, [setShowConfirmationModal, setSelectedDac, setIsEditMode]); + }, [setShowConfirmationModal, setSelectedDac]); const viewMembers = useCallback((selectedDac) => { setShowMembersModal(true); diff --git a/src/components/manage_dac_table/ManageDacTableCellData.jsx b/src/components/manage_dac_table/ManageDacTableCellData.jsx index a3d3dd9bb..b3eaf5461 100644 --- a/src/components/manage_dac_table/ManageDacTableCellData.jsx +++ b/src/components/manage_dac_table/ManageDacTableCellData.jsx @@ -6,6 +6,7 @@ import {Styles} from '../../libs/theme'; import {Delete} from '@mui/icons-material'; import {Link} from 'react-router-dom'; import editPencilIcon from '../../images/edit_pencil.svg'; +import { checkEnv, envGroups } from '../../utils/EnvironmentUtils'; export function nameCellData({name = '- -', dac, viewMembers, dacId, label= 'dac-name'}) { return { @@ -65,7 +66,7 @@ export function actionsCellData({dac, deleteDac, userRole}) {
{ + return { + attachment: '', + validAttachment: true + }; +}; + +export const UploadDaaModal = (props) => { + const [modalState, setModalState] = useState(getInitialState); + + const okHandler = async () => { + props.onAttachmentChange(modalState.attachment); + props.onCloseRequest(); + }; + + const closeHandler = () => { + props.onCloseRequest(); + }; + + const attachmentChangeHandler = (e) => { + setModalState({ + ...modalState, + attachment: e + }); + }; + + const attachmentCancel = () => { + setModalState({ + ...modalState, + attachment: '' + }); + }; + + const iconStyle = { + verticalAlign: 'middle', + height: 40, + width: 30, + paddingLeft: '1rem', + }; + + return ( + +
+ +
+ Upload a file +
+
+ attachmentChangeHandler(acceptedFiles)} maxFiles={1} multiple={false}> + {({ isDragActive, getRootProps, getInputProps }) => ( +
+ { + modalState.attachment.length === 0 && ( +
+ +
+ ) + } +
+ + + {modalState.attachment.length === 0 ? 'Drag and drop a file to upload or click to browse files' : modalState.attachment[0].name} + + { + modalState.attachment.length !== 0 && ( + + ) + } +
+
+ )} +
+ { + modalState.attachment.length !== 0 && ( + Clicking Save will create this new Data Access Agreement and associate it with this DAC. + ) + } +
+ +
+ +
+
+
+ + ); +}; diff --git a/src/libs/ajax/DAA.js b/src/libs/ajax/DAA.js index a529342f6..a07181840 100644 --- a/src/libs/ajax/DAA.js +++ b/src/libs/ajax/DAA.js @@ -1,6 +1,7 @@ import fileDownload from 'js-file-download'; import { getApiUrl } from '../ajax'; import { Config } from '../config'; +import { isFileEmpty } from '../utils'; import axios from 'axios'; @@ -64,4 +65,43 @@ export const DAA = { fileDownload(response.data, daaFileName); }); }, -}; + + createDaa: async (file, dacId) => { + if (isFileEmpty(file)) { + return Promise.resolve({ data: null }); + } else { + let authOpts = Config.authOpts(); + authOpts.headers['Content-Type'] = 'multipart/form-data'; + let formData = new FormData(); + formData.append('file', file); + const url = `${await getApiUrl()}/api/daa/dac/${dacId}`; + return axios.post(url, formData, authOpts); + } + }, + + addDaaToDac: async (daaId, dacId) => { + const url = `${await getApiUrl()}/api/daa/${daaId}/dac/${dacId}`; + const res = await axios.put(url, {}, Config.authOpts()); + return res.status; + }, + + deleteDaa: async (daaId) => { + const url = `${await getApiUrl()}/api/daa/${daaId}`; + const res = await axios.delete(url, Config.authOpts()); + return res; + }, + + deleteDacDaaRelationship: async (daaId, dacId) => { + const url = `${await getApiUrl()}/api/daa/${daaId}/dac/${dacId}`; + const res = await axios.delete(url, Config.authOpts()); + return res; + }, + + // NOTE: In the future, this functionality should be handled in the backend and should not be + // dependent on the UI. + sendDaaUpdateEmails: async (dacId, oldDaaId, newDaaName) => { + const url = `${await getApiUrl()}/api/daa/${dacId}/updated/${oldDaaId}/${newDaaName}`; + const res = await axios.post(url, {}, Config.authOpts()); + return res.status; + } +}; \ No newline at end of file diff --git a/src/libs/models.js b/src/libs/models.js index 3bbde0792..4c7f5b68d 100644 --- a/src/libs/models.js +++ b/src/libs/models.js @@ -6,7 +6,8 @@ export const Models = { createDate: null, updateDate: null, chairpersons: [], - members: [] + members: [], + associatedDaa: null }, dar: { researcherProperties: [], diff --git a/src/pages/manage_dac/EditDac.jsx b/src/pages/manage_dac/EditDac.jsx new file mode 100644 index 000000000..e0ff83795 --- /dev/null +++ b/src/pages/manage_dac/EditDac.jsx @@ -0,0 +1,579 @@ +import * as ld from 'lodash'; +import React, { useEffect, useState } from 'react'; +import AsyncSelect from 'react-select/async'; +import { DAC } from '../../libs/ajax/DAC'; +import { DAA } from '../../libs/ajax/DAA'; +import { Models } from '../../libs/models'; +import { PromiseSerial } from '../../libs/utils'; +import { Alert } from '../../components/Alert'; +import { Link } from 'react-router-dom'; +import { DacUsers } from './DacUsers'; +import { Notifications } from '../../libs/utils'; +import editDACIcon from '../../images/dac_icon.svg'; +import backArrowIcon from '../../images/back_arrow.svg'; +import { Spinner } from '../../components/Spinner'; +import { Styles } from '../../libs/theme'; +import DUOSUniformDataAccessAgreement from '../../assets/DUOS_Uniform_Data_Access_Agreement.pdf'; +import PublishIcon from '@mui/icons-material/Publish'; +import { UploadDaaModal } from '../../components/modals/UploadDaaModal'; + +export const CHAIR = 'chair'; +export const MEMBER = 'member'; +const CHAIRPERSON = 'Chairperson'; +const ADMIN = 'Admin'; + +export default function EditDac(props) { + const [state, setState] = useState({ + error: Models.error, + dirtyFlag: false, + dac: Models.dac, + chairsSelectedOptions: [], + chairIdsToAdd: [], + chairIdsToRemove: [], + membersSelectedOptions: [], + memberIdsToAdd: [], + memberIdsToRemove: [], + searchInputChanged: false + }); + const [isLoading, setIsLoading] = useState(true); + const [newDaaId, setNewDaaId] = useState(null); + const [selectedDaa, setSelectedDaa] = useState(null); + const [createdDaa, setCreatedDaa] = useState(null); + const [uploadedDAAFile, setUploadedDaaFile] = useState(null); + const [daaFileData, setDaaFileData] = useState(null); + const [showUploadModal, setShowUploadModal] = useState(false); + const [fetchedDac, setFetchedDac] = useState(null); + const dacId = props.match.params.dacId; + const [broadDaa, setBroadDaa] = useState(null); + const [matchingDaas, setMatchingDaas] = useState([]); + const dacText = dacId === undefined ? 'Create a new Data Access Committee in the system' : 'Manage My Data Access Committee'; + + useEffect(() => { + const fetchData = async () => { + if (dacId !== undefined) { + try { + const fetchedDac = await DAC.get(dacId); + setFetchedDac(fetchedDac); + const daas = await DAA.getDaas(); + const broadDaa = daas.find(daa => daa.broadDaa === true); + setBroadDaa(broadDaa); + setState(prev => ({ ...prev, dac: fetchedDac })); + const matchingDaas = daas.filter(daa => daa.initialDacId === fetchedDac.dacId); + setMatchingDaas(matchingDaas); + const daa = fetchedDac?.associatedDaa ? fetchedDac.associatedDaa : null; + setSelectedDaa(daa?.daaId ? daa : null); + } + catch(e) { + Notifications.showError({text: 'Error: Unable to retrieve current DAC from server'}); + } + } else { + try { + const daas = await DAA.getDaas(); + const broadDaa = daas.find(daa => daa.broadDaa === true); + setBroadDaa(broadDaa); + } + catch(e) { + Notifications.showError({text: 'Error: Unable to retrieve current DAC from server'}); + } + } + }; + fetchData(); + setIsLoading(false); + }, [dacId, setState]); + + const okHandler = async (event) => { + event.preventDefault(); + + let currentDac = state.dac; + if (state.dirtyFlag) { + if (props.location.state.userRole === ADMIN) { + if (dacId !== undefined) { + await DAC.update(currentDac.dacId, currentDac.name, currentDac.description, currentDac.email); + } else { + if (daaFileData === null && selectedDaa.daaId !== broadDaa.daaId) { + handleErrors('Please select either the default agreement or upload your own agreement before saving.'); + return; + } else if (daaFileData !== null && selectedDaa === undefined) { + currentDac = await DAC.create(currentDac.name, currentDac.description, currentDac.email); + const createdDaa = await DAA.createDaa(daaFileData, currentDac.dacId); + setCreatedDaa(createdDaa.data); + } else { + currentDac = await DAC.create(currentDac.name, currentDac.description, currentDac.email); + } + } + + // Order here is important. Since users cannot have multiple roles in the + // same DAC, we have to make sure we remove users before re-adding any + // back in a different role. + // Chairs are a special case since we cannot remove all chairs from a DAC + // so we handle that case first. + const ops0 = state.chairIdsToAdd.map(id => () => DAC.removeDacMember(currentDac.dacId, id)); + const ops1 = state.memberIdsToRemove.map(id => () => DAC.removeDacMember(currentDac.dacId, id)); + const ops2 = state.chairIdsToAdd.map(id => () => DAC.addDacChair(currentDac.dacId, id)); + const ops3 = state.chairIdsToRemove.map(id => () => DAC.removeDacChair(currentDac.dacId, id)); + const ops4 = state.memberIdsToAdd.map(id => () => DAC.addDacMember(currentDac.dacId, id)); + const ops5 = newDaaId !== null && selectedDaa !== undefined ? [() => DAA.addDaaToDac(newDaaId, currentDac.dacId)] : []; + const allOperations = ops0.concat(ops1, ops2, ops3, ops4, ops5); + const responses = await PromiseSerial(allOperations); + const errorCodes = ld.filter(responses, r => JSON.stringify(r) !== '200' && JSON.stringify(r.status) !== '201'); + if (!ld.isEmpty(errorCodes)) { + handleErrors('There was an error saving DAC information. Please verify that the DAC is correct by viewing the current information.'); + } else { + closeHandler(); + } + } else { + closeHandler(); + } + } + }; + + const closeHandler = () => { + props.history.push('/manage_dac'); + }; + + const handleErrors = (message) => { + Notifications.showError({text: message}); + }; + + const chairSearch = (query, callback) => { + // A valid chair is any user: + // * minus current chairs + // * minus current members (you shouldn't be both a chair and a member) + // * minus any new members selected (you shouldn't be both a chair and a member) + // * plus any members that are slated for removal + // * plus any chairs that are slated for removal + + const invalidChairs = ld.difference( + ld.union( + ld.map(state.dac.chairpersons, 'userId'), + ld.map(state.dac.members, 'userId'), + state.memberIdsToAdd), + state.memberIdsToRemove, + state.chairIdsToRemove); + userSearch(invalidChairs, query, callback); + }; + + const memberSearch = (query, callback) => { + // A valid member is any user: + // * minus current members + // * minus current chairs (you shouldn't be both a chair and a member) + // * minus any new chairs selected (you shouldn't be both a chair and a member) + // * plus any members that are slated for removal + // * plus any chairs that are slated for removal + + const invalidMembers = ld.difference( + ld.union( + ld.map(state.dac.members, 'userId'), + ld.map(state.dac.chairpersons, 'userId'), + state.chairIdsToAdd), + state.memberIdsToRemove, + state.chairIdsToRemove); + userSearch(invalidMembers, query, callback); + }; + + const userSearch = (invalidUserIds, query, callback) => { + DAC.autocompleteUsers(query).then( + items => { + const filteredUsers = ld.filter(items, item => { return !invalidUserIds.includes(item.userId); }); + const options = filteredUsers.map(function (item) { + return { + key: item.userId, + value: item.userId, + label: item.displayName + ' (' + item.email + ')', + item: item + }; + }); + callback(options); + }, + rejected => { + handleErrors(rejected); + }); + }; + + const onChairSearchChange = (data) => { + setState(prev => ({ + ...prev, + chairIdsToAdd: ld.map(data, 'item.userId'), + chairsSelectedOptions: data, + dirtyFlag: true + })); + }; + + const onMemberSearchChange = (data) => { + setState(prev => ({ + ...prev, + memberIdsToAdd: ld.map(data, 'item.userId'), + membersSelectedOptions: data, + dirtyFlag: true + })); + }; + + const onSearchInputChanged = () => { + setState(prev => ({ + ...prev, + searchInputChanged: true + })); + }; + + const onSearchMenuClosed = () => { + setState(prev => ({ + ...prev, + searchInputChanged: false + })); + }; + + const handleChange = (event) => { + const target = event.target; + const value = target.value; + const name = target.name; + + setState(prev => { + let newDac = Object.assign({}, prev.dac); + newDac[name] = value; + return { + ...prev, + dac: newDac, + dirtyFlag: true + }; + }); + }; + + const removeDacMember = (dacId, userId, role) => { + switch (role) { + case CHAIR: + if (state.chairIdsToRemove.includes(userId)) { + setState(prev => ({ + ...prev, + chairIdsToRemove: ld.difference(prev.chairIdsToRemove, [userId]), + dirtyFlag: true + })); + } else { + setState(prev => ({ + ...prev, + chairIdsToRemove: ld.union(prev.chairIdsToRemove, [userId]), + dirtyFlag: true + })); + } + break; + case MEMBER: + if (state.memberIdsToRemove.includes(userId)) { + setState(prev => ({ + ...prev, + memberIdsToRemove: ld.difference(prev.memberIdsToRemove, [userId]), + dirtyFlag: true + })); + } else { + setState(prev => ({ + ...prev, + memberIdsToRemove: ld.union(prev.memberIdsToRemove, [userId]), + dirtyFlag: true + })); + } + break; + default: + break; + } + }; + + const handleAttachment = async(attachment) => { + if (dacId !== undefined) { + setUploadedDaaFile(attachment); + setDaaFileData(attachment[0]); + const createdDaa = await DAA.createDaa(attachment[0], state.dac.dacId); + setCreatedDaa(createdDaa.data); + setState(prev => ({ + ...prev, + dirtyFlag: true + })); + } else { + setUploadedDaaFile(attachment); + setDaaFileData(attachment[0]); + setState(prev => ({ + ...prev, + dirtyFlag: true + })); + setSelectedDaa(undefined); + } + }; + + const handleDaaChange = (daaId) => { + if (daaId === undefined) { + setSelectedDaa(undefined); + setState(prev => ({ + ...prev, + dirtyFlag: true + })); + } else { + setSelectedDaa({ ...selectedDaa, daaId: daaId }); + setNewDaaId(daaId); + setState(prev => ({ + ...prev, + dirtyFlag: true + })); + } + }; + + const DaaItem = ({ specificDaa }) => ( +
+ handleDaaChange(specificDaa.daaId)} style={{accentColor:'#00609f'}}/> +
+
+
+ {specificDaa.file.fileName} +
+
+ Uploaded on {specificDaa?.updateDate ? new Date(specificDaa.updateDate).toLocaleDateString() : ''} +
+
+ +
+
+ ); + + return ( + isLoading ? + : +
+
+
+ + + +
+ +
+
+
{dacText}
+
{dacId === undefined ? 'Create DAC' : fetchedDac?.name}
+
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+ +
+