Commit 9732a1a3 authored by Alejandro De Maria Antolinos's avatar Alejandro De Maria Antolinos
Browse files

Merge branch 'issue_480' into 'master'

Add collaborators to an investigation: Issue 480

Closes #480

See merge request !493
parents ae99af35 42173869
Pipeline #45675 passed with stages
in 13 minutes and 14 seconds
import React, { useState } from 'react';
import { useFetcher } from 'rest-hooks';
import {
Alert,
Grid,
Row,
Col,
Panel,
Glyphicon,
Button,
} from 'react-bootstrap';
import UserComboBoxList from './UserComboBoxList';
import LoadingBoundary from '../LoadingBoundary';
import InvestigationUserResource from '../../resources/investigationUser';
import Loader from '../Loader';
export default function InvestigationSharingPanel(props) {
const { investigationId, userAddedHandler } = props;
const [loading, setLoading] = useState();
const [submitError, setSubmitError] = useState();
const [selectedUsers, setSelectedUsers] = useState([]);
const createInvestigationUser = useFetcher(
InvestigationUserResource.createShape()
);
async function handleSubmit() {
try {
setLoading('Adding user to the investigation');
setSubmitError(undefined);
await createInvestigationUser(
{ investigationId, selectedUsers },
selectedUsers[0]
);
} catch {
setSubmitError(
`${selectedUsers[0].fullName} could not be added to the investigation`
);
}
setLoading();
userAddedHandler();
setSelectedUsers([]);
}
return (
<Panel bsStyle="success">
<Panel.Heading>
<Panel.Title componentClass="h3">
{' '}
<Glyphicon glyph="share" /> Sharing
</Panel.Title>
</Panel.Heading>
<Panel.Body>
{loading && <Loader message={loading}></Loader>}
{!loading && (
<Grid fluid style={{ margin: '20px' }}>
<Row>
<p>
Sharing allows non-proposal users to{' '}
<strong>access to the data</strong> and
<strong> read/write the electronic logbook</strong>.
<strong> Only ICAT users can be added</strong>. When a user is
granted to access to an investigation then it will appear under
his "My data" tab.
</p>
<Col xs={12} md={6}>
<LoadingBoundary message="Loading users..." inPanel>
<UserComboBoxList
investigationId={investigationId}
onUserSelected={setSelectedUsers}
/>
</LoadingBoundary>
</Col>
<Col xs={12} md={2}>
<Button
bsStyle="primary"
onClick={handleSubmit}
disabled={selectedUsers.length === 0}
>
Invite
</Button>
</Col>
</Row>
<Row>
<Col>
{' '}
{submitError && <Alert bsStyle="danger">{submitError}</Alert>}
</Col>
</Row>
</Grid>
)}
</Panel.Body>
</Panel>
);
}
...@@ -155,7 +155,8 @@ function InvestigationTable(props) { ...@@ -155,7 +155,8 @@ function InvestigationTable(props) {
instrumentName, instrumentName,
} = props; } = props;
const { sessionId, isAdministrator } = useSelector((state) => state.user); const user = useSelector((state) => state.user);
const { sessionId, isAdministrator } = user;
const history = useHistory(); const history = useHistory();
const query = useQuery(); const query = useQuery();
...@@ -210,6 +211,7 @@ function InvestigationTable(props) { ...@@ -210,6 +211,7 @@ function InvestigationTable(props) {
<InvestigationWidget <InvestigationWidget
investigation={investigation} investigation={investigation}
sessionId={sessionId} sessionId={sessionId}
user={user}
/> />
), ),
}; };
......
...@@ -5,7 +5,7 @@ import ParticipantsPanel from './ParticipantsPanel'; ...@@ -5,7 +5,7 @@ import ParticipantsPanel from './ParticipantsPanel';
import LoadingBoundary from '../LoadingBoundary'; import LoadingBoundary from '../LoadingBoundary';
function InvestigationWidget(props) { function InvestigationWidget(props) {
const { investigation } = props; const { investigation, user } = props;
return ( return (
<Panel> <Panel>
...@@ -17,7 +17,12 @@ function InvestigationWidget(props) { ...@@ -17,7 +17,12 @@ function InvestigationWidget(props) {
</Well> </Well>
<Tabs id="tabs"> <Tabs id="tabs">
<Tab style={{ margin: 30 }} eventKey={1} title="Participants"> <Tab style={{ margin: 30 }} eventKey={1} title="Participants">
<ParticipantsPanel investigationId={investigation.id} /> <LoadingBoundary message="Loading participants...">
<ParticipantsPanel
investigationId={investigation.id}
name={user.name}
/>
</LoadingBoundary>
</Tab> </Tab>
<Tab style={{ margin: 30 }} eventKey={2} title="Samples"> <Tab style={{ margin: 30 }} eventKey={2} title="Samples">
<LoadingBoundary message="Loading samples"> <LoadingBoundary message="Loading samples">
......
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Alert } from 'react-bootstrap'; import { useFetcher } from 'rest-hooks';
import { getInvestigationUsersByInvestigationId } from '../../api/icat-plus/catalogue'; import { Alert, Label, Panel, Glyphicon, Button } from 'react-bootstrap';
import { groupBy } from 'lodash-es'; import { groupBy } from 'lodash-es';
import axios from 'axios';
import ResponsiveTable from '../Table/ResponsiveTable'; import ResponsiveTable from '../Table/ResponsiveTable';
import Loader from '../Loader';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { USER_ROLES } from '../../constants';
import InvestigationUserResource from '../../resources/investigationUser';
import InvestigationSharingPanel from './InvestigationSharingPanel';
import LoadingBoundary from '../LoadingBoundary';
import Loader from '../Loader';
/**
* It basically merges InvestigationUsers in an array by grouping by roles and it sorts the array alphabetically
* @param {*} investigationUsers
*/
const parseInvestigationUsers = (investigationUsers) => {
const participantUsers = groupBy(investigationUsers, (user) => user.name);
return Object.entries(participantUsers)
.map(([id, users]) => {
const role = users.map((user) => user.role).join(', ');
return {
id,
fullName: users[0].fullName,
name: users[0].name,
role,
};
})
.sort((a, b) => (a.role < b.role ? -1 : 1));
};
/**
* Returns a boolean if the user has permissions to revoke an user of the investigation. A user has permissions if it is principal administrator or local contact
* @param {*} investigationUsers
* @param {*} name
*/
const isAllowed = (investigationUsers, name) => {
let isAllowed = false;
const participantUsers = groupBy(investigationUsers, (user) => user.name);
Object.entries(participantUsers).map(([id, users]) => {
const role = users.map((user) => user.role).join(', ');
isAllowed =
(id === name && role.indexOf(USER_ROLES.PrincipalInvestigator) !== -1) ||
isAllowed;
/** To be commented out if we move to LocalContact
isAllowed =
(id === name &&
(role.indexOf(USER_ROLES.PrincipalInvestigator) !== -1 ||
role.indexOf(USER_ROLES.LocalContact) !== -1)) ||
isAllowed;
**/
return isAllowed;
});
return isAllowed;
};
function ParticipantsPanel(props) { function ParticipantsPanel(props) {
const { investigationId } = props; const { investigationId } = props;
const sessionId = useSelector((state) => state.user.sessionId); const { name } = useSelector((state) => state.user);
const [state, setState] = useState({}); const [submitError, setSubmitError] = useState();
const { participants, fetching, error } = state; const [loading, setLoading] = useState();
const [investigationUsers, setInvestigationUsers] = useState([]);
const [isPrincipalInvestigator, setIsPrincipalInvestigator] = useState(false);
useEffect(() => { const deleteInvestigationUser = useFetcher(
setState({ fetching: true }); InvestigationUserResource.deleteShape()
);
axios
.get(getInvestigationUsersByInvestigationId(sessionId, investigationId)) const getInvestigationUserList = useFetcher(
.then((res) => groupBy(res.data, (user) => user.name)) InvestigationUserResource.listShape()
.then((participantsById) => { );
setState({
participants: Object.entries(participantsById).map(([id, users]) => ({
id,
name: users[0].fullName,
role: users.map((user) => user.role).join(', '),
})),
});
})
.catch(() => {
setState({ error: true });
});
}, [sessionId, investigationId]);
if (fetching) { const updateData = (investigationUsers, name) => {
return <Loader message="Loading participants..." />; const participants = parseInvestigationUsers(investigationUsers, name);
} const isPrincipalInvestigator = isAllowed(investigationUsers, name);
setInvestigationUsers(participants);
setIsPrincipalInvestigator(isPrincipalInvestigator);
};
if (error) { const revokeHandler = async (user) => {
return <Alert bsStyle="danger">Unable to retrieve participants</Alert>; try {
} setSubmitError(undefined);
setLoading(`Removing ${user.fullName}`);
const investigationUsers = await deleteInvestigationUser(
{ investigationId },
user
);
updateData(investigationUsers, name);
} catch (error) {
setSubmitError(
`There was an error revoking access to user ${user.name}. ${error}`
);
} finally {
setLoading();
}
};
if (!participants) { const userAddedHandler = async () => {
return null; const investigationUsers = await getInvestigationUserList({
} investigationId,
});
updateData(investigationUsers, name);
};
useEffect(() => {
async function fetchData() {
const investigationUsers = await getInvestigationUserList({
investigationId,
});
updateData(investigationUsers, name);
}
fetchData();
}, [getInvestigationUserList, investigationId, name]);
return ( return (
<ResponsiveTable <Panel bsStyle="primary">
keyField="id" <Panel.Heading>
data={participants} <Panel.Title componentClass="h3">
columns={[ {' '}
{ text: 'id', dataField: 'id', hidden: true, searchable: false }, <Glyphicon glyph="user" /> Participants
{ text: 'Name', dataField: 'name' }, </Panel.Title>
{ text: 'Role', dataField: 'role' }, </Panel.Heading>
]} <Panel.Body>
/> {submitError && <Alert bsStyle="danger">{submitError}</Alert>}
{loading && <Loader message={loading}></Loader>}
{!loading && (
<ResponsiveTable
keyField="id"
data={investigationUsers}
columns={[
{ text: 'id', dataField: 'id', hidden: true, searchable: false },
{
text: 'Name',
dataField: 'fullName',
formatter: (_, user) => {
if (name === user.id) {
return (
<Label bsStyle="primary">
{' '}
{user.fullName.toUpperCase()}
</Label>
);
}
return user.fullName;
},
},
{
text: 'Role',
dataField: 'role',
},
{
text: 'Permissions',
dataField: 'permissions',
hidden: !isPrincipalInvestigator,
formatter: (_, investigationUser) =>
investigationUser.role
.toUpperCase()
.indexOf(USER_ROLES.Collaborator.toUpperCase()) !== -1 ? (
<Button
bsStyle="danger"
onClick={() => revokeHandler(investigationUser)}
>
Revoke
</Button>
) : null,
headerStyle: () => ({ width: '50%', textAlign: 'center' }),
},
]}
/>
)}
{isPrincipalInvestigator ? (
<LoadingBoundary message="Loading users..." inPanel>
<InvestigationSharingPanel
investigationId={investigationId}
userAddedHandler={userAddedHandler}
></InvestigationSharingPanel>
</LoadingBoundary>
) : null}
</Panel.Body>
</Panel>
); );
} }
......
import React, { useState } from 'react';
import { useResource } from 'rest-hooks';
import UserResource from '../../resources/user';
import { Typeahead } from 'react-bootstrap-typeahead';
export default function UserComboBoxList(props) {
const { onUserSelected, multiple, investigationId } = props;
const [state] = useState({});
const { usersSelected } = state;
/** Get all users and orders by fullName */
const users = useResource(UserResource.listShape(), {
investigationId,
}).sort((a, b) => (a.fullName < b.fullName ? -1 : 1));
const _renderMenuItemChildren = (option) => [
<p key={option.name}>{option.fullName}</p>,
];
const handleChange = (value) => {
onUserSelected(value.usersSelected);
return true;
};
return (
<>
<Typeahead
id="typeAheadUsers"
labelKey="fullName"
renderMenuItemChildren={_renderMenuItemChildren}
multiple={multiple}
onChange={(usersSelected) => {
handleChange({ usersSelected });
}}
selected={usersSelected}
options={users}
placeholder="Invite a user..."
/>
</>
);
}
...@@ -34,4 +34,5 @@ export const BYTE_UNITS = ['PB', 'TB', 'GB', 'MB', 'KB']; ...@@ -34,4 +34,5 @@ export const BYTE_UNITS = ['PB', 'TB', 'GB', 'MB', 'KB'];
export const USER_ROLES = Object.freeze({ export const USER_ROLES = Object.freeze({
PrincipalInvestigator: 'Principal investigator', PrincipalInvestigator: 'Principal investigator',
LocalContact: 'Local contact', LocalContact: 'Local contact',
Collaborator: 'Collaborator',
}); });
import { Resource } from 'rest-hooks'; import { Resource } from 'rest-hooks';
import ICATPLUS from '../config/icatPlus';
import { store } from '../store'; import { store } from '../store';
import { getInvestigationUsersByInvestigationId } from '../api/icat-plus/catalogue';
export default class InvestigationUserResource extends Resource { export default class InvestigationUserResource extends Resource {
name = undefined; name = undefined;
...@@ -9,9 +9,10 @@ export default class InvestigationUserResource extends Resource { ...@@ -9,9 +9,10 @@ export default class InvestigationUserResource extends Resource {
investigationName = ''; investigationName = '';
investigationId = undefined; investigationId = undefined;
email = ''; email = '';
id = undefined;
pk() { pk() {
return this.name?.toString(); return this.id?.toString();
} }
static get key() { static get key() {
...@@ -21,7 +22,13 @@ export default class InvestigationUserResource extends Resource { ...@@ -21,7 +22,13 @@ export default class InvestigationUserResource extends Resource {
static listUrl(params) { static listUrl(params) {
const { sessionId } = store.getState().user; const { sessionId } = store.getState().user;
const { investigationId } = params; const { investigationId } = params;
return getInvestigationUsersByInvestigationId(sessionId, investigationId);
}
return `${ICATPLUS.server}/catalogue/${sessionId}/investigation/id/${investigationId}/investigationusers`; static deleteShape() {
return {
...super.deleteShape(),
fetch: (params, body) => this.fetch('delete', this.listUrl(params), body),
};
} }
} }
import { Resource } from 'rest-hooks';
import ICATPLUS from '../config/icatPlus';
import { store } from '../store';
export default class UserResource extends Resource {
name = undefined;
fullName = '';
email = '';
id = '';
pk() {
return this.name?.toString();
}
static get key() {
return 'UserResource';
}
static listUrl(params) {
const { investigationId } = params;
const { sessionId } = store.getState().user;
if (investigationId) {
return `${ICATPLUS.server}/catalogue/${sessionId}/investigation/${investigationId}/user`;
}
return `${ICATPLUS.server}/catalogue/${sessionId}/user`;
}
}
//import { renderApp } from '../test-utils'; import { renderApp } from '../test-utils';
test('renders login page', () => { test('renders login page', () => {
//const { queryByRole } = renderApp(); const { queryByRole } = renderApp();
//expect(queryByRole('heading', { name: 'Sign in' })).toBeTruthy(); expect(queryByRole('heading', { name: 'Sign in' })).toBeTruthy();
}); });
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment