Commit b1593dd2 authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Rewrote the apiV1.js to be more in a functional components react style.

parent ba455f16
Pipeline #101256 passed with stages
in 25 minutes and 30 seconds
......@@ -28,7 +28,6 @@ import { KeycloakProvider } from 'react-keycloak'
import { MuiThemeProvider } from '@material-ui/core/styles'
import { ApiProvider } from './api'
import { ErrorSnacks, ErrorBoundary } from './errors'
import { ApiV1Provider } from './apiV1'
import Navigation from './nav/Navigation'
export const matomo = matomoEnabled ? PiwikReactRouter({
......@@ -55,11 +54,9 @@ export default function App() {
<ErrorSnacks>
<ErrorBoundary>
<RecoilRoot>
<ApiV1Provider>
<ApiProvider>
<Navigation />
</ApiProvider>
</ApiV1Provider>
<ApiProvider>
<Navigation />
</ApiProvider>
</RecoilRoot>
</ErrorBoundary>
</ErrorSnacks>
......
......@@ -15,18 +15,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext, useEffect, useCallback, useRef } from 'react'
import React from 'react'
import PropTypes from 'prop-types'
import { withErrors } from './errors'
import { apiBase } from '../config'
import { Typography, withStyles } from '@material-ui/core'
import { makeStyles, Typography } from '@material-ui/core'
import LoginLogout from './LoginLogout'
import { compose } from 'recompose'
import { withKeycloak } from 'react-keycloak'
import { useKeycloak } from 'react-keycloak'
import axios from 'axios'
export const apiContextV1 = React.createContext()
export class DoesNotExist extends Error {
constructor(msg) {
super(msg)
......@@ -228,236 +224,49 @@ function parse(result) {
}
}
export class ApiProviderComponent extends React.Component {
static propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired,
raiseError: PropTypes.func.isRequired,
keycloak: PropTypes.object.isRequired,
keycloakInitialized: PropTypes.bool
}
constructor(props) {
super(props)
this.onToken = this.onToken.bind(this)
}
onToken(token) {
// console.log(token)
}
update() {
const { keycloak } = this.props
this.setState({apiV1: this.createApi(keycloak)})
if (keycloak.token) {
keycloak.loadUserInfo()
.success(user => {
this.setState({user: user})
})
.error(error => {
this.props.raiseError(error)
})
}
}
componentDidMount() {
this.update()
}
componentDidUpdate(prevProps) {
if (this.props.keycloakInitialized !== prevProps.keycloakInitialized) {
this.update()
}
}
let api = null
createApi(keycloak) {
const api = new Api(keycloak)
return api
}
export function useApi() {
const [keycloak] = useKeycloak()
state = {
apiV1: null
if (!api || api.keycloak !== keycloak) {
api = new Api(keycloak)
}
render() {
const { children } = this.props
return (
<apiContextV1.Provider value={this.state}>
{children}
</apiContextV1.Provider>
)
}
return api
}
class LoginRequiredUnstyled extends React.Component {
static propTypes = {
classes: PropTypes.object.isRequired,
message: PropTypes.string
}
static styles = theme => ({
root: {
display: 'flex',
alignItems: 'center',
padding: theme.spacing(2),
'& p': {
marginRight: theme.spacing(2)
}
}
})
render() {
const {classes, message} = this.props
let loginMessage = ''
if (message) {
loginMessage = <Typography>
{this.props.message}
</Typography>
const useLoginRequiredStyles = makeStyles(theme => ({
root: {
padding: theme.spacing(2),
display: 'flex',
alignItems: 'center',
'& p': {
marginRight: theme.spacing(1)
}
return (
<div className={classes.root}>
<div>
{loginMessage}
</div>
<LoginLogout color="primary" />
</div>
)
}
}
export function DisableOnLoading({children}) {
const containerRef = useRef(null)
const {apiV1} = useContext(apiContextV1)
const handleLoading = useCallback((loading) => {
const enable = loading ? 'none' : ''
containerRef.current.style.pointerEvents = enable
containerRef.current.style.userSelects = enable
}, [])
useEffect(() => {
apiV1.onLoading(handleLoading)
return () => {
apiV1.removeOnLoading(handleLoading)
}
}, [apiV1, handleLoading])
return <div ref={containerRef}>{children}</div>
}
DisableOnLoading.propTypes = {
children: PropTypes.any.isRequired
}
export const ApiV1Provider = compose(withKeycloak, withErrors)(ApiProviderComponent)
const LoginRequired = withStyles(LoginRequiredUnstyled.styles)(LoginRequiredUnstyled)
const __reauthorize_trigger_changes = ['apiV1', 'calcId', 'uploadId', 'calc_id', 'upload_id']
class WithApiComponent extends React.Component {
static propTypes = {
raiseError: PropTypes.func.isRequired,
loginRequired: PropTypes.bool,
showErrorPage: PropTypes.bool,
loginMessage: PropTypes.string,
api: PropTypes.object,
user: PropTypes.object,
Component: PropTypes.any
}
state = {
notAuthorized: false
}
constructor(props) {
super(props)
this.raiseError = this.raiseError.bind(this)
}
componentDidUpdate(prevProps) {
if (__reauthorize_trigger_changes.find(key => this.props[key] !== prevProps[key])) {
this.setState({notAuthorized: false})
}
}
raiseError(error) {
const { raiseError, showErrorPage } = this.props
console.error(error)
if (!showErrorPage) {
raiseError(error)
} else {
if (error.name === 'NotAuthorized') {
this.setState({notAuthorized: true})
} else {
raiseError(error)
}
}
}
render() {
const { raiseError, loginRequired, loginMessage, Component, ...rest } = this.props
const { apiV1, keycloak } = rest
const { notAuthorized } = this.state
if (notAuthorized) {
if (keycloak.authenticated) {
return (
<div style={{marginTop: 24}}>
<Typography variant="h6">Not Authorized</Typography>
<Typography>
You are not authorized to access this information. If someone send
you a link to this data, ask the authors to make the data publicly available
or share it with you.
</Typography>
</div>
)
} else {
return (
<LoginRequired message="You need to be logged in to access this information." />
)
}
} else {
if (apiV1) {
if (keycloak.authenticated || !loginRequired) {
return <Component {...rest} raiseError={this.raiseError} />
} else {
return <LoginRequired message={loginMessage} />
}
} else {
return ''
}
}
}))
export function LoginRequired({message, children}) {
const classes = useLoginRequiredStyles()
const api = useApi()
if (api.keycloak.authenticated) {
return <React.Fragment>
{children}
</React.Fragment>
} else {
return <div className={classes.root}>
<Typography>
{message || 'You have to login to use this functionality.'}
</Typography>
<LoginLogout color="primary" />
</div>
}
}
const WithKeycloakWithApiComponent = withKeycloak(WithApiComponent)
/**
* HOC that will check the API connectivity before rendering a component.
*
* @param {bool} loginRequired Set to true if component should not be displayed
* without login.
* @param {bool} showErrorPage
* @param {string} loginMessage The login message to show.
*/
export function withApiV1(loginRequired, showErrorPage, loginMessage) {
return function(Component) {
return withErrors(props => (
<apiContextV1.Consumer>
{apiContext => (
<WithKeycloakWithApiComponent
loginRequired={loginRequired}
loginMessage={loginMessage}
showErrorPage={showErrorPage}
Component={Component}
{...props} {...apiContext}
/>
)}
</apiContextV1.Consumer>
))
}
LoginRequired.propTypes = {
message: PropTypes.string,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
}
......@@ -21,7 +21,7 @@ import { useRecoilValue } from 'recoil'
import { Box, Card, CardContent, Grid, Typography, Link, makeStyles, Divider } from '@material-ui/core'
import _ from 'lodash'
import { apiContext as apiContextV0 } from '../api'
import { apiContextV1 } from '../apiV1'
import { useApi } from '../apiV1'
import { ApiDialog } from '../ApiDialogButton'
import ElectronicProperties from '../visualization/ElectronicProperties'
import VibrationalProperties from '../visualization/VibrationalProperties'
......@@ -216,7 +216,7 @@ export default function DFTEntryOverview({data}) {
}, [data, hasResults])
const apiV0 = useContext(apiContextV0).api
const apiV1 = useContext(apiContextV1).apiV1
const apiV1 = useApi()
const {raiseError} = useContext(errorContext)
const [dosElectronic, setDosElectronic] = useState(availableProps.has('dos_electronic') ? null : false)
const [bsElectronic, setBsElectronic] = useState(availableProps.has('band_structure_electronic') ? null : false)
......
......@@ -18,17 +18,28 @@
import React from 'react'
import 'regenerator-runtime/runtime'
import { renderWithAPIRouter } from '../../testutils'
import DFTEntryOverview from './DFTEntryOverview'
import { renderWithAPIRouter, archives, wait } from '../../testutils'
import { screen } from '@testing-library/react'
import { waitFor, within } from '@testing-library/dom'
import '@testing-library/jest-dom/extend-expect'
import DFTEntryOverview from './DFTEntryOverview'
import {
repoDftBulk,
repoDftBulkOld,
archiveDftBulk,
archiveDftBulkOld
} from '../../../tests/DFTBulk'
import '@testing-library/jest-dom/extend-expect'
import { useApi } from '../apiV1'
jest.mock('../apiV1')
beforeAll(() => {
useApi.mockReturnValue({
results: entry_id => wait(archives.get(entry_id))
})
})
afterAll(() => jest.unmock('../apiV1'))
async function testMaterialMethod(repo, archive) {
renderWithAPIRouter(
......
......@@ -18,8 +18,7 @@
import React, { useContext, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { domainComponents } from '../domainComponents'
import { compose } from 'recompose'
import { withApiV1 } from '../apiV1'
import { useApi } from '../apiV1'
import { EntryPageContent } from './EntryPage'
import { errorContext } from '../errors'
import { Typography, makeStyles } from '@material-ui/core'
......@@ -47,16 +46,17 @@ const useStyles = makeStyles(theme => ({
/**
* Shows an informative overview about the selected entry.
*/
function OverviewView({uploadId, entryId, apiV1}) {
export default function OverviewView({uploadId, entryId}) {
const classes = useStyles()
const {raiseError} = useContext(errorContext)
const [entry, setEntry] = useState(null)
const [exists, setExists] = useState(true)
const api = useApi()
// When loaded for the first time, download calc data from the ElasticSearch
// index. It is used to decide the subview to show.
useEffect(() => {
apiV1.entry(entryId).then(data => {
api.entry(entryId).then(data => {
setEntry(data)
}).catch(error => {
if (error.name === 'DoesNotExist') {
......@@ -65,7 +65,7 @@ function OverviewView({uploadId, entryId, apiV1}) {
raiseError(error)
}
})
}, [apiV1, raiseError, entryId, setEntry, setExists])
}, [api, raiseError, entryId, setEntry, setExists])
// The entry does not exist
if (!exists) {
......@@ -88,10 +88,5 @@ function OverviewView({uploadId, entryId, apiV1}) {
OverviewView.propTypes = {
uploadId: PropTypes.string.isRequired,
entryId: PropTypes.string.isRequired,
apiV1: PropTypes.object.isRequired
entryId: PropTypes.string.isRequired
}
export default compose(
withApiV1(false, true)
)(OverviewView)
......@@ -17,12 +17,11 @@
*/
import React, { useContext, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { compose } from 'recompose'
import { Typography, makeStyles } from '@material-ui/core'
import { withApiV1 } from '../apiV1'
import { domainComponents } from '../domainComponents'
import { EntryPageContent } from './EntryPage'
import { errorContext } from '../errors'
import { useApi } from '../apiV1'
const useStyles = makeStyles(theme => ({
error: {
......@@ -30,17 +29,18 @@ const useStyles = makeStyles(theme => ({
}
}))
function RawFileView({uploadId, entryId, apiV1}) {
export default function RawFileView({uploadId, entryId}) {
const classes = useStyles()
const {raiseError} = useContext(errorContext)
const [state, setState] = useState({entryData: null, doesNotExist: false})
const api = useApi()
useEffect(() => {
setState({entryData: null, doesNotExist: false})
}, [setState, uploadId, entryId])
useEffect(() => {
apiV1.entry(entryId).then(entry => {
api.entry(entryId).then(entry => {
setState({entryData: entry.data, doesNotExist: false})
}).catch(error => {
if (error.name === 'DoesNotExist') {
......@@ -50,7 +50,7 @@ function RawFileView({uploadId, entryId, apiV1}) {
raiseError(error)
}
})
}, [apiV1, raiseError, entryId, setState])
}, [api, raiseError, entryId, setState])
const entryData = state.entryData || {uploadId: uploadId, entryId: entryId}
const domainComponent = entryData.domain && domainComponents[entryData.domain]
......@@ -72,10 +72,5 @@ function RawFileView({uploadId, entryId, apiV1}) {
RawFileView.propTypes = {
uploadId: PropTypes.string.isRequired,
entryId: PropTypes.string.isRequired,
apiV1: PropTypes.object.isRequired
entryId: PropTypes.string.isRequired
}
export default compose(
withApiV1(false, true)
)(RawFileView)
......@@ -15,7 +15,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {useState, useCallback, useContext, useEffect} from 'react'
import React, {useState, useCallback, useEffect} from 'react'
import PropTypes from 'prop-types'
import {
makeStyles,
......@@ -31,13 +31,13 @@ import {
} from '@material-ui/core'
import DownloadIcon from '@material-ui/icons/CloudDownload'
import { withApi } from '../api'
import { apiContextV1 } from '../apiV1'
import { compose } from 'recompose'
import Download from './Download'
import ReloadIcon from '@material-ui/icons/Cached'
import ViewIcon from '@material-ui/icons/Search'
import InfiniteScroll from 'react-infinite-scroller'
import { ScrollContext } from '../nav/Navigation'
import { useApi } from '../apiV1'
const useStyles = makeStyles(theme => ({
root: {},
......@@ -101,8 +101,7 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
const [files, setFiles] = useState(null)
const [loading, setLoading] = useState(false)
const [doesNotExist, setDoesNotExist] = useState(false)
const c = useContext(apiContextV1)
const apiv1 = c.api
const apiv1 = useApi()
useEffect(() => {
setSelectedFiles([])
......
/*
* 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 from 'react'
import PropTypes, { instanceOf } from 'prop-types'
import Markdown from '../Markdown'
import { withStyles, Paper, IconButton, FormGroup, FormLabel, Tooltip, Typography, Link } from '@material-ui/core'
import UploadIcon from '@material-ui/icons/CloudUpload'
import Dropzone from 'react-dropzone'
import Upload from './Upload'
import { compose } from 'recompose'
import ReloadIcon from '@material-ui/icons/Cached'
import MoreIcon from '@material-ui/icons/MoreHoriz'
import ClipboardIcon from '@material-ui/icons/Assignment'
import HelpDialog from '../Help'
import { withApi } from '../api'
import { withCookies, Cookies } from 'react-cookie'
import Pagination from 'material-ui-flat-pagination'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import { guiBase, appBase } from '../../config'
import qs from 'qs'
import { CodeList } from '../About'
export const help = `
NOMAD allows you to upload data. After upload, NOMAD will process your data: it will
identify the main output files of supported codes.
and then it will parse these files. The result will be a list of entries (one per each identified mainfile).
Each entry is associated with metadata. This is data that NOMAD acquired from your files and that
describe your calculations (e.g. chemical formula, used code, system type and symmetry, etc.).
Furthermore, you can provide your own metadata (comments, references, co-authors, etc.).
At first, uploaded data is only visible to you. Before others can actually see and download
your data, you need to publish your upload.
#### Prepare and upload files
Please put all the relevant files of all the calculations
you want to upload into a single \`*.zip\` or \`*.tar.gz\` archive.
We encourage you to add all code input and
output files, as well as any other auxiliary files that you might have created.
You can put data from multiple calculations into one file using as many directories as
you like. NOMAD will consider all files on a single directory to form a single entry.
Ideally, you put only files related to a single code run into each directory. If users
want to download an entry, they can download all files in the respective directory.
The directory structure can be nested.
Drop your archive file(s) on the dropbox. You can also click the dropbox to select the file from
your hard drive. Alternatively, you can upload files via the given shell command.
Replace \`<local_file>\` with your archive file. After executing the command,
return here and press the reload button below).
There is a limit of 10 unpublished uploads per user. Please accumulate all data into as
few uploads as possible. But, there is a also an upper limit of 32 GB per upload.
Please upload multiple archives, if you have more than 32 GB of data to upload.
#### The staging area
Uploaded data will not be public immediately. Below you will find all your unpublished and
published uploads. The unpublished uploads are only visible to you. You can see the
progress on the processing, you can review your uploads, and publish or delete them again.
Click on an upload to see more details about its contents. Click on processed calculations
to see their metadata, archive data, and a processing log. In the details view, you also
find buttons for editing user metadata, deleting uploads, and publishing uploads. Only
full uploads can be deleted or published.
#### Publishing and embargo
If you press publish, a dialog will appear that allows you to set an
*embargo* or publish your data as *Open Access* right away. The *embargo* allows you to share
data with selected users, create a DOI for your data, and later publish the data.
The *embargo* might last up to 36 month before data becomes public automatically.
During an *embargo* the data (and datasets created from this data) are already visible and
findable, but only you and users you share the data with (i.e. users you added under
*share with* when editing entries) can view and download the raw-data and archive.
#### Processing errors
We distinguish between uploads that fail processing completely and uploads that contain
entries that could not be processed. The former might be caused by issues during the