Commit 7f5f6754 authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Continue to implement the edit user metadata dialog.

parent 20edd70d
......@@ -15,51 +15,17 @@ import ReactJson from 'react-json-view'
import Autosuggest from 'react-autosuggest'
import match from 'autosuggest-highlight/match'
import parse from 'autosuggest-highlight/parse'
import deburr from 'lodash/deburr'
// TODO replace with the actual authors
const suggestions = [
{ label: 'Afghanistan' },
{ label: 'Aland Islands' },
{ label: 'Albania' },
{ label: 'Algeria' },
{ label: 'American Samoa' },
{ label: 'Andorra' },
{ label: 'Angola' },
{ label: 'Anguilla' },
{ label: 'Antarctica' },
{ label: 'Antigua and Barbuda' },
{ label: 'Argentina' },
{ label: 'Armenia' },
{ label: 'Aruba' },
{ label: 'Australia' },
{ label: 'Austria' },
{ label: 'Azerbaijan' },
{ label: 'Bahamas' },
{ label: 'Bahrain' },
{ label: 'Bangladesh' },
{ label: 'Barbados' },
{ label: 'Belarus' },
{ label: 'Belgium' },
{ label: 'Belize' },
{ label: 'Benin' },
{ label: 'Bermuda' },
{ label: 'Bhutan' },
{ label: 'Bolivia, Plurinational State of' },
{ label: 'Bonaire, Sint Eustatius and Saba' },
{ label: 'Bosnia and Herzegovina' },
{ label: 'Botswana' },
{ label: 'Bouvet Island' },
{ label: 'Brazil' },
{ label: 'British Indian Ocean Territory' },
{ label: 'Brunei Darussalam' }
]
class AuthorTextFieldUnstyled extends React.Component {
import { compose } from 'recompose'
import { withApi } from './api'
class SuggestionsTextFieldUnstyled extends React.Component {
static propTypes = {
classes: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired
onChange: PropTypes.func.isRequired,
suggestions: PropTypes.func.isRequired,
suggestionValue: PropTypes.func.isRequired,
suggestionRendered: PropTypes.func.isRequired
}
static styles = theme => ({
......@@ -79,8 +45,37 @@ class AuthorTextFieldUnstyled extends React.Component {
}
})
constructor(props) {
super(props)
this.lastRequestId = null
}
loadSuggestions(value) {
if (this.state.isLoading) {
return
}
if (this.lastRequestId !== null) {
clearTimeout(this.lastRequestId)
}
this.setState({
isLoading: true
})
this.lastRequestId = setTimeout(() => {
this.props.suggestions(value).then(suggestions => {
this.setState({
isLoading: false,
suggestions: suggestions
})
})
}, 1000)
}
state = {
suggestions: [],
isLoading: false,
anchorEl: null
}
......@@ -105,8 +100,9 @@ class AuthorTextFieldUnstyled extends React.Component {
}
renderSuggestion(suggestion, { query, isHighlighted }) {
const matches = match(suggestion.label, query)
const parts = parse(suggestion.label, matches)
suggestion = this.props.suggestionRendered(suggestion)
const matches = match(suggestion, query)
const parts = parse(suggestion, matches)
return (
<MenuItem selected={isHighlighted} component="div">
......@@ -121,35 +117,12 @@ class AuthorTextFieldUnstyled extends React.Component {
)
}
getSuggestions(value) {
const inputValue = deburr(value.trim()).toLowerCase()
const inputLength = inputValue.length
let count = 0
return inputLength === 0
? []
: suggestions.filter(suggestion => {
const keep =
count < 5 && suggestion.label.slice(0, inputLength).toLowerCase() === inputValue
if (keep) {
count += 1
}
return keep
})
}
getSuggestionValue(suggestion) {
return suggestion.label
}
render() {
const { classes, onChange, value, ...props } = this.props
const { suggestions, anchorEl } = this.state
const { classes, onChange, value, suggestions, suggestionValue, suggestionRendered, ...props } = this.props
const { anchorEl } = this.state
const handleSuggestionsFetchRequested = ({ value }) => {
this.setState({suggestions: this.getSuggestions(value)})
this.loadSuggestions(value)
}
const handleSuggestionsClearRequested = () => {
......@@ -162,10 +135,10 @@ class AuthorTextFieldUnstyled extends React.Component {
const autosuggestProps = {
renderInputComponent: this.renderInputComponent.bind(this),
suggestions,
suggestions: this.state.suggestions,
onSuggestionsFetchRequested: handleSuggestionsFetchRequested,
onSuggestionsClearRequested: handleSuggestionsClearRequested,
getSuggestionValue: this.getSuggestionValue.bind(this),
getSuggestionValue: suggestionValue,
renderSuggestion: this.renderSuggestion.bind(this)
}
......@@ -206,7 +179,7 @@ class AuthorTextFieldUnstyled extends React.Component {
}
}
const AuthorTextField = withStyles(AuthorTextFieldUnstyled.styles)(AuthorTextFieldUnstyled)
const SuggestionsTextField = withStyles(SuggestionsTextFieldUnstyled.styles)(SuggestionsTextFieldUnstyled)
var urlPattern = new RegExp('^(https?:\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.?)+[a-z]{2,}|' + // domain name
......@@ -216,7 +189,7 @@ var urlPattern = new RegExp('^(https?:\\/\\/)?' + // protocol
'(\\#[-a-z\\d_]*)?$', 'i') // fragment locator
function isURL(str) {
return urlPattern.test(str)
return str === '' || urlPattern.test(str.trim())
}
class ListTextInputUnstyled extends React.Component {
......@@ -308,55 +281,113 @@ class ListTextInputUnstyled extends React.Component {
const ListTextInput = withStyles(ListTextInputUnstyled.styles)(ListTextInputUnstyled)
class AuthorsListTextInput extends React.Component {
class SuggestionsListTextInput extends React.Component {
render() {
return <ListTextInput component={AuthorTextField} {...this.props} />
return <ListTextInput component={SuggestionsTextField} {...this.props} />
}
}
class EditUserMetadataDialog extends React.Component {
class EditUserMetadataDialogUnstyled extends React.Component {
static propTypes = {
query: PropTypes.object
classes: PropTypes.object.isRequired,
total: PropTypes.number,
example: PropTypes.object,
buttonProps: PropTypes.object,
api: PropTypes.object.isRequired
}
static styles = theme => ({
dialog: {
width: '100%'
}
})
constructor(props) {
super(props)
this.handleButtonClick = this.handleButtonClick.bind(this)
}
state = {
open: false,
comment: 'This is the existing comment and it is very long. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
references: ['http://reference1', 'http://reference2'],
coAuthors: ['Scheidgen, Markus'],
sharedWith: [],
datasets: [],
withEmbargo: true
editData: {
comment: '',
references: [],
coAuthors: [],
sharedWith: [],
datasets: [],
withEmbargo: true
}
}
update() {
const { example } = this.props
const 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),
datasets: (example.datasets || []).map(ds => ds.name),
withEmbargo: example.with_embargo
}
this.setState({editData: editData})
}
componentDidMount() {
this.update()
}
handleChange(key, value) {
this.setState({[key]: value})
componentDidUpdate(prevProps) {
if (prevProps.example.calc_id !== this.props.example.calc_id) {
this.update()
}
}
handleButtonClick() {
const { open } = this.state
if (!open) {
this.update()
}
this.setState({open: !open})
}
render() {
const { query, ...buttonProps } = this.props
const { classes, buttonProps, total, api } = this.props
const { open } = this.state
const close = () => this.setState({open: false})
const handleChange = (key, value) => {
this.setState({editData: {...this.state.editData, [key]: value}})
}
const value = key => this.state.editData[key]
const userSuggestions = query => {
return api.getUsers(query)
.then(result => result.users)
.catch((err) => {
console.log(err)
return []
})
}
return (
<React.Fragment>
<Tooltip title="Edit user metadata">
<IconButton {...buttonProps} onClick={() => this.setState({open: true})}>
<IconButton {...(buttonProps || {})} onClick={this.handleButtonClick}>
<EditIcon />
</IconButton>
</Tooltip>
<Dialog open={open} onClose={close} disableBackdropClick disableEscapeKeyDown>
<DialogTitle>Edit the user metadata of X entries</DialogTitle>
<Dialog classes={{paper: classes.dialog}} open={open} onClose={close} disableBackdropClick disableEscapeKeyDown>
<DialogTitle>Edit the user metadata of {total} entries</DialogTitle>
<DialogContent>
<DialogContentText>
To subscribe to this website, please enter your email address here. We will send updates
occasionally.
TODO better text
</DialogContentText>
<TextField
id="comment"
label="Comment"
value={this.state.comment}
onChange={event => this.handleChange('comment', event.target.value)}
value={value('comment')}
onChange={event => handleChange('comment', event.target.value)}
margin="normal"
multiline
fullWidth
......@@ -366,45 +397,66 @@ class EditUserMetadataDialog extends React.Component {
label="References"
errorLabel="References must be valid URLs"
placeholder="Add a URL reference"
values={this.state.references}
onChange={values => this.handleChange('references', values)}
values={value('references')}
onChange={values => handleChange('references', values)}
validate={isURL}
fullWidth
/>
<AuthorsListTextInput
<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={this.state.coAuthors}
onChange={values => this.handleChange('coAuthors', values)}
values={value('coAuthors')}
onChange={values => handleChange('coAuthors', values)}
fullWidth
/>
<AuthorsListTextInput
<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={this.state.sharedWith}
onChange={values => this.handleChange('sharedWith', values)}
values={value('sharedWith')}
onChange={values => handleChange('sharedWith', values)}
fullWidth
/>
<ListTextInput
<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={this.state.datasets}
onChange={values => this.handleChange('datasets', values)}
values={value('datasets')}
onChange={values => handleChange('datasets', values)}
fullWidth
/>
</DialogContent>
<DialogContent>
<ReactJson src={this.props.query} enableClipboard={false} collapsed={0} />
<ReactJson
src={this.state.editData}
enableClipboard={false}
collapsed={0}
/>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Cancel
</Button>
<Button onClick={close} color="primary">
Subscribe
Submit
</Button>
</DialogActions>
</Dialog>
......@@ -413,4 +465,4 @@ class EditUserMetadataDialog extends React.Component {
}
}
export default EditUserMetadataDialog
export default compose(withApi(false, false), withStyles(EditUserMetadataDialogUnstyled.styles))(EditUserMetadataDialogUnstyled)
......@@ -326,6 +326,33 @@ class Api {
.finally(this.onFinishLoading)
}
async getDatasets(prefix) {
this.onStartLoading()
return this.swagger()
.then(client => client.apis.datasets.list_datasets({prefix: prefix}))
.catch(handleApiError)
.then(response => response.body)
.finally(this.onFinishLoading)
}
async getUsers(query) {
this.onStartLoading()
return this.swagger()
.then(client => client.apis.auth.get_users({query: query}))
.catch(handleApiError)
.then(response => response.body)
.finally(this.onFinishLoading)
}
async quantities_search(search) {
this.onStartLoading()
return this.swagger()
.then(client => client.apis.repo.quantities_search(search))
.catch(handleApiError)
.then(response => response.body)
.finally(this.onFinishLoading)
}
async deleteUpload(uploadId) {
this.onStartLoading()
return this.swagger()
......
......@@ -208,7 +208,7 @@ class EntryListUnstyled extends React.Component {
renderEntryActions(row) {
return <React.Fragment>
<EditUserMetadataDialog query={{calc_id: row.calc_id}}/>
<EditUserMetadataDialog example={row} total={1} />
<Tooltip title="Download raw files">
<IconButton>
<DownloadIcon />
......@@ -225,6 +225,7 @@ class EntryListUnstyled extends React.Component {
render() {
const { classes, data, order, order_by, page, per_page, domain, editable } = this.props
const { results, pagination: { total } } = data
const { selected } = this.state
const columns = {
...domain.searchResultColumns,
......@@ -243,8 +244,12 @@ class EntryListUnstyled extends React.Component {
onChangeRowsPerPage={this.handleChangeRowsPerPage}
/>
const example = selected ? data.results.find(d => d.calc_id === selected[0]) : data.results[0]
const selectActions = editable ? <React.Fragment>
<EditUserMetadataDialog color="primary" query={this.selectionQuery()}/>
<EditUserMetadataDialog
buttonProps={{color: 'primary'}}
example={example} total={total}
/>
<Tooltip title="Download raw files">
<IconButton color="primary">
<DownloadIcon />
......
......@@ -220,6 +220,31 @@ class AuthResource(Resource):
abort(401, 'The authenticated user does not exist')
users_model = api.model('UsersModel', {
'users': fields.Nested(api.model('UserModel', {
'name': fields.String(description='The full name of the user as presented in the UI.'),
'user_id': fields.String(description='The unique user UUID.'),
'email': fields.String(description='The email.')
}))
})
users_parser = api.parser()
users_parser.add_argument(
'query', default='',
help='Only return users that contain this string in their names, usernames, or emails.')
@ns.route('/users')
class UsersResource(Resource):
@api.doc('get_users')
@api.marshal_with(users_model, code=200, description='User suggestions send')
@api.expect(users_parser, validate=True)
def get(self):
args = users_parser.parse_args()
return dict(users=infrastructure.keycloak.search_user(args.get('query')))
def with_signature_token(func):
"""
A decorator for API endpoint implementations that validates signed URLs. Token to
......
......@@ -14,6 +14,7 @@
from flask import request, g
from flask_restplus import Resource, fields, abort
import re
from nomad import utils
from nomad.app.utils import with_logger
......@@ -35,22 +36,32 @@ dataset_list_model = api.model('DatasetList', {
'results': fields.List(fields.Nested(model=dataset_model, skip_none=True))
})
list_datasets_parser = pagination_request_parser.copy()
list_datasets_parser.add_argument('prefix', help='Only return dataset with names that start with prefix.')
@ns.route('/')
class DatasetListResource(Resource):
@api.doc('list_datasets')
@api.marshal_with(dataset_list_model, skip_none=True, code=200, description='Dateset send')
@api.expect(pagination_request_parser)
@api.expect(list_datasets_parser)
@authenticate(required=True)
def get(self):
""" Retrieve a list of all datasets of the authenticated user. """
try:
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 10))
except Exception:
abort(400, message='bad parameter types')
args = {
key: value for key, value in list_datasets_parser.parse_args().items()
if value is not None}
page = args.get('page', 1)
per_page = args.get('per_page', 10)
prefix = args.get('prefix', '')
query_params = dict(user_id=g.user.user_id)
if prefix is not '':
query_params.update(name=re.compile('^%s.*' % prefix))
result_query = DatasetME.objects(**query_params)
result_query = DatasetME.objects(user_id=g.user.user_id)
return dict(
pagination=dict(total=result_query.count(), page=page, per_page=per_page),
results=result_query[(page - 1) * per_page: page * per_page]), 200
......
......@@ -77,7 +77,7 @@ repo_calcs_model = api.model('RepoCalculations', {
'value and quantity value as key. The possible metrics are code runs(calcs), %s. '
'There is a pseudo quantity "total" with a single value "all" that contains the '
' metrics over all results. ' % ', '.join(datamodel.Domain.instance.metrics_names))),
'datasets': fields.Raw(api.model('RepoDatasets', {
'datasets': fields.Nested(api.model('RepoDatasets', {
'after': fields.String(description='The after value that can be used to retrieve the next datasets.'),
'values': fields.Raw(description='A dict with names as key. The values are dicts with "total" and "examples" keys.')
}), skip_none=True)
......@@ -232,29 +232,34 @@ class RepoCalcsResource(Resource):
Ordering is determined by ``order_by`` and ``order`` parameters.
"""
search_request = search.SearchRequest()
add_query(search_request, repo_request_parser.parse_args())
try:
scroll = bool(request.args.get('scroll', False))
scroll_id = request.args.get('scroll_id', None)
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 10 if not scroll else 1000))
order = int(request.args.get('order', -1))
order_by = request.args.get('order_by', 'formula')
if bool(request.args.get('date_histogram', False)):
search_request.date_histogram()
args = {
key: value for key, value in repo_request_parser.parse_args().items()
if value is not None}
scroll = args.get('scroll', False)
scroll_id = args.get('scroll_id', None)
page = args.get('page', 1)
per_page = args.get('per_page', 10 if not scroll else 1000)
order = args.get('order', -1)
order_by = args.get('order_by', 'formula')
date_histogram = args.get('date_histogram', False)
metrics: List[str] = request.args.getlist('metrics')
with_datasets = request.args.get('datasets', False)
with_statistics = request.args.get('statistics', False)
except Exception:
abort(400, message='bad parameter types')