diff --git a/locale/en.json b/locale/en.json index a10dc4fd8..f85844035 100644 --- a/locale/en.json +++ b/locale/en.json @@ -200,6 +200,7 @@ "SEARCH_ITIS_SPECIES": "Search ITIS species", "SEARCH_INDIVIDUALS_INSTRUCTION": "Search for an individual by name or guid", "SEARCH_SIGHTINGS_INSTRUCTION": "Search for a sighting by location, owner or guid", + "SEARCH_USER_INSTRUCTION": "Search for a user by name or email", "PRAIRIE": "Prairie", "EDIT_USER_METADATA": "Edit user metadata", "EDIT_SIGHTING_METADATA": "Edit sighting metadata", @@ -987,6 +988,7 @@ "COMPONENT_COMMIT_HASH": "{component} commit hash: ", "INDIVIDUAL_SEARCH_NO_RESULTS": "Your search \"{searchTerm}\" did not match any individuals.", "SIGHTING_SEARCH_NO_RESULTS": "Your search \"{searchTerm}\" did not match any sightings.", + "POTENTIAL_COLLABORATOR_SEARCH_NO_RESULTS": "Your search \"{searchTerm}\" did not match any potential collaborators.", "SEARCH_SERVER_ERROR": "A server error occurred while attempting to search.", "CONFIGURATION_SITE_NAME_LABEL": "Site name", "CONFIGURATION_SITE_NAME_DESCRIPTION": "The name of this site, excluding domain name suffix (ie. PandaMatcher, NOT pandamatcher.org).", @@ -1197,6 +1199,8 @@ "EDIT_COLLABORATION_CURRENT_STATE_DESCRIPTION": "Changing the state will cancel any pending requests.", "COLLABORATIONS": "Collaborations", "URLS_MUST_INCLUDE_HTTPS": "All URLs must include https:// to be valid", + "ADD_COLLABORATION": "Add collaboration", + "EMAIL": "Email", "EDIT_COLLABORATION_REVOKED_BY_USER_MANAGER": "Edit collaboration revoked by a user manager.", "EDIT_COLLABORATION_WAS_REVOKED_BY_A_USER_MANAGER": "An edit-level collaboration with {otherUserNameForManagerNotifications} was revoked by a user manager {managerName}.", "COLLABORATION_EDIT_DENIED": "Collaboration edit denied", diff --git a/src/components/UserProfile.jsx b/src/components/UserProfile.jsx index bf57af94a..f68bbb780 100644 --- a/src/components/UserProfile.jsx +++ b/src/components/UserProfile.jsx @@ -2,12 +2,11 @@ import React, { useState, useMemo } from 'react'; import { useIntl, FormattedMessage } from 'react-intl'; import { get } from 'lodash-es'; -import Card from '@material-ui/core/Card'; +import Grid from '@material-ui/core/Grid'; import { getHighestRoleLabelId } from '../utils/roleUtils'; import useUserMetadataSchemas from '../models/users/useUserMetadataSchemas'; import useGetUserSightings from '../models/users/useGetUserSightings'; -import useGetMe from '../models/users/useGetMe'; import useGetUserUnprocessedAssetGroupSightings from '../models/users/useGetUserUnproccessedAssetGroupSightings'; import { formatDate, formatUserMessage } from '../utils/formatters'; import EntityHeader from './EntityHeader'; @@ -19,9 +18,9 @@ import Text from './Text'; import RequestCollaborationButton from './RequestCollaborationButton'; import MetadataCard from './cards/MetadataCard'; import SightingsCard from './cards/SightingsCard'; -import CollaborationsCard from './cards/CollaborationsCard'; +import MyCollaborationsCard from './cards/MyCollaborationsCard'; +import UserManagerCollaborationsCard from './cards/UserManagerCollaborationsCard'; import CardContainer from './cards/CardContainer'; -import UserManagerCollaborationEditTable from './UserManagerCollaborationEditTable'; export default function UserProfile({ children, @@ -30,6 +29,7 @@ export default function UserProfile({ userDataLoading, refreshUserData, someoneElse, + viewerIsUserManager, noCollaborate = false, }) { const { data: sightingsData, loading: sightingsLoading } = @@ -39,18 +39,6 @@ export default function UserProfile({ const metadataSchemas = useUserMetadataSchemas(userId); const { data: agsData, loading: agsLoading } = useGetUserUnprocessedAssetGroupSightings(userId); - const { - data: currentUserData, - loading: currentUserDataLoading, - error: currentUserDataError, - } = useGetMe(); - const isUserManager = get( - currentUserData, - 'is_user_manager', - false, - ); - - const nonSelfCollabData = get(userData, ['collaborations'], []); const metadata = useMemo(() => { if (!userData || !metadataSchemas) return []; @@ -182,27 +170,14 @@ export default function UserProfile({ } /> {!someoneElse && ( - + + + )} - {someoneElse && isUserManager && ( - - - + {someoneElse && viewerIsUserManager && ( + + + )} diff --git a/src/components/cards/CollaborationsCard.jsx b/src/components/cards/MyCollaborationsCard.jsx similarity index 54% rename from src/components/cards/CollaborationsCard.jsx rename to src/components/cards/MyCollaborationsCard.jsx index 73c54eb28..b7dce9d05 100644 --- a/src/components/cards/CollaborationsCard.jsx +++ b/src/components/cards/MyCollaborationsCard.jsx @@ -1,20 +1,27 @@ -import React, { useState, useEffect } from 'react'; -import { useIntl } from 'react-intl'; +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; import { get, partition } from 'lodash-es'; +import { useIntl } from 'react-intl'; +import { useMutation, useQueryClient } from 'react-query'; + +import Card from '@material-ui/core/Card'; +import CardActions from '@material-ui/core/CardActions'; +import CardContent from '@material-ui/core/CardContent'; -import useGetUser from '../../models/users/useGetUser'; -import Card from './Card'; -import ActionIcon from '../ActionIcon'; +import { withApiPrefix } from '../../utils/requestUtils'; +import { cellRendererTypes } from '../dataDisplays/cellRenderers'; import Text from '../Text'; -import Link from '../Link'; import DataDisplay from '../dataDisplays/DataDisplay'; +import AddCollaboratorButton from './collaborations/AddCollaboratorButton'; import CollaborationsDialog from './collaborations/CollaborationsDialog'; +import queryKeys from '../../constants/queryKeys'; +import useHandleRequestError from '../../hooks/useHandleRequestError'; -export default function CollaborationsCard({ - userId, - htmlId = null, -}) { +export default function MyCollaborationsCard({ userData }) { const intl = useIntl(); + const queryClient = useQueryClient(); + const handleRequestError = useHandleRequestError(); + const [activeCollaboration, setActiveCollaboration] = useState(null); const [ @@ -22,13 +29,33 @@ export default function CollaborationsCard({ setCollabDialogButtonClickLoading, ] = useState(false); - const { data, loading } = useGetUser(userId); + const handleEdit = useCallback((_, collaboration) => { + setActiveCollaboration(collaboration); + }, []); + + async function addCollaboratorMutationFn({ userGuid }) { + try { + const result = await axios.request({ + url: withApiPrefix('/collaborations/'), + method: 'POST', + data: { user_guid: userGuid }, + }); + return result; + } catch (error) { + return handleRequestError(error); + } + } + + const mutation = useMutation(addCollaboratorMutationFn, { + onSuccess: async () => + queryClient.invalidateQueries(queryKeys.me), + }); useEffect(() => { setCollabDialogButtonClickLoading(false); - }, [data]); + }, [userData]); - const collaborations = get(data, ['collaborations'], []); + const collaborations = get(userData, ['collaborations'], []); const tableData = collaborations.map(collaboration => { const collaborationMembers = Object.values( get(collaboration, 'members', []), @@ -40,7 +67,7 @@ export default function CollaborationsCard({ ); const [thisUserDataArray, otherUserDataArray] = partition( filteredCollaborationMembers, - member => member.guid === userId, + member => member.guid === userData?.guid, ); const thisUserData = get(thisUserDataArray, '0', {}); @@ -91,11 +118,11 @@ export default function CollaborationsCard({ name: 'otherUserName', label: intl.formatMessage({ id: 'NAME' }), options: { - customBodyRender: (otherUserName, datum) => ( - - {otherUserName} - - ), + cellRenderer: cellRendererTypes.user, + cellRendererProps: { + guidProperty: 'otherUserId', + nameProperty: 'otherUserName', + }, }, }, { @@ -110,19 +137,14 @@ export default function CollaborationsCard({ name: 'actions', label: intl.formatMessage({ id: 'ACTIONS' }), options: { - customBodyRender: (_, collaboration) => ( - setActiveCollaboration(collaboration)} - /> - ), + cellRenderer: cellRendererTypes.actionGroup, + cellRendererProps: { onEdit: handleEdit }, }, }, ]; return ( - + <> setActiveCollaboration(null)} @@ -131,16 +153,27 @@ export default function CollaborationsCard({ setCollabDialogButtonClickLoading } /> - - + + + + + + + + + + ); } diff --git a/src/components/cards/UserManagerCollaborationsCard.jsx b/src/components/cards/UserManagerCollaborationsCard.jsx new file mode 100644 index 000000000..1f24585a8 --- /dev/null +++ b/src/components/cards/UserManagerCollaborationsCard.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import axios from 'axios'; +import { get } from 'lodash-es'; +import { useMutation, useQueryClient } from 'react-query'; + +import Card from '@material-ui/core/Card'; +import CardActions from '@material-ui/core/CardActions'; +import CardContent from '@material-ui/core/CardContent'; + +import { withApiPrefix } from '../../utils/requestUtils'; +import { getUserQueryKey } from '../../constants/queryKeys'; +import AddCollaboratorButton from './collaborations/AddCollaboratorButton'; +import UserManagerCollaborationEditTable from '../UserManagerCollaborationEditTable'; +import useHandleRequestError from '../../hooks/useHandleRequestError'; + +export default function UserManagerCollaborationsCard({ userData }) { + const queryClient = useQueryClient(); + const collaborations = get(userData, ['collaborations'], []); + const handleRequestError = useHandleRequestError(); + + const userGuid = userData?.guid; + + async function mutationFn({ userGuid: secondUserGuid }) { + try { + const result = await axios.request({ + url: withApiPrefix('/collaborations/'), + method: 'POST', + data: { + user_guid: userGuid, + second_user_guid: secondUserGuid, + }, + }); + return result; + } catch (error) { + return handleRequestError(error); + } + } + + const mutation = useMutation(mutationFn, { + onSuccess: async () => + queryClient.invalidateQueries(getUserQueryKey(userGuid)), + }); + + return ( + + + + + + + + + ); +} diff --git a/src/components/cards/collaborations/AddCollaboratorButton.jsx b/src/components/cards/collaborations/AddCollaboratorButton.jsx new file mode 100644 index 000000000..563d9e58f --- /dev/null +++ b/src/components/cards/collaborations/AddCollaboratorButton.jsx @@ -0,0 +1,230 @@ +import React, { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import AddIcon from '@material-ui/icons/Add'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import TextField from '@material-ui/core/TextField'; +import SearchIcon from '@material-ui/icons/Search'; + +import Button from '../../Button'; +import CustomAlert from '../../Alert'; +import DataDisplay from '../../dataDisplays/DataDisplay'; +import StandardDialog from '../../StandardDialog'; +import Text from '../../Text'; +import useGetUsers from '../../../models/users/useGetUsers'; +import { cellRendererTypes } from '../../dataDisplays/cellRenderers'; +import { getHighestRoleLabelId } from '../../../utils/roleUtils'; + +function filterCollaborator( + potentialCollaborator, + searchTerm, + userGuid, + currentCollaboratorGuids, +) { + // If there is no search term, no users should be returned + if (!searchTerm) return false; + + // The user should not be able to collaborate with themself + if (potentialCollaborator?.guid === userGuid) return false; + + // No internal or deactivated users should be available for collaboration + const isInternal = potentialCollaborator?.is_internal || false; + const isActive = potentialCollaborator?.is_active || false; + if (isInternal || !isActive) return false; + + // No users that the user is already collaborating with should be available + // for collaboration + if (currentCollaboratorGuids.includes(potentialCollaborator?.guid)) + return false; + + // User name and email are filterable fields + const lowerCaseSearchTerm = searchTerm.toLowerCase(); + const fullName = + potentialCollaborator?.full_name?.toLowerCase().trim() ?? ''; + if (fullName.includes(lowerCaseSearchTerm)) return true; + + const email = + potentialCollaborator?.email?.toLowerCase().trim() ?? ''; + if (email.includes(lowerCaseSearchTerm)) return true; + + return false; +} + +export default function AddCollaboratorButton({ + userData, + mutation, +}) { + const intl = useIntl(); + const userGuid = userData?.guid; + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedUserGuid, setSelectedUserGuid] = useState(null); + + const { + data: users, + isLoading: isUsersLoading, + error: getUsersError, + } = useGetUsers(); + + const { + mutate: addCollaboration, + isLoading: isAddCollaborationLoading, + error: addCollaborationError, + reset: resetAddCollaborationMutation, + } = mutation; + + const error = addCollaborationError || getUsersError; + + function handleCloseDialog() { + setIsDialogOpen(false); + setSearchTerm(''); + setSelectedUserGuid(null); + resetAddCollaborationMutation(); + } + + function handleSubmitSearchTerm(e) { + e.preventDefault(); + + const form = e.target; + const formData = new FormData(form); + const searchValue = formData.get('search'); + + setSearchTerm(searchValue); + setSelectedUserGuid(null); + } + + const currentCollaboratorGuids = ( + userData?.collaborations || [] + ).map(collaboration => { + const memberGuids = Object.keys(collaboration?.members || {}); + return memberGuids.find(guid => guid !== userGuid); + }); + + const columns = [ + { name: 'name', labelId: 'NAME', align: 'left' }, + { name: 'email', labelId: 'EMAIL', align: 'left' }, + { name: 'role', labelId: 'ROLE', align: 'left' }, + { + name: 'created', + labelId: 'CREATED', + align: 'left', + options: { + cellRenderer: cellRendererTypes.date, + cellRendererProps: { fancy: true }, + }, + }, + ]; + + const tableData = (users || []) + .filter(user => + filterCollaborator( + user, + searchTerm, + userGuid, + currentCollaboratorGuids, + ), + ) + .map(user => ({ + guid: user?.guid, + email: user?.email, + name: user?.full_name, + created: user?.created, + role: intl.formatMessage({ id: getHighestRoleLabelId(user) }), + })); + + return ( + <> + + + {users && ( +
+ + } + InputProps={{ startAdornment: }} + /> +