Commit 34b60557 authored by Mohammad Nakhaee's avatar Mohammad Nakhaee Committed by David Sikter
Browse files

Eln user author (#918)

parent ace60545
......@@ -13,6 +13,8 @@ import {BoolEditQuantity} from '../editQuantity/BoolEditQuantity'
import FileEditQuantity from '../editQuantity/FileEditQuantity'
import RichTextEditQuantity from '../editQuantity/RichTextEditQuantity'
import ReferenceEditQuantity from '../editQuantity/ReferenceEditQuantity'
import UserEditQuantity from '../editQuantity/UserEditQuantity'
import AuthorEditQuantity from '../editQuantity/AuthorEditQuantity'
import { QuantityMDef } from './metainfo'
import { RadioEnumEditQuantity } from '../editQuantity/RadioEnumEditQuantity'
......@@ -27,7 +29,9 @@ const editQuantityComponents = {
FileEditQuantity: FileEditQuantity,
DateTimeEditQuantity: DateTimeEditQuantity,
RichTextEditQuantity: RichTextEditQuantity,
ReferenceEditQuantity: ReferenceEditQuantity
ReferenceEditQuantity: ReferenceEditQuantity,
UserEditQuantity: UserEditQuantity,
AuthorEditQuantity: AuthorEditQuantity
}
export const JsonEditor = React.memo(function JsonEditor({data, onChange}) {
......
/*
* 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, useEffect, useMemo, useState} from 'react'
import PropTypes from 'prop-types'
import {getFieldProps, TextFieldWithHelp} from './StringEditQuantity'
import {Box} from '@material-ui/core'
export const AuthorEditQuantity = React.memo((props) => {
const {quantityDef, onChange, ...otherProps} = props
const [author, setAuthor] = useState(undefined)
const [open, setOpen] = useState(false)
const [email, setEmail] = useState(otherProps?.value?.email)
const [emailError, setEmailError] = useState(false)
useEffect(() => {
setAuthor(otherProps?.value)
}, [otherProps?.value])
const handleChange = useCallback((key, value) => {
if (onChange) {
const newValue = {...author}
if (value) {
newValue[key] = value
} else {
delete newValue[key]
}
onChange(newValue && Object.keys(newValue).length !== 0 ? newValue : undefined)
}
}, [author, onChange])
const title = useMemo(() => {
if (author?.first_name && author?.last_name) {
return author?.affiliation
? `${author?.first_name} ${author?.last_name} (${author?.affiliation})`
: `${author?.first_name} ${author?.last_name}`
} else {
return ''
}
}, [author?.affiliation, author?.first_name, author?.last_name])
const handleEmailChange = useCallback((value) => {
setEmail(value)
if (value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
handleChange('email', value)
setEmailError(false)
} else {
setEmailError(true)
}
}, [handleChange])
return <React.Fragment>
<TextFieldWithHelp
variant='filled'
size='small'
value={title}
onClick={event => setOpen(open => !open)}
fullWidth
inputProps={{readOnly: true}}
{...getFieldProps(quantityDef)}
/>
<Box hidden={!open} padding={0}>
<TextFieldWithHelp
label={'First name'}
variant='filled'
size='small'
placeholder='First name'
value={otherProps?.value?.first_name}
onChange={event => handleChange('first_name', event.target.value)}
fullWidth
/>
<TextFieldWithHelp
label={'Last name'}
variant='filled'
size='small'
placeholder='Last name'
value={otherProps?.value?.last_name}
onChange={event => handleChange('last_name', event.target.value)}
fullWidth
/>
<TextFieldWithHelp
label={'Affiliation'}
variant='filled'
size='small'
placeholder='Affiliation'
value={otherProps?.value?.affiliation}
onChange={event => handleChange('affiliation', event.target.value)}
fullWidth
/>
<TextFieldWithHelp
label={'Email'}
variant='filled'
size='small'
placeholder="Email address"
value={email}
onChange={event => handleEmailChange(event.target.value)}
error={emailError}
helperText={emailError && 'The email is not valid!'}
fullWidth
/>
<TextFieldWithHelp
label={'Address'}
variant='filled'
size='small'
placeholder='Affiliation address'
value={otherProps?.value?.affiliation_address}
onChange={event => handleChange('affiliation_address', event.target.value)}
fullWidth
/>
</Box>
</React.Fragment>
})
AuthorEditQuantity.propTypes = {
quantityDef: PropTypes.object.isRequired,
value: PropTypes.string,
onChange: PropTypes.func
}
export default AuthorEditQuantity
......@@ -30,6 +30,8 @@ import RichTextEditQuantity from './RichTextEditQuantity'
import ListEditQuantity from './ListEditQuantity'
import { Code } from '../buttons/SourceDialogButton'
import { stripIndent } from '../../utils'
import UserEditQuantity from './UserEditQuantity'
import AuthorEditQuantity from './AuthorEditQuantity'
const enumValues = [
'Vapor deposition', 'Chemical vapor deposition', 'Metalorganic vapour phase epitaxy', 'Electrostatic spray assisted vapour deposition (ESAVD)', 'Sherardizing',
......@@ -397,6 +399,30 @@ export function EditQuantityExamples() {
/>
</Example>
</Grid>
<Grid item>
<Example
code={`
string:
type: User
m_annotations:
eln:
component: UserEditQuantity`}
>
<UserEditQuantity {...createDefaultProps('User')} />
</Example>
</Grid>
<Grid item>
<Example
code={`
string:
type: Author
m_annotations:
eln:
component: AuthorEditQuantity`}
>
<AuthorEditQuantity {...createDefaultProps('Author')} />
</Example>
</Grid>
</Grid>
</Box>
</CardContent>
......
......@@ -18,8 +18,9 @@
import React from 'react'
import {
closeAPI,
render,
screen, wait
screen, startAPI, wait, waitForGUI
} from '../conftest.spec'
import {EditQuantityExamples} from './EditQuantityExamples'
import {within} from '@testing-library/dom'
......@@ -100,3 +101,26 @@ test('correctly renders edit quantities', async () => {
await waitFor(() => expect(numberFieldUnitInputInMeter.value).toEqual('Å'))
await waitFor(() => expect(screen.queryByText(/"float_with_bounds": 1\.5e-10/i)).toBeInTheDocument())
})
test('Test UserEditQuantity', async () => {
await startAPI('tests.states.uploads.empty', 'tests/data/editquantity/user')
render(<EditQuantityExamples />)
// Wait to load the entry metadata, i.e. wait for some texts to appear
await screen.findByText('User')
const userField = screen.getByTestId('user-edit-quantity')
const userFieldInput = within(userField).getByRole('textbox')
userField.focus()
// assign an incomplete value to the input field
fireEvent.change(userFieldInput, { target: { value: 'schei' } })
await waitForGUI()
await waitFor(() => expect(userFieldInput.value).toEqual('schei'))
await waitForGUI()
fireEvent.keyDown(userField, { key: 'ArrowDown' })
fireEvent.keyDown(userField, { key: 'Enter' })
await waitForGUI()
await waitFor(() => expect(userFieldInput.value).toEqual('Markus Scheidgen (FHI)'))
closeAPI()
})
/*
* 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, useEffect, useMemo, useState} from 'react'
import PropTypes from 'prop-types'
import {useApi} from "../api"
import {useErrors} from "../errors"
import AutoComplete from "@material-ui/lab/Autocomplete"
import {debounce} from "lodash"
import {getFieldProps, TextFieldWithHelp} from './StringEditQuantity'
import {fetchUsers} from '../uploads/EditMembersDialog'
export const UserEditQuantity = React.memo((props) => {
const {quantityDef, onChange, ...otherProps} = props
const {helpDescription, ...otherFieldProps} = otherProps
const {api} = useApi()
const {raiseError} = useErrors()
const [query, setQuery] = useState('')
const [user, setUser] = useState(undefined)
const [suggestions, setSuggestions] = useState([])
const handleInputChange = useCallback((event, value) => {
if (!(event && event.type && event.type === 'change')) {
return
}
const newQuery = value.toLowerCase()
if (!(newQuery.startsWith(query) && suggestions.length === 0) || query === '') {
fetchUsers(api, query, newQuery)
.then(setSuggestions)
.catch(err => {
raiseError(err)
setSuggestions([])
})
}
setQuery(newQuery)
}, [api, raiseError, query, suggestions])
const getUser = useCallback((user_id) => {
return new Promise((resolve, reject) => {
if (!user_id) {
resolve(undefined)
}
api.get(`users?user_id=${user_id}`)
.then(response => {
const user = response['data']?.[0]
user ? resolve(user) : reject(new Error('Unable to find the member'))
})
.catch(error => {
reject(new Error('Unable to find the member: ' + error))
})
})
}, [api])
useEffect(() => {
getUser(otherProps.value).then(user => {
setSuggestions(user ? [user] : [])
setUser(user)
})
}, [getUser, otherProps.value])
const debouncedHandleInputChange = useMemo(() => (
debounce(handleInputChange, 700)
), [handleInputChange])
const handleChange = useCallback((event, value) => {
if (onChange) {
setSuggestions(value ? [value] : [])
setUser(value)
onChange(value?.user_id === '' ? undefined : value?.user_id)
}
}, [onChange])
return <React.Fragment>
<AutoComplete
style={{width: '100%'}}
options={suggestions}
getOptionLabel={option => option ? (option.affiliation ? `${option.name} (${option.affiliation})` : option.name) : ''}
getOptionSelected={(option, value) => suggestions && value && option.user_id === value.user_id}
onInputChange={debouncedHandleInputChange}
onChange={handleChange}
value={suggestions.includes(user) ? user : null}
renderInput={params => (
<TextFieldWithHelp
{...params}
variant='filled'
size='small'
placeholder="NOMAD member's name"
margin='normal'
fullWidth
{...getFieldProps(quantityDef)}
{...otherFieldProps}
/>
)}
data-testid='user-edit-quantity'
/>
</React.Fragment>
})
UserEditQuantity.propTypes = {
quantityDef: PropTypes.object.isRequired,
value: PropTypes.string,
onChange: PropTypes.func
}
export default UserEditQuantity
......@@ -191,6 +191,30 @@ function MembersTable() {
</Datatable>
}
export const fetchUsers = (api, previousQuery, newQuery) => {
return new Promise((resolve, reject) => {
api.getUsers(newQuery)
.then(users => {
const withQueryInName = users.filter(user => user.name.toLowerCase().indexOf(newQuery) !== -1)
withQueryInName.sort((a, b) => {
const aValue = a.name.toLowerCase()
const bValue = b.name.toLowerCase()
if (aValue.startsWith(newQuery)) {
return -1
} else if (bValue.startsWith(newQuery)) {
return 1
} else {
return 0
}
})
resolve(withQueryInName.slice(0, 5))
})
.catch(err => {
reject(err)
})
})
}
function AddMember({...props}) {
const {api, raiseError} = props
const [role, setRole] = useState('Co-author')
......@@ -201,36 +225,22 @@ function AddMember({...props}) {
const [isValid, setIsValid] = useState(false)
const [query, setQuery] = useState('')
const fetchUsers = useCallback((event, value) => {
const handleInputChange = useCallback((event, value) => {
const newQuery = value.toLowerCase()
if (!(newQuery.startsWith(query) && suggestions.length === 0) || query === '') {
api.getUsers(newQuery)
.then(users => {
const withQueryInName = users.filter(user => user.name.toLowerCase().indexOf(newQuery) !== -1)
withQueryInName.sort((a, b) => {
const aValue = a.name.toLowerCase()
const bValue = b.name.toLowerCase()
if (aValue.startsWith(newQuery)) {
return -1
} else if (bValue.startsWith(newQuery)) {
return 1
} else {
return 0
}
})
setSuggestions(withQueryInName.slice(0, 5))
})
fetchUsers(api, query, newQuery)
.then(setSuggestions)
.catch(err => {
setSuggestions([])
raiseError(err)
})
}
setQuery(newQuery)
}, [api, raiseError, query, suggestions])
}, [api, query, raiseError, suggestions.length])
const handleInputChange = useMemo(() => (
debounce(fetchUsers, 700)
), [fetchUsers])
const debouncedHandleInputChange = useMemo(() => (
debounce(handleInputChange, 700)
), [handleInputChange])
const handleChange = useCallback((event, value) => {
if (value && value?.user_id) {
......@@ -260,7 +270,7 @@ function AddMember({...props}) {
options={suggestions}
getOptionLabel={option => (option.affiliation ? `${option.name} (${option.affiliation})` : option.name)}
getOptionSelected={(option, value) => value ? option.user_id === value.user_id : false}
onInputChange={handleInputChange}
onInputChange={debouncedHandleInputChange}
onChange={handleChange}
renderInput={params => (
<TextField
......
{
"2a8cbe65e9653c6c7b814d5314109d60": [
{
"request": {
"url": "http://localhost:8000/fairdi/nomad/latest/api/v1/users?user_id=undefined",
"method": "GET",
"body": "",
"headers": {
"accept": "application/json",
"cookie": null
}
},
"response": {
"status": 200,
"body": {
"data": []
},
"headers": {
"connection": "close",
"content-length": "16",
"content-type": "application/json",
"server": "uvicorn"
}
}
}
],
"4fb167ea4a236fd22afec9242e306b2f": [
{
"request": {
"url": "http://localhost:8000/fairdi/nomad/latest/api/v1/users?prefix=schei",
"method": "GET",
"body": "",
"headers": {
"accept": "application/json",
"cookie": null
}
},
"response": {
"status": 200,
"body": {
"data": [
{
"name": "Admin Administrator",
"first_name": "Admin",
"last_name": "Administrator",
"user_id": "c97facc2-92ec-4fa6-80cf-a08ed957255b",
"username": "admin",
"created": "2020-01-17T12:39:45.509000+00:00",
"is_admin": false,
"is_oasis_admin": false
},
{
"name": "Markus Scheidgen",
"first_name": "Markus",
"last_name": "Scheidgen",
"affiliation": "FHI",
"affiliation_address": "Berlin",
"user_id": "20bb9766-d338-4314-be43-7906042a5086",
"username": "mscheidg",
"created": "2019-08-28T14:00:32.403000+00:00",
"is_admin": false,
"is_oasis_admin": false
},
{
"name": "Markus Scheidgen",
"first_name": "Markus",
"last_name": "Scheidgen",
"affiliation": "dsl@2982ä`242",
"affiliation_address": "",
"user_id": "b225ca5d-b50d-486b-859a-fbab7d41da15",
"username": "mscheidgen",
"created": "2021-09-17T08:51:28.940000+00:00",
"repo_user_id": "null",
"is_admin": false,
"is_oasis_admin": false
},
{
"name": "Markus Scheidgen",
"first_name": "Markus",
"last_name": "Scheidgen",
"affiliation": "Affiliation",
"affiliation_address": "",
"user_id": "774fb559-4588-4148-8bb2-c9b1a72240a1",
"username": "mscheidgen1",
"created": "2022-01-28T11:25:52.286000+00:00",
"repo_user_id": "null",
"is_admin": false,
"is_oasis_admin": false
},
{
"name": "Markus Scheidgen",
"first_name": "Markus",
"last_name": "Scheidgen",
"user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470",
"username": "test",
"created": "2021-09-21T12:00:27.257000+00:00",
"is_admin": false,
"is_oasis_admin": false
}
]
},
"headers": {
"connection": "close",
"content-length": "1810",
"content-type": "application/json",
"server": "uvicorn"
}
}
}
],
"13f25438d310781bc2b34b2f13009dca": [
{
"request": {
"url": "http://localhost:8000/fairdi/nomad/latest/api/v1/users?user_id=20bb9766-d338-4314-be43-7906042a5086",
"method": "GET",
"body": "",
"headers": {
"accept": "application/json",
"cookie": null
}
},
"response": {
"status": 200,
"body": {
"data": [
{
"name": "Markus Scheidgen",
"first_name": "Markus",
"last_name": "Scheidgen",
"affiliation": "FHI",
"affiliation_address": "Berlin",
"user_id": "20bb9766-d338-4314-be43-7906042a5086",
"username": "mscheidg",
"created": "2019-08-28T14:00:32.403000+00:00",
"is_admin": false,
"is_oasis_admin": false
}
]
},
"headers": {
"connection": "close",
"content-length": "389",
"content-type": "application/json",
"server": "uvicorn"
}
}
}
]
}
\ No newline at end of file