Commit a8a0f459 authored by Mohammad Nakhaee's avatar Mohammad Nakhaee
Browse files

Merge branch 'UploadPage_Members' into 'v1.0.0'

Members dialog

See merge request !463
parents b5e947c0 c0380f55
Pipeline #116034 passed with stages
in 28 minutes and 2 seconds
......@@ -535,7 +535,7 @@ const useDatatableToolbarStyles = makeStyles(theme => ({
/** A toolbar shown on top of tables. It shows a title, general table actions, and actions
* on selected rows. Must be child of a Datatable */
export const DatatableToolbar = React.memo(function DatatableToolbar({children, title}) {
export const DatatableToolbar = React.memo(function DatatableToolbar({children, title, hideColumns}) {
const classes = useDatatableToolbarStyles()
const {selected} = useDatatableContext()
return (
......@@ -554,13 +554,14 @@ export const DatatableToolbar = React.memo(function DatatableToolbar({children,
</Typography>
)}
{children}
{!(selected?.length > 0) && <DatatableColumnSelector />}
{!hideColumns && !(selected?.length > 0) && <DatatableColumnSelector />}
</Toolbar>
)
})
DatatableToolbar.propTypes = {
/** Optional table title */
title: PropTypes.string,
hideColumns: PropTypes.bool,
/** Children, e.g. DatatableToolbarActions for general and selection actions. */
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
......
/*
* Copyright The NOMAD Authors.
*
* This file is part of NOMAD. See https://nomad-lab.eu for further info.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {useCallback, useContext, useMemo, useState} from 'react'
import {
makeStyles, DialogTitle, DialogContent, Dialog, IconButton, Tooltip,
Box, Divider, TextField, MenuItem, Select, StepLabel, Typography
} from '@material-ui/core'
import DialogContentText from '@material-ui/core/DialogContentText'
import MembersIcon from '@material-ui/icons/People'
import Button from '@material-ui/core/Button'
import DialogActions from '@material-ui/core/DialogActions'
import {uploadPageContext} from '../uploads/UploadPage'
import {Datatable, DatatableTable, DatatableToolbar} from '../datatable/Datatable'
import PropTypes from 'prop-types'
import {useApi} from '../api'
import {useErrors} from '../errors'
import AutoComplete from '@material-ui/lab/Autocomplete'
import DeleteIcon from '@material-ui/icons/Delete'
export const editMembersDialogContext = React.createContext()
const useStyles = makeStyles(theme => ({
dialog: {
width: '100%'
}
}))
function MembersTable() {
const {members, setIsChanged} = useContext(editMembersDialogContext)
const columns = useMemo(() => ([
{key: 'Name', align: 'left', render: member => member.name},
{key: 'Affiliation', align: 'left', render: member => member.affiliation},
{
key: 'Role',
align: 'left',
render: member => (member.role === 'Main author' ? member.role
: <Select defaultValue={member.role}
onChange={(event) => {
member.role = event.target.value
setIsChanged(true)
}}
>
<MenuItem value={'Co-author'}>Co-author</MenuItem>
<MenuItem value={'Reviewer'}>Reviewer</MenuItem>
</Select>)
}
]), [setIsChanged])
return <Datatable columns={columns} data={members} >
<DatatableToolbar title={`Members (${members.length})`} hideColumns/>
<DatatableTable actions={DeleteAction}/>
</Datatable>
}
function AddMember({...props}) {
const {api, raiseError} = props
const [role, setRole] = useState('Co-author')
const [suggestions, setSuggestions] = useState([])
const [newMember, setNewMember] = useState([])
const {members, setMembers, setIsChanged} = useContext(editMembersDialogContext)
const [isDuplicated, setIsDuplicated] = useState(false)
const [isValid, setIsValid] = useState(false)
const handleInputChange = useCallback((event, value) => {
const query = value.toLowerCase()
api.getUsers(query)
.then(users => {
const withQueryInName = users.filter(user => user.name.toLowerCase().indexOf(query) !== -1)
withQueryInName.sort((a, b) => {
const aValue = a.name.toLowerCase()
const bValue = b.name.toLowerCase()
if (aValue.startsWith(query)) {
return -1
} else if (bValue.startsWith(query)) {
return 1
} else {
return 0
}
})
setSuggestions(withQueryInName.slice(0, 5))
})
.catch(err => {
setSuggestions([])
raiseError(err)
})
}, [api, raiseError])
const handleChange = (event, value) => {
if (value && value?.user_id) {
setNewMember(value)
setIsValid(true)
setIsDuplicated(members.map(member => member.user_id).includes(value.user_id))
} else {
setIsValid(false)
}
}
const handleAdd = () => {
if (role) {
if (!members.map(member => member.user_id).includes(newMember.user_id)) {
newMember['role'] = role
setMembers(members => [...members, newMember])
setIsChanged(true)
} else {
setIsDuplicated(true)
}
}
}
return <React.Fragment>
<AutoComplete
style={{width: '100%'}}
options={suggestions}
getOptionLabel={option => (option.affiliation ? `${option.name} (${option.affiliation})` : option.name)}
onInputChange={handleInputChange}
onChange={handleChange}
renderInput={params => (
<TextField
{...params}
variant='standard'
label='Search the name and select a user from the list'
placeholder="Member's name"
margin='normal'
fullWidth
/>
)}
/>
<Box marginLeft={2}>
<Typography hidden={!isDuplicated} color="error">
The selected user is already in the members list
</Typography>
</Box>
<StepLabel>{"Select the member's role"}</StepLabel>
<Select defaultValue={'Co-author'} style={{width: '100%'}} onChange={(event) => setRole(event.target.value)}>
<MenuItem value={'Co-author'}>Co-author</MenuItem>
<MenuItem value={'Reviewer'}>Reviewer</MenuItem>
</Select>
<DialogActions>
<Button onClick={handleAdd} color="primary" disabled={isDuplicated || !isValid}>
Add
</Button>
</DialogActions>
</React.Fragment>
}
AddMember.propTypes = {
api: PropTypes.object.isRequired,
raiseError: PropTypes.func.isRequired
}
const DeleteAction = React.memo((props) => {
const {data} = props
const {members, setMembers, setIsChanged} = useContext(editMembersDialogContext)
const handleRemove = () => {
const filteredMembers = members.filter(member => !(member.user_id === data.user_id))
setMembers(filteredMembers)
setIsChanged(true)
}
const isOwner = data.role === 'Main author'
return <IconButton disabled={isOwner} onClick={handleRemove}>
<Tooltip title="Remove the member">
<DeleteIcon />
</Tooltip>
</IconButton>
})
DeleteAction.propTypes = {
data: PropTypes.object.isRequired
}
function EditMembersDialog({...props}) {
const classes = useStyles()
const {api} = useApi()
const {raiseError} = useErrors()
const {upload, setUpload} = useContext(uploadPageContext)
const [open, setOpen] = useState(false)
const [members, setMembers] = useState([])
const [isChanged, setIsChanged] = useState(false)
const [openConfirmDialog, setOpenConfirmDialog] = useState(false)
const getUser = useCallback((user_id, role) => {
return new Promise(async (resolve, reject) => {
try {
let member = await api.get(`users/${user_id}`)
member.role = role
resolve(member)
} catch {
reject(new Error('Unable to fetch the members'))
}
})
}, [api])
const fetchMembers = useCallback(() => {
let promises = []
promises.push(getUser(upload.main_author, 'Main author'))
upload.coauthors.forEach(user_id => promises.push(getUser(user_id, 'Co-author')))
upload.reviewers.forEach(user_id => promises.push(getUser(user_id, 'Reviewer')))
return Promise.all(promises)
}, [getUser, upload])
const handleOpenDialog = () => {
setMembers([])
setIsChanged(false)
fetchMembers()
.then(members => setMembers(members))
.catch(error => raiseError(error))
setOpen(true)
}
const handleDiscardChanges = () => {
setOpenConfirmDialog(false)
setOpen(false)
}
const handleSubmitChanges = () => {
if (isChanged) {
const newCoauthors = members.filter(member => member.role === 'Co-author').map(member => member.user_id)
const newReviewers = members.filter(member => member.role === 'Reviewer').map(member => member.user_id)
api.post(`/uploads/${upload.upload_id}/edit`, {
'metadata': {
'coauthors': newCoauthors,
'reviewers': newReviewers
}
}).then(results => {
setUpload(results.data)
setOpen(false)
}).catch(err => raiseError(err))
} else {
setOpen(false)
}
}
const onConfirm = () => {
if (isChanged) {
setOpenConfirmDialog(true)
} else {
setOpen(false)
}
}
const contextValue = useMemo(() => ({
members: members,
setMembers: setMembers,
isChanged: isChanged,
setIsChanged: setIsChanged
}), [members, setMembers, isChanged, setIsChanged])
return <editMembersDialogContext.Provider value={contextValue}>
<React.Fragment>
<IconButton onClick={handleOpenDialog}>
<Tooltip title="Manage upload members">
<MembersIcon/>
</Tooltip>
</IconButton>
{open && <Dialog classes={{paper: classes.dialog}} open={open} on disableBackdropClick disableEscapeKeyDown>
<DialogTitle>Manage upload members</DialogTitle>
<DialogContent>
<DialogContentText>
Upload ID: {upload?.upload_id}
<br/>
The upload includes {upload?.entries} {upload?.entries === 1 ? 'entry' : 'entries'}.
<br/>
You can add new members to this upload.
</DialogContentText>
<Divider/>
<AddMember api={api} raiseError={raiseError} {...props}/>
<Divider/>
<MembersTable />
</DialogContent>
<DialogActions>
<span style={{flexGrow: 1}} />
<Button onClick={onConfirm} color="secondary">
Cancel
</Button>
<Button onClick={handleSubmitChanges} disabled={!isChanged} color="secondary">
Submit
</Button>
</DialogActions>
<Dialog
open={openConfirmDialog}
aria-describedby="alert-dialog-description"
>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Your changes are not submitted yet. Discard changes?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenConfirmDialog(false)} autoFocus>Cancel</Button>
<Button onClick={handleDiscardChanges}>Discard</Button>
</DialogActions>
</Dialog>
</Dialog>}
</React.Fragment>
</editMembersDialogContext.Provider>
}
export default EditMembersDialog
......@@ -254,75 +254,6 @@ class MyAutosuggestUnstyled extends React.PureComponent {
const MyAutosuggest = withStyles(MyAutosuggestUnstyled.styles)(MyAutosuggestUnstyled)
class UserInputUnstyled extends React.Component {
static propTypes = {
value: PropTypes.string, // user_id
label: PropTypes.string,
error: PropTypes.bool,
api: PropTypes.object.isRequired,
onChange: PropTypes.func,
margin: PropTypes.any
}
suggestions(query) {
const {api} = this.props
query = query.toLowerCase()
return api.getUsers(query)
.then(users => {
users.forEach(user => update_local_user(user))
const withQueryInName = users.filter(
user => user.name.toLowerCase().indexOf(query) !== -1)
withQueryInName.sort((a, b) => {
const aValue = a.name.toLowerCase()
const bValue = b.name.toLowerCase()
if (aValue.startsWith(query)) {
return -1
} else if (bValue.startsWith(query)) {
return 1
} else {
return 0 // aValue.localeCompare(bValue)
}
})
return withQueryInName.slice(0, 5)
})
.catch(err => {
console.error(err)
return []
})
}
getSuggestionRenderValue(suggestion) {
const affiliation = suggestion.affiliation && suggestion.affiliation.trim()
return suggestion.name + (affiliation && ' (' + affiliation + ')')
}
getSuggestionValue(suggestion) {
return (suggestion && suggestion.name) || ''
}
handleChange(event) {
const value = event.target.value
this.props.onChange({target: {value: value ? value.user_id : value}})
}
render() {
const {label, error, value, margin} = this.props
const errorLabel = (value === undefined) && 'This user does not exist, you can invite new users'
return <MyAutosuggest onChange={this.handleChange.bind(this)} value={value ? local_users[value] : value}
suggestions={this.suggestions.bind(this)}
getSuggestionValue={this.getSuggestionValue.bind(this)}
getSuggestionRenderValue={this.getSuggestionRenderValue.bind(this)}
shouldRenderSuggestions={value => value.trim().length > 2}
margin={margin}
label={errorLabel || label}
error={!!(error || errorLabel)}
placeholder={`Type ${label}'s name and select a user from the list`}
/>
}
}
const UserInput = withApi(UserInputUnstyled)
class DatasetInputUnstyled extends React.Component {
static propTypes = {
value: PropTypes.string, // name
......@@ -481,21 +412,6 @@ class ListTextInputUnstyled extends React.Component {
component: PropTypes.any
}
static styles = theme => ({
root: {},
row: {
display: 'flex'
},
buttonContainer: {
position: 'relative',
width: 52
},
button: {
position: 'absolute',
bottom: 0
}
})
render() {
const { classes, values, onChange, label, component, ...fieldProps } = this.props
......@@ -1052,20 +968,6 @@ class EditUserMetadataDialogUnstyled extends React.Component {
label="References"
/>
</UserMetadataField>
<UserMetadataField {...metadataFieldProps('entry_coauthors', true)}>
<ListTextInput
component={UserInput}
{...listTextInputProps('entry_coauthors', true)}
label="Co-author"
/>
</UserMetadataField>
<UserMetadataField {...metadataFieldProps('reviewers', true)}>
<ListTextInput
component={UserInput}
{...listTextInputProps('reviewers', true)}
label="Reviewers"
/>
</UserMetadataField>
<UserMetadataField {...metadataFieldProps('datasets', true)}>
<ListTextInput
component={DatasetInput}
......
......@@ -38,6 +38,7 @@ import PublishedIcon from '@material-ui/icons/Public'
import UnPublishedIcon from '@material-ui/icons/AccountCircle'
import Markdown from '../Markdown'
import EditUserMetadataDialog from '../entry/EditUserMetadataDialog'
import EditMembersDialog from '../entry/EditMembersDialog'
import Page from '../Page'
import { getUrl } from '../nav/Routes'
import { combinePagination } from '../datatable/Datatable'
......@@ -388,9 +389,11 @@ function UploadPage() {
const contextValue = useMemo(() => ({
upload: upload,
setUpload: setUpload,
data: data,
isViewer: isViewer,
isWriter: isWriter
}), [upload, isViewer, isWriter])
}), [upload, setUpload, data, isViewer, isWriter])
if (!upload) {
return <Page limitedWidth>
......@@ -438,6 +441,7 @@ function UploadPage() {
</WithButton>
</Grid>
<Grid>
<EditMembersDialog/>
<UploadDownloadButton tooltip="Download files" query={{'upload_id': uploadId}} />
<IconButton disabled={isPublished || !isWriter} onClick={handleReprocess}>
<Tooltip title="Reprocess">
......
......@@ -958,7 +958,7 @@ async def put_upload_metadata(
@router.post(
'/{upload_id}/edit', tags=[metadata_tag],
summary='Updates the metadata of the specified upload.',
response_model=MetadataEditRequest,
response_model=UploadProcDataResponse,
responses=create_responses(_upload_not_found, _not_authorized_to_upload, _bad_request),
response_model_exclude_unset=True,
response_model_exclude_none=True)
......@@ -986,9 +986,9 @@ async def post_upload_edit(
'''
edit_request_json = await request.json()
try:
verified_json = MetadataEditRequestHandler.edit_metadata(
MetadataEditRequestHandler.edit_metadata(
edit_request_json=edit_request_json, upload_id=upload_id, user=user)
return verified_json
return UploadProcDataResponse(upload_id=upload_id, data=_upload_to_pydantic(Upload.get(upload_id)))
except RequestValidationError as e:
raise # A problem which we have handled explicitly. Fastapi does json conversion.
except Exception as e:
......
......@@ -62,7 +62,7 @@ async def read_users_me(current_user: User = Depends(create_user_dependency(requ
'',
tags=[default_tag],
summary='Get existing users',
description='Get existing users witht the given prefix',
description='Get existing users with the given prefix',
response_model_exclude_unset=True,
response_model_exclude_none=True,
response_model=Users)
......@@ -75,6 +75,20 @@ async def get_users(prefix: str):
return dict(data=users)
@router.get(
'/{user_id}',
tags=[default_tag],
summary='Get existing users',
description='Get the user using the given user_id',
response_model_exclude_unset=True,
response_model_exclude_none=True,
response_model=User)
async def get_user(user_id: str):
user = infrastructure.keycloak.get_user(user_id=user_id)
user.email = None
return user
@router.put(
'/invite',
tags=[default_tag],
......@@ -85,7 +99,7 @@ async def invite_user(user: User, current_user: User = Depends(create_user_depen
if config.keycloak.oasis:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='User invide does not work this NOMAD OASIS.')
detail='User invite does not work this NOMAD OASIS.')
json_data = user.dict()
try:
......
......@@ -326,7 +326,7 @@ class Keycloak():
self.__user_from_keycloak_user(keycloak_user)
for keycloak_user in keycloak_results]
def get_user(self, user_id: str = None, username: str = None, user=None) -> object:
def get_user(self, user_id: str = None, username: str = None, user=None):
'''
Retrives all available information about a user from the keycloak admin
interface. This must be used to retrieve complete user information, because
......
......@@ -17,6 +17,9 @@
# limitations under the License.
#
import pytest
def test_me(client, test_user_auth):
response = client.get('users/me', headers=test_user_auth)
assert response.status_code == 200
......@@ -62,3 +65,22 @@ def test_users(client):
assert value is not None
assert 'email' not in user
@pytest.mark.parametrize('args, expected_status_code, expected_content', [
pytest.param(dict(
user_id='00000000-0000-0000-0000-000000000001'), 200,
{'name': 'Sheldon Cooper', 'is_admin': False, 'is_oasis_admin': True, 'email': None},
id='valid-user')])
def test_users_id(
client, example_data, test_auth_dict,
args, expected_status_code, expected_content):
user_id = args['user_id']
rv = client.get(f'users/{user_id}'