Commit 1547c456 authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Completed first implementation of working user metadata edit button and dialog.

parent 7f5f6754
Pipeline #62844 passed with stages
in 32 minutes and 46 seconds
......@@ -62,7 +62,7 @@ linting:
image: $TEST_IMAGE
script:
- cd /app
- python -m pycodestyle --ignore=E501,E701 nomad tests
- python -m pycodestyle --ignore=E501,E701,E731 nomad tests
- python -m pylint --load-plugins=pylint_mongoengine nomad tests
- python -m mypy --ignore-missing-imports --follow-imports=silent --no-strict-optional nomad tests
except:
......
......@@ -79,8 +79,17 @@ your browser.
Omitted versions are plain bugfix releases with only minor changes and fixes.
### v0.7.0
- User metadata editing and datasets with DOI
- Revised GUI lists (search results, datasets, uploads)
- Keycloak based user management
- no dependencies with the NOMAD CeE Repository
- no dependencies with the NOMAD CoE Repository
### v0.6.2
- GUI performance enhancements
- API /raw/query endpoint takes file pattern to further filter download contents and
strips potential shared path prefixes for a cleaner download .zip
- Stipped common path prefixes in raw file downloads
- minor bugfixes
### v0.6.0
- GUI URL, and API endpoint that resolves NOMAD CoE legacy PIDs
......
......@@ -142,7 +142,7 @@ def publish_upload(upload, calc_metadata):
'comment': 'Data from a cool external project',
'references': ['http://external.project.eu'],
# '_uploader': <nomad_user_id>, # only works if the admin user is publishing
# 'co_authors': [<nomad_user_id>, <nomad_user_id>, <nomad_user_id>]
# 'coauthors': [<nomad_user_id>, <nomad_user_id>, <nomad_user_id>]
# these are calc specific metadata that supercede any upload metadata
'calculations': calc_metadata}
......
......@@ -7,6 +7,30 @@
user-select: none;
}
html, body {
background: url('nomad.png') no-repeat center center;
min-height: 100%;
height: 100%;
width: 100%;
padding: 0;
margin: 0;
}
img.bg {
/* Set rules to fill background */
min-height: 100%;
min-width: 1920px;
/* Set up proportionate scaling */
width: 100%;
height: 100%;
/* Set up positioning */
position: fixed;
top: 0;
left: 0;
}
.pace-inactive {
display: none;
}
......
......@@ -32,6 +32,7 @@ import ResolvePID from './entry/ResolvePID'
import DatasetPage from './DatasetPage'
import { capitalize } from '../utils'
import { amber } from '@material-ui/core/colors'
import KeepState from './KeepState'
export class VersionMismatch extends Error {
constructor(msg) {
......@@ -140,7 +141,7 @@ class NavigationUnstyled extends React.Component {
flexGrow: 1,
backgroundColor: theme.palette.background.default,
width: '100%',
overflow: 'scroll'
overflow: 'auto'
},
link: {
textDecoration: 'none',
......@@ -428,6 +429,11 @@ export default class App extends React.Component {
}
}
},
'entry_query': {
exact: true,
path: '/entry/query',
render: props => <EntryPage {...props} query />
},
'dataset': {
path: '/dataset/id/:datasetId',
key: (props) => `dataset/id/${props.match.params.datasetId}`,
......@@ -461,6 +467,7 @@ export default class App extends React.Component {
'metainfo': {
exact: true,
path: '/metainfo',
singleton: true,
render: props => <MetaInfoBrowser {...props} />
},
'metainfoEntry': {
......@@ -471,21 +478,13 @@ export default class App extends React.Component {
}
renderChildren(routeKey, props) {
// const { match, ...rest } = props
return (
<div>
{Object.keys(this.routes)
.filter(route => this.routes[route].singleton || route === routeKey)
.map(route => (
<div
key={route.key ? route.key(props) : route}
style={{display: routeKey === route ? 'block' : 'none'}}
>
{this.routes[route].render(props)}
</div>
))}
</div>
<React.Fragment>
{Object.keys(this.routes).map(route => <KeepState key={route}
visible={routeKey === route}
render={(props) => this.routes[route].render(props)}
{...props} />)}
</React.Fragment>
)
}
......
......@@ -40,7 +40,7 @@ class DatasetPage extends React.Component {
dataset: {}
}
componentDidMount() {
update() {
const {datasetId, raiseError, api} = this.props
api.search({
owner: 'all',
......@@ -56,6 +56,16 @@ class DatasetPage extends React.Component {
})
}
componentDidMount() {
this.update()
}
componentDidUpdate(prevProps) {
if (prevProps.api !== this.props.api || prevProps.datasetId !== this.props.datasetId) {
this.update()
}
}
render() {
const { classes, datasetId } = this.props
const { dataset } = this.state
......
......@@ -7,11 +7,10 @@ import DialogContent from '@material-ui/core/DialogContent'
import DialogContentText from '@material-ui/core/DialogContentText'
import DialogTitle from '@material-ui/core/DialogTitle'
import PropTypes from 'prop-types'
import { IconButton, Tooltip, withStyles, Paper, MenuItem, Popper } from '@material-ui/core'
import { IconButton, Tooltip, withStyles, Paper, MenuItem, Popper, CircularProgress } from '@material-ui/core'
import EditIcon from '@material-ui/icons/Edit'
import AddIcon from '@material-ui/icons/Add'
import RemoveIcon from '@material-ui/icons/Delete'
import ReactJson from 'react-json-view'
import Autosuggest from 'react-autosuggest'
import match from 'autosuggest-highlight/match'
import parse from 'autosuggest-highlight/parse'
......@@ -48,6 +47,15 @@ class SuggestionsTextFieldUnstyled extends React.Component {
constructor(props) {
super(props)
this.lastRequestId = null
this.unmounted = false
}
componentWillUnmount() {
this.unmounted = true
}
componentDidMount() {
this.unmounted = false
}
loadSuggestions(value) {
......@@ -65,12 +73,14 @@ class SuggestionsTextFieldUnstyled extends React.Component {
this.lastRequestId = setTimeout(() => {
this.props.suggestions(value).then(suggestions => {
this.setState({
isLoading: false,
suggestions: suggestions
})
if (!this.unmounted) {
this.setState({
isLoading: false,
suggestions: suggestions
})
}
})
}, 1000)
}, 200)
}
state = {
......@@ -195,7 +205,7 @@ function isURL(str) {
class ListTextInputUnstyled extends React.Component {
static propTypes = {
classes: PropTypes.object.isRequired,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
validate: PropTypes.func,
label: PropTypes.string,
errorLabel: PropTypes.string,
......@@ -223,13 +233,19 @@ class ListTextInputUnstyled extends React.Component {
const handleChange = (index, value) => {
// TODO
if (onChange) {
onChange([...values.slice(0, index), value, ...values.slice(index + 1)])
const newValues = [...values]
if (newValues[index]) {
newValues[index].value = value
} else {
newValues[index] = {value: value}
}
onChange(newValues)
}
}
const handleAdd = () => {
if (onChange) {
onChange([...values, ''])
onChange([...values, {value: ''}])
}
}
......@@ -240,14 +256,20 @@ class ListTextInputUnstyled extends React.Component {
}
const Component = component || TextField
const normalizedValues = values.length === 0 ? [''] : values
const normalizedValues = values.length === 0 ? [{value: ''}] : values
return <React.Fragment>
{normalizedValues.map((value, index) => {
const error = validate && !validate(value)
let labelValue = index === 0 ? label : null
{normalizedValues.map(({value, message, success}, index) => {
let error = validate && !validate(value)
let labelValue
if (index === 0) {
labelValue = label
}
if (error) {
labelValue = errorLabel || 'Bad value'
} else if (message) {
labelValue = message
error = !success
}
return <div key={index} className={classes.row}>
<Component
......@@ -268,7 +290,7 @@ class ListTextInputUnstyled extends React.Component {
</IconButton> : ''}
</div>
<div className={classes.buttonContainer}>
{index + 1 === normalizedValues.length && normalizedValues[index] !== ''
{index + 1 === normalizedValues.length && normalizedValues[index].value !== ''
? <IconButton className={classes.button} size="tiny" onClick={handleAdd}>
<AddIcon fontSize="inherit" />
</IconButton> : ''}
......@@ -293,46 +315,74 @@ class EditUserMetadataDialogUnstyled extends React.Component {
total: PropTypes.number,
example: PropTypes.object,
buttonProps: PropTypes.object,
api: PropTypes.object.isRequired
api: PropTypes.object.isRequired,
raiseError: PropTypes.func.isRequired,
user: PropTypes.object,
onEditComplete: PropTypes.func,
disabled: PropTypes.bool
}
static styles = theme => ({
dialog: {
width: '100%'
},
submitWrapper: {
margin: theme.spacing.unit,
position: 'relative'
},
submitProgress: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12
}
})
constructor(props) {
super(props)
this.handleButtonClick = this.handleButtonClick.bind(this)
}
state = {
open: false,
editData: {
this.handleClose = this.handleClose.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.verifyTimer = null
this.state = {...this.defaultState}
this.editData = {
comment: '',
references: [],
coAuthors: [],
sharedWith: [],
coauthors: [],
shared_with: [],
datasets: [],
withEmbargo: true
with_embargo: true
}
this.unmounted = false
}
defaultState = {
open: false,
actions: {},
isVerifying: false,
verified: true,
submitting: false
}
componentWillUnmount() {
this.unmounted = true
}
update() {
const { example } = this.props
const editData = {
this.editData = {
comment: example.comment || '',
references: example.references || [],
coAuthors: example.authors.filter(author => author.user_id !== example.uploader.user_id).map(author => author.email),
sharedWith: example.owners.filter(author => author.user_id !== example.uploader.user_id).map(author => author.email),
coauthors: example.authors.filter(author => author.user_id !== example.uploader.user_id).map(author => author.email),
shared_with: example.owners.filter(author => author.user_id !== example.uploader.user_id).map(author => author.email),
datasets: (example.datasets || []).map(ds => ds.name),
withEmbargo: example.with_embargo
with_embargo: example.with_embargo
}
this.setState({editData: editData})
}
componentDidMount() {
this.unmounted = false
this.update()
}
......@@ -342,6 +392,59 @@ class EditUserMetadataDialogUnstyled extends React.Component {
}
}
verify() {
if (this.state.isVerifying) {
return
}
if (this.verifyTimer !== null) {
clearTimeout(this.verifyTimer)
}
this.setState({
isVerifying: true, verified: false
})
this.verifyTimer = setTimeout(() => {
this.submitPromise(true).then(newState => {
this.setState(newState)
}).catch(error => {
this.setState({verified: false, isVerifying: false})
return this.props.raiseError(error)
})
}, 200)
}
submitPromise(verify) {
const { actions } = this.state
const editRequest = {
verify: verify,
actions: actions
}
return this.props.api.edit(editRequest).then(data => {
if (this.unmounted) {
return
}
const newActions = {...this.state.actions}
let verified = true
if (data.actions) {
Object.keys(newActions).forEach(key => {
if (Array.isArray(newActions[key])) {
newActions[key] = newActions[key].map((action, i) => {
verified &= !data.actions[key] || data.actions[key].success !== false
return data.actions[key]
? {...(data.actions[key][i] || {}), value: action.value}
: action
})
}
})
}
return {actions: newActions, isVerifying: false, verified: verified}
})
}
handleButtonClick() {
const { open } = this.state
if (!open) {
......@@ -351,115 +454,150 @@ class EditUserMetadataDialogUnstyled extends React.Component {
this.setState({open: !open})
}
render() {
const { classes, buttonProps, total, api } = this.props
const { open } = this.state
const close = () => this.setState({open: false})
handleClose() {
this.setState({submitting: true})
this.setState({...this.defaultState})
}
handleSubmit() {
this.setState({submitting: true})
const handleChange = (key, value) => {
this.setState({editData: {...this.state.editData, [key]: value}})
this.submitPromise(false).then(newState => {
if (this.props.onEditComplete) {
this.props.onEditComplete()
}
this.setState({...newState, submitting: false})
this.handleClose()
}).catch(error => {
this.setState({verified: false, isVerifying: false, submitting: false})
return this.props.raiseError(error)
})
}
render() {
const { classes, buttonProps, total, api, user, example, disabled } = this.props
const { open, actions, verified, submitting } = this.state
const dialogEnabled = user && example.uploader.user_id === user.sub && !disabled
const submitEnabled = Object.keys(actions).length && !submitting && verified
const listTextInputProps = (key, verify) => {
const values = actions[key] ? actions[key] : this.editData[key].map(value => ({value: value}))
return {
id: key,
fullWidth: true,
values: values,
onChange: values => {
this.setState({actions: {...actions, [key]: values}})
if (verify) {
this.verify()
}
}
}
}
const value = key => this.state.editData[key]
const userSuggestions = query => {
return api.getUsers(query)
.then(result => result.users)
.catch((err) => {
console.log(err)
.catch(err => {
console.error(err)
return []
})
}
return (
<React.Fragment>
<Tooltip title="Edit user metadata">
<IconButton {...(buttonProps || {})} onClick={this.handleButtonClick}>
<IconButton {...(buttonProps || {})} onClick={this.handleButtonClick} disabled={!dialogEnabled}>
<Tooltip title={`Edit user metadata${dialogEnabled ? '' : '. You can only edit your data.'}`}>
<EditIcon />
</IconButton>
</Tooltip>
<Dialog classes={{paper: classes.dialog}} open={open} onClose={close} disableBackdropClick disableEscapeKeyDown>
<DialogTitle>Edit the user metadata of {total} entries</DialogTitle>
<DialogContent>
<DialogContentText>
TODO better text
</DialogContentText>
<TextField
id="comment"
label="Comment"
value={value('comment')}
onChange={event => handleChange('comment', event.target.value)}
margin="normal"
multiline
fullWidth
/>
<ListTextInput
id="references"
label="References"
errorLabel="References must be valid URLs"
placeholder="Add a URL reference"
values={value('references')}
onChange={values => handleChange('references', values)}
validate={isURL}
fullWidth
/>
<SuggestionsListTextInput
suggestions={userSuggestions}
suggestionValue={v => v.email}
suggestionRendered={v => `${v.name} (${v.email})`}
id="coAuthors"
label="Co-authors"
placeholder="Add a co-author by name"
values={value('coAuthors')}
onChange={values => handleChange('coAuthors', values)}
fullWidth
/>
<SuggestionsListTextInput
suggestions={userSuggestions}
suggestionValue={v => v.email}
suggestionRendered={v => `${v.name} (${v.email})`}
id="sharedWith"
label="Shared with"
placeholder="Add a user by name to share with"
values={value('sharedWith')}
onChange={values => handleChange('sharedWith', values)}
fullWidth
/>
<SuggestionsListTextInput
suggestions={prefix => {
console.log(prefix)
return api.getDatasets(prefix)
.then(result => result.results.map(ds => ds.name))
.catch((err) => {
console.log(err)
return []
})
}}
suggestionValue={v => v}
suggestionRendered={v => v}
id="datasets"
label="Datasets"
placeholder="Add a dataset"
values={value('datasets')}
onChange={values => handleChange('datasets', values)}
fullWidth
/>
</DialogContent>
<DialogContent>
<ReactJson
src={this.state.editData}
enableClipboard={false}
collapsed={0}
/>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Cancel
</Button>
<Button onClick={close} color="primary">
Submit
</Button>
</DialogActions>
</Dialog>
</Tooltip>
</IconButton>
{dialogEnabled
? <Dialog classes={{paper: classes.dialog}} open={open} onClose={this.handleClose} disableBackdropClick disableEscapeKeyDown>
<DialogTitle>Edit the user metadata of {total} entries</DialogTitle>
<DialogContent>
<DialogContentText>
You are editing {total} {total === 1 ? 'entry' : 'entries'}. {total > 1
? 'The fields are pre-filled with data from the first entry for.' : ''
} Only the fields that you change will be updated.
Be aware that all references, co-authors, shared_with, or datasets count as
one field.
</DialogContentText>
<TextField