Skip to content

Commit

Permalink
task/FP-1131: Add Member role to shared workspace (frontend) (#597)
Browse files Browse the repository at this point in the history
* Add API endpoints to update project/system roles for users

* Additional backend changes to support project/system role queries

* Decouple project/system role selectors in the Manage Team Members modal

* use single-column view that only displays the Tapis role

* Add new users as USER instead of ADMIN

* fix cutoff for ownership transfer text

* Restore memoization of table data

* Hide roles column for USER/MEMBER since they can't see others' roles

* python linting

* Fix memoization loop causing infinite query refetch

* hide role column while transferring ownership

* fix(FP-1670): button text overflow (workspaces) (#641)

* merge main

* show spinner while mutating

Co-authored-by: Sal Tijerina <r.sal.tijerina@gmail.com>
Co-authored-by: Wesley B <62723358+wesleyboar@users.noreply.github.com>
  • Loading branch information
3 people authored Jun 2, 2022
1 parent f33e483 commit b3aa730
Show file tree
Hide file tree
Showing 21 changed files with 733 additions and 64 deletions.
207 changes: 174 additions & 33 deletions client/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"react-dom": "^17.0.2",
"react-dropzone": "^10.2.1",
"react-google-recaptcha": "^2.1.0",
"react-query": "^3.34.15",
"react-redux": "^7.2.5",
"react-resize-detector": "^6.1.0",
"react-router-dom": "^5.3.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const DataFilesManageProjectModal = () => {
</ModalHeader>
<ModalBody>
<DataFilesProjectMembers
projectId={projectId}
members={members}
onAdd={onAdd}
onRemove={onRemove}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
projectsListingFixture,
projectMetadataFixture,
} from '../../../../redux/sagas/fixtures/projects.fixture';
jest.mock('cross-fetch');
const mockStore = configureStore();

const initialMockState = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import PropTypes from 'prop-types';
import { InfiniteScrollTable, LoadingSpinner } from '_common';
import { useDispatch, useSelector } from 'react-redux';
import { Input, Label, Button } from 'reactstrap';
import { SystemRoleSelector, ProjectRoleSelector } from './_cells';
import styles from './DataFilesProjectMembers.module.scss';
import { useSystemRole } from './_cells/SystemRoleSelector';
import './DataFilesProjectMembers.scss';

const DataFilesProjectMembers = ({
projectId,
members,
onAdd,
onRemove,
Expand All @@ -17,6 +20,12 @@ const DataFilesProjectMembers = ({
const dispatch = useDispatch();

const userSearchResults = useSelector((state) => state.users.search.users);
const authenticatedUser = useSelector(
(state) => state.authenticatedUser.user.username
);
const { query: authenticatedUserQuery } = !projectId
? {}
: useSystemRole(projectId, authenticatedUser);

const [selectedUser, setSelectedUser] = useState('');

Expand Down Expand Up @@ -67,6 +76,17 @@ const DataFilesProjectMembers = ({
);
};

const mapAccessToRoles = (access) => {
switch (access) {
case 'owner':
return { projectRole: 'PI', systemRole: 'OWNER' };
case 'edit':
return { projectRole: 'Member', systemRole: 'USER' };
default:
return { projectRole: 'N/A', systemRole: 'N/A' };
}
};

const memberColumn = {
Header: 'Members',
headerStyle: { textAlign: 'left' },
Expand All @@ -81,15 +101,36 @@ const DataFilesProjectMembers = ({
</span>
),
};
const roleColumn =
mode !== 'transfer' &&
(!projectId ||
['OWNER', 'ADMIN'].includes(authenticatedUserQuery?.data?.role))
? [
{
Header: 'Role',
accessor: 'user.username',
id: 'role',
className: 'project-members__cell',
show: false,
Cell: projectId
? (el) => (
<SystemRoleSelector
projectId={projectId}
username={el.value}
/>
)
: (el) => (
<span>
{mapAccessToRoles(el.row.original.access).systemRole}
</span>
),
},
]
: [];

const columns = [
memberColumn,
{
Header: 'Access',
accessor: 'access',
className: 'project-members__cell',
Cell: (el) => <span className={styles.access}>{el.value}</span>,
},
...roleColumn,
{
Header: loading ? (
<LoadingSpinner
Expand Down Expand Up @@ -219,7 +260,12 @@ const DataFilesProjectMembers = ({
tableColumns={isTransferring ? transferColumns : columns}
tableData={existingMembers}
className={styles[listStyle]}
columnMemoProps={[loading, mode, transferUser]}
columnMemoProps={[
loading,
mode,
transferUser,
authenticatedUserQuery?.data?.role,
]}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,24 @@
/* owner */
th:nth-child(2),
td:nth-child(2) {
width: 10%;
}
/* id */
width: 20%;
} /* owner */
th:nth-child(3),
td:nth-child(3) {
width: 30%;
width: 20%;
}
}

.transfer-list {
/* owner */
th:nth-child(1),
td:nth-child(1) {
width: 60%;
width: 50%;
}
/* confirmation */
th:nth-child(2),
td:nth-child(2) {
width: 40%;
width: 50%;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React, { useState, useEffect, useContext, createContext } from 'react';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import Cookies from 'js-cookie';
import fetch from 'cross-fetch';
import DropdownSelector from '_common/DropdownSelector';
import { Button } from 'reactstrap';
import LoadingSpinner from '_common/LoadingSpinner';

const getProjectRole = async (projectId, username) => {
const url = `/api/projects/${projectId}/project-role/${username}/`;
const request = await fetch(url, {
headers: { 'X-CSRFToken': Cookies.get('csrftoken') },
credentials: 'same-origin',
});
const data = await request.json();
return data;
};

const setProjectRole = async (projectId, username, oldRole, newRole) => {
const url = `/api/projects/${projectId}/members/`;
const request = await fetch(url, {
headers: { 'X-CSRFToken': Cookies.get('csrftoken') },
credentials: 'same-origin',
method: 'PATCH',
body: JSON.stringify({
action: 'change_project_role',
username,
oldRole,
newRole,
}),
});
const data = await request.json();
return data;
};

const useProjectRole = (projectId, username) => {
const queryClient = useQueryClient();
const query = useQuery(['project-role', projectId, username], () =>
getProjectRole(projectId, username)
);
const mutation = useMutation(async ({ oldRole, newRole }) => {
await setProjectRole(projectId, username, oldRole, newRole);
query.refetch();
// Invalidate the system role query to keep it up to date.
queryClient.invalidateQueries(['system-role', projectId, username]);
});
return { query, mutation };
};

const ProjectRoleSelector = ({ projectId, username }) => {
const {
query: { data, isLoading, error, isFetching },
mutation: { mutate: setProjectRole, isLoading: isMutating },
} = useProjectRole(projectId, username);

const [selectedRole, setSelectedRole] = useState(data?.role);
useEffect(() => setSelectedRole(data?.role), [data?.role]);

if (isLoading) return <LoadingSpinner placement="inline" />;
if (error) return <span>Error</span>;
if (data?.role == 'pi') return <span>PI</span>;
return (
<div style={{ display: 'inline-flex' }}>
<DropdownSelector
data-testid="role-dropdown"
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
>
<option value="co_pi">Co-PI</option>
<option value="team_member">Member</option>
</DropdownSelector>
{data.role !== selectedRole && !isFetching && (
<Button
className="data-files-btn"
onClick={() =>
setProjectRole({ oldRole: data.role, newRole: selectedRole })
}
>
{isMutating ? <LoadingSpinner placement="inline" /> : 'Update'}
</Button>
)}
</div>
);
};

export default ProjectRoleSelector;
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React, { useState, useEffect, createContext, useContext } from 'react';
import { useQuery, useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import Cookies from 'js-cookie';
import fetch from 'cross-fetch';
import DropdownSelector from '_common/DropdownSelector';
import { Button } from 'reactstrap';
import styles from '../DataFilesProjectMembers.module.scss';
import LoadingSpinner from '_common/LoadingSpinner';

const getSystemRole = async (projectId, username) => {
const url = `/api/projects/${projectId}/system-role/${username}/`;
const request = await fetch(url, {
headers: { 'X-CSRFToken': Cookies.get('csrftoken') },
credentials: 'same-origin',
});
const data = await request.json();
return data;
};

const setSystemRole = async (projectId, username, role) => {
const url = `/api/projects/${projectId}/members/`;
const request = await fetch(url, {
headers: { 'X-CSRFToken': Cookies.get('csrftoken') },
credentials: 'same-origin',
method: 'PATCH',
body: JSON.stringify({
action: 'change_system_role',
username,
newRole: role,
}),
});
const data = await request.json();
return data;
};

export const useSystemRole = (projectId, username) => {
const query = useQuery(['system-role', projectId, username], () =>
getSystemRole(projectId, username)
);
const mutation = useMutation(async (role) => {
await setSystemRole(projectId, username, role);
query.refetch();
});
return { query, mutation };
};

const SystemRoleSelector = ({ projectId, username }) => {
const authenticatedUser = useSelector(
(state) => state.authenticatedUser.user.username
);
const { query: authenticatedUserQuery } = useSystemRole(
projectId,
authenticatedUser
);
const currentUserRole = authenticatedUserQuery.data?.role;

const {
query: { data, isLoading, isFetching, error },
mutation: { mutate: setSystemRole, isLoading: isMutating },
} = useSystemRole(projectId, username);
const [selectedRole, setSelectedRole] = useState(data?.role);
useEffect(() => setSelectedRole(data?.role), [data?.role]);

if (isLoading || authenticatedUserQuery.isLoading || isMutating)
return <LoadingSpinner placement="inline" />;
if (error) return <span>Error</span>;
//Only owners/admins can change roles;
// owner roles cannot be changed except using the Transfer mechanism;
// users cannot change their own roles.
if (
data.role === 'OWNER' ||
username === authenticatedUser ||
!['OWNER', 'ADMIN'].includes(currentUserRole)
)
return <span>{data.role}</span>;
return (
<div style={{ display: 'inline-flex' }}>
<DropdownSelector
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
>
{username !== authenticatedUser && <option value="ADMIN">ADMIN</option>}
<option value="USER">USER</option>
<option value="GUEST">GUEST</option>
</DropdownSelector>
{data.role !== selectedRole && !isFetching && (
<Button
style={{ marginLeft: '5px' }}
className={styles['ownership-button']}
onClick={() => setSystemRole(selectedRole)}
>
Update
</Button>
)}
</div>
);
};

export default SystemRoleSelector;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';
import '@testing-library/jest-dom/extend-expect';
import renderComponent from 'utils/testing';
import ProjectRoleSelector from '../ProjectRoleSelector';
import { waitFor, screen, fireEvent } from '@testing-library/react';

import fetch from 'cross-fetch';
jest.mock('cross-fetch');
const mockStore = configureStore();

describe('ProjectRoleSelector', () => {
it('renders AppRouter and dispatches events', async () => {
const fm = fetchMock
.sandbox()
.get(`/api/projects/CEP-000/project-role/testuser/`, {
status: 200,
body: { role: 'co_pi' },
});
fetch.mockImplementation(fm);

renderComponent(
<ProjectRoleSelector projectId="CEP-000" username="testuser" />,
mockStore({})
);
expect(await screen.findByTestId('loading-spinner')).toBeDefined();

await waitFor(async () => {
expect(await screen.findByDisplayValue('Co-PI')).toBeDefined();
const selector = await screen.findByTestId('selector');

fireEvent.change(selector, { target: { value: 'team_member' } });
expect(await screen.findByDisplayValue('Member')).toBeDefined();
expect(await screen.findByText('Update')).toBeDefined();
});
});
});
Loading

0 comments on commit b3aa730

Please sign in to comment.