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 && (
+
+ )}
+ {searchTerm && tableData.length === 0 && (
+
+ )}
+ {searchTerm && tableData.length > 0 && (
+ setSelectedUserGuid(row?.guid)}
+ />
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ }
+ loading={isUsersLoading}
+ disabled={isUsersLoading}
+ onClick={() => setIsDialogOpen(true)}
+ />
+ >
+ );
+}
diff --git a/src/hooks/useHandleRequestError.js b/src/hooks/useHandleRequestError.js
new file mode 100644
index 000000000..a76288fd3
--- /dev/null
+++ b/src/hooks/useHandleRequestError.js
@@ -0,0 +1,31 @@
+import { useIntl } from 'react-intl';
+
+function getHoustonErrorMessage(error) {
+ return error?.response?.data?.message;
+}
+
+function getAxiosErrorMessage(error) {
+ return error?.toJSON ? error.toJSON().message : null;
+}
+
+function getJsErrorMessage(error) {
+ return error?.message;
+}
+
+export default function useHandleRequestError() {
+ const intl = useIntl();
+
+ return function handleRequestError(error) {
+ const defaultErrorMessage = intl.formatMessage({
+ id: 'UNKNOWN_ERROR',
+ });
+
+ const errorMessage =
+ getHoustonErrorMessage(error) ||
+ getAxiosErrorMessage(error) ||
+ getJsErrorMessage(error) ||
+ defaultErrorMessage;
+
+ return Promise.reject(errorMessage);
+ };
+}
diff --git a/src/models/users/useGetUsers.js b/src/models/users/useGetUsers.js
index 153bb3962..2f67f7529 100644
--- a/src/models/users/useGetUsers.js
+++ b/src/models/users/useGetUsers.js
@@ -1,16 +1,9 @@
import queryKeys from '../../constants/queryKeys';
import useFetch from '../../hooks/useFetch';
-const limit = 20;
-const offset = 0;
-
export default function useGetUsers() {
return useFetch({
queryKey: queryKeys.users,
url: '/users/',
- data: {
- limit,
- offset,
- },
});
}
diff --git a/src/pages/home/Home.jsx b/src/pages/home/Home.jsx
index 4589875c5..7ae911e7e 100644
--- a/src/pages/home/Home.jsx
+++ b/src/pages/home/Home.jsx
@@ -32,9 +32,12 @@ export default function Home() {
// if (error) handle error
if (!fullName) return ;
+ const isUserManager = get(data, ['is_user_manager'], false);
+
return (
;
+
+ const { data: me, loading: meLoading } = useGetMe();
+
+ if (loading || meLoading) return ;
+
+ const viewerIsUserManager = get(me, ['is_user_manager'], false);
return (