From 7b8324f78b8b0a1997497245e3df7de32e8f2f90 Mon Sep 17 00:00:00 2001 From: Mingfei Shao <2475897+mfshao@users.noreply.github.com> Date: Mon, 28 Sep 2020 22:22:27 -0500 Subject: [PATCH] PXP-6562 Feat/study viewer (#728) * rebase * study viewer * dont crash homepage * access * fix logic * requestor redir * fix title * fetch doc data * fix trigger * object file download * fix button logic * requset error handle * fix: useGuppyForExplorer * fix lgtm * doc * fix doc * remove dar dau * datasets * use accessibleValidationValue * read-storage only * no default for title * better gql query generation * openMode and defaultOpenTitle * sv config as array * doc * put projectIsOpenData back * fix: reset to trigger re-fetch * error msg * more fix about array of configs * hide download button if no file * fix crash for table items * link in table * link in table * fix: don't flood guppy if request fails * fix: filedmapping for ssv * fix file optional * hide help msg when needed * update doc * clean up * fix login redirect flow * disable request access btn when needed * check access before sends out * batch request * clean --- docs/study_viewer.md | 47 ++++ src/Login/Login.jsx | 4 + src/StudyViewer/SingleStudyViewer.jsx | 133 +++++++++++ src/StudyViewer/StudyCard.jsx | 75 +++++++ src/StudyViewer/StudyDetails.jsx | 312 ++++++++++++++++++++++++++ src/StudyViewer/StudyViewer.css | 58 +++++ src/StudyViewer/StudyViewer.jsx | 118 ++++++++++ src/StudyViewer/reducers.js | 26 +++ src/StudyViewer/reduxer.js | 251 +++++++++++++++++++++ src/authMappingUtils.js | 8 +- src/components/Introduction.jsx | 18 +- src/index.jsx | 23 ++ src/localconf.js | 18 ++ src/reducers.js | 5 +- 14 files changed, 1084 insertions(+), 12 deletions(-) create mode 100644 docs/study_viewer.md create mode 100644 src/StudyViewer/SingleStudyViewer.jsx create mode 100644 src/StudyViewer/StudyCard.jsx create mode 100644 src/StudyViewer/StudyDetails.jsx create mode 100644 src/StudyViewer/StudyViewer.css create mode 100644 src/StudyViewer/StudyViewer.jsx create mode 100644 src/StudyViewer/reducers.js create mode 100644 src/StudyViewer/reduxer.js diff --git a/docs/study_viewer.md b/docs/study_viewer.md new file mode 100644 index 0000000000..ff9f5efb9a --- /dev/null +++ b/docs/study_viewer.md @@ -0,0 +1,47 @@ +# Study Viewer + +Example configuration: + +``` +[ + { + "dataType": "dataset", + "title": "Datasets", // page title + "titleField": "name", // row title + "listItemConfig": { // required + // displayed outside of table: + "blockFields": ["short_description"], + // displayed in table: + "tableFields": ["condition", ...], + }, + "singleItemConfig": { //optional, if omitted, "listItemConfig" block will be used for both pages + // displayed outside of table: + "blockFields": ["long_description"], + // displayed in table: + "tableFields": ["condition", ...], + }, + "fieldMapping": [...], + "rowAccessor": "project_id", // rows unique ID + "downloadField": "object_id", // GUID + "fileDataType": "clinicalTrialFile", // ES index of the clinical trial object files, optional + "docDataType": "openAccessFile", // ES index of the open access documents, optional + "openMode": "open-first", // optional, configure how the study viewer list do with each collapsible card on initial loading, see details in notes + "openFirstRowAccessor": "", // optional, only works if `openMode` is `open-first` + }, + { + ....another study viewer config + } +] +``` + +## Notes + +1. The configuration above is subject to change. After `Tube` supports generating nested ES document then we can remove the `fileDataType` and `docDataType` fields. +2. Required fields for `fileData` and `docData` ES indices are: `file_name`, `file_size`, `data_format` and `data_type`. For `fileData`, additional required field is `object_id`; and for `docData`, `doc_url` is also required. +3. The field `rowAccessor` should be a field that exists in all 3 ES indices. The study viewer will use that field to cross query with different ES indices. +4. About `openMode` and `openFirstRowAccessor`, the list view of study browser supports 3 display modes on initial loading: + - `open-all`: opens all collapsible cards by default. And this is the default option if `openMode` is omitted in the config + - `close-all`: closes all collapsible cards by default + - `open-first`: opens the first collapsible card in the list and keeps all other cards closing + - When in `open-first` mode, user can specify a value using `openFirstRowAccessor`. The study viewer will try to find a study with that title in the list and bring it to the top of the list in order to open it. +5. The access request logic depends on `Requestor`, for more info, see [Requestor](https://github.com/uc-cdis/requestor/). diff --git a/src/Login/Login.jsx b/src/Login/Login.jsx index 3799b36d8d..e0ed787ed6 100644 --- a/src/Login/Login.jsx +++ b/src/Login/Login.jsx @@ -55,12 +55,16 @@ class Login extends React.Component { const location = this.props.location; // this is the react-router "location" // compose next according to location.from let next = (location.from) ? `${basename}${location.from}` : basename; + if (location.state && location.state.from) { + next = `${basename}${location.state.from}`; + } // clean up url: no double slashes next = next.replace(/\/+/g, '/'); const queryParams = querystring.parse(location.search ? location.search.replace(/^\?+/, '') : ''); if (queryParams.next) { next = basename === '/' ? queryParams.next : basename + queryParams.next; } + next = next.replace('?request_access', '?request_access_logged_in'); const customImage = components.login && components.login.image ? components.login.image : 'gene'; diff --git a/src/StudyViewer/SingleStudyViewer.jsx b/src/StudyViewer/SingleStudyViewer.jsx new file mode 100644 index 0000000000..fcd8b5f0d8 --- /dev/null +++ b/src/StudyViewer/SingleStudyViewer.jsx @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { Space, Typography, Spin, Result } from 'antd'; +import { FileOutlined, FilePdfOutlined } from '@ant-design/icons'; +import BackLink from '../components/BackLink'; +import { humanFileSize } from '../utils.js'; +import { ReduxStudyDetails, fetchDataset, fetchFiles, resetMultipleStudyData, fetchStudyViewerConfig } from './reduxer'; +import getReduxStore from '../reduxStore'; +import './StudyViewer.css'; + +const { Title } = Typography; + +class SingleStudyViewer extends React.Component { + constructor(props) { + super(props); + this.state = { + dataType: undefined, + rowAccessor: undefined, + }; + } + + static getDerivedStateFromProps(nextProps, prevState) { + const newState = {}; + if (nextProps.match.params.dataType + && nextProps.match.params.dataType !== prevState.dataType) { + newState.dataType = nextProps.match.params.dataType; + } + if (nextProps.match.params.rowAccessor + && nextProps.match.params.rowAccessor !== prevState.rowAccessor) { + newState.rowAccessor = nextProps.match.params.rowAccessor; + } + return Object.keys(newState).length ? newState : null; + } + + render() { + if (this.props.noConfigError) { + this.props.history.push('/not-found'); + } + if (!this.props.dataset) { + if (this.state.dataType && this.state.rowAccessor) { + getReduxStore().then( + store => + Promise.allSettled( + [ + store.dispatch(fetchDataset(decodeURIComponent(this.state.dataType), + decodeURIComponent(this.state.rowAccessor))), + store.dispatch(fetchFiles(decodeURIComponent(this.state.dataType), 'object', decodeURIComponent(this.state.rowAccessor))), + store.dispatch(fetchFiles(decodeURIComponent(this.state.dataType), 'open-access', decodeURIComponent(this.state.rowAccessor))), + store.dispatch(resetMultipleStudyData()), + ], + )); + } + return ( +
+
+ +
+
+ ); + } + + const studyViewerConfig = fetchStudyViewerConfig(this.state.dataType); + const dataset = this.props.dataset; + const backURL = this.props.location.pathname.substring(0, this.props.location.pathname.lastIndexOf('/')); + if (_.isEmpty(dataset)) { + return ( +
+ + +
+ ); + } + return ( +
+ + +
+ {dataset.title} +
+
+ +
+ + {(this.props.docData.length > 0) ? +
+ +
Study Documents
+ {this.props.docData.map((doc) => { + const iconComponent = (doc.data_format === 'PDF') ? : ; + const linkText = `${doc.file_name} (${doc.data_format} - ${humanFileSize(doc.file_size)})`; + const linkComponent = {linkText}; + return (
+ {iconComponent} + {linkComponent} +
); + })} +
+
+ : null + } +
+
+
+
+
+ ); + } +} + +SingleStudyViewer.propTypes = { + dataset: PropTypes.object, + docData: PropTypes.array, + fileData: PropTypes.array, + noConfigError: PropTypes.string, + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, +}; + +SingleStudyViewer.defaultProps = { + dataset: undefined, + docData: [], + fileData: [], + noConfigError: undefined, +}; + +export default SingleStudyViewer; diff --git a/src/StudyViewer/StudyCard.jsx b/src/StudyViewer/StudyCard.jsx new file mode 100644 index 0000000000..e67220e5b8 --- /dev/null +++ b/src/StudyViewer/StudyCard.jsx @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Card, Collapse } from 'antd'; +import { PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons'; +import { ReduxStudyDetails } from './reduxer'; +import './StudyViewer.css'; + +const { Panel } = Collapse; + +class StudyCard extends React.Component { + constructor(props) { + super(props); + this.state = { + panelExpanded: props.initialPanelExpandStatus, + }; + } + + onCollapseChange = () => { + this.setState(prevState => ({ + panelExpanded: !prevState.panelExpanded, + })); + }; + + render() { + return ( + + + ((isActive) ? : )} + onChange={this.onCollapseChange} + ghost + > + + + + + + ); + } +} + +StudyCard.propTypes = { + data: PropTypes.shape({ + accessRequested: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + rowAccessorValue: PropTypes.string.isRequired, + blockData: PropTypes.object, + tableData: PropTypes.object, + accessibleValidationValue: PropTypes.string, + fileData: PropTypes.array, + docData: PropTypes.array, + }).isRequired, + fileData: PropTypes.array, + studyViewerConfig: PropTypes.object, + initialPanelExpandStatus: PropTypes.bool.isRequired, +}; + +StudyCard.defaultProps = { + fileData: [], + studyViewerConfig: {}, +}; + +export default StudyCard; diff --git a/src/StudyViewer/StudyDetails.jsx b/src/StudyViewer/StudyDetails.jsx new file mode 100644 index 0000000000..6c726e2233 --- /dev/null +++ b/src/StudyViewer/StudyDetails.jsx @@ -0,0 +1,312 @@ +/* eslint-disable max-len */ +import React from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { Space, Typography, Descriptions, message, Divider, Alert, Modal, List } from 'antd'; +import Button from '@gen3/ui-component/dist/components/Button'; +import { + // for nested docs maybe + // FileOutlined, + // FilePdfOutlined, + LinkOutlined } from '@ant-design/icons'; +import { capitalizeFirstLetter, humanFileSize } from '../utils'; +import { userHasMethodOnResource } from '../authMappingUtils'; +import { useArboristUI, requestorPath, userapiPath } from '../localconf'; +import { fetchWithCreds } from '../actions'; +import './StudyViewer.css'; + +const { Paragraph } = Typography; + +// small helper to check if a given string is a valid URL by using URL() +const stringIsAValidUrl = (s) => { + try { + // eslint-disable-next-line no-new + new URL(s); + return true; + } catch (err) { + return false; + } +}; + +class StudyDetails extends React.Component { + constructor(props) { + super(props); + this.state = { + downloadModalVisible: false, + }; + } + + componentDidUpdate() { + // check if user is not logged in by looking at the user props + // note that we only need to redirect user to /login if the search param is `?request_access` + // `?request_access` means user got here by clicking the `Request Access` button + // and `?request_access_logged_in` means user got here by redirecting from the login page + // in that case, don't redirect user again, just wait for user props to update + if ((!this.props.user || !this.props.user.username) + && this.props.location.search + && this.props.location.search === '?request_access') { + this.props.history.push('/login', { from: `${this.props.location.pathname}?request_access` }); + } else if (this.props.user + && this.props.user.username + && this.props.location.search + && this.props.location.search.includes('?request_access')) { + // if we still have either `?request_access` or `?request_access_logged_in` + // it means we haven't finished check yet + // next is to check if user has access to the resource + if (!this.isDataAccessible(this.props.data.accessibleValidationValue)) { + // if the user haven't have a request in `SUBMITTED` state for this resource yet + if (!this.props.data.accessRequested) { + const body = { + username: this.props.user.username, + resource_path: this.props.data.accessibleValidationValue, + resource_id: this.props.data.rowAccessorValue, + resource_display_name: this.props.data.title, + }; + fetchWithCreds({ + path: `${requestorPath}request`, + method: 'POST', + body: JSON.stringify(body), + }).then( + ({ data, status }) => { + if (status === 201) { + // if a redirect is configured, Requestor returns a redirect URL + message + .success('A request has been sent', 3); + if (data && data.redirect_url) { + window.open(data.redirect_url); + } + } else { + message + .error(`Something went wrong when talking to Requestor service, status ${status}`, 3); + } + }, + ); + } + } + // we are done here, remove the query string from URL + this.props.history.push(`${this.props.location.pathname}`, { from: this.props.location.pathname }); + } + } + + getLabel = (label) => { + if (!this.props.studyViewerConfig.fieldMapping + || this.props.studyViewerConfig.fieldMapping.length === 0) { + return capitalizeFirstLetter(label); + } + const fieldMappingEntry = this.props.studyViewerConfig.fieldMapping + .find(i => i.field === label); + if (fieldMappingEntry) { + return fieldMappingEntry.name; + } + return capitalizeFirstLetter(label); + }; + + showDownloadModal = () => { + this.setState({ + downloadModalVisible: true, + }); + }; + + handleOk = () => { + this.setState({ + downloadModalVisible: false, + }); + }; + + handleCancel = () => { + this.setState({ + downloadModalVisible: false, + }); + }; + + isDataAccessible = (accessibleValidationValue) => { + if (!useArboristUI) { + return true; + } + if (!accessibleValidationValue) { + return false; + } + return (userHasMethodOnResource('read-storage', accessibleValidationValue, this.props.userAuthMapping)); + }; + + render() { + const onRequestAccess = () => this.props.history.push(`${this.props.location.pathname}?request_access`, { from: this.props.location.pathname }); + const userHasLoggedIn = !!this.props.user.username; + + const displayDownloadButton = userHasLoggedIn + && this.isDataAccessible(this.props.data.accessibleValidationValue) + && this.props.fileData.length > 0; + const downloadButtonFunc = this.showDownloadModal; + + const displayRequestAccessButton = !userHasLoggedIn + || !this.isDataAccessible(this.props.data.accessibleValidationValue); + + return ( +
+ + { (this.props.displayLearnMoreBtn + || displayDownloadButton + || displayRequestAccessButton) ? + ( + {(this.props.displayLearnMoreBtn) ? +
+ ); + } +} + +StudyDetails.propTypes = { + data: PropTypes.shape({ + accessRequested: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + rowAccessorValue: PropTypes.string.isRequired, + blockData: PropTypes.object, + tableData: PropTypes.object, + accessibleValidationValue: PropTypes.string, + }).isRequired, + fileData: PropTypes.arrayOf( + PropTypes.shape({ + object_id: PropTypes.string.isRequired, + file_name: PropTypes.string.isRequired, + file_size: PropTypes.number, + data_format: PropTypes.string, + })), + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + displayLearnMoreBtn: PropTypes.bool, + userAuthMapping: PropTypes.object.isRequired, + studyViewerConfig: PropTypes.object, +}; + +StudyDetails.defaultProps = { + displayLearnMoreBtn: false, + fileData: [], + studyViewerConfig: {}, +}; + +export default StudyDetails; diff --git a/src/StudyViewer/StudyViewer.css b/src/StudyViewer/StudyViewer.css new file mode 100644 index 0000000000..b2bf012afa --- /dev/null +++ b/src/StudyViewer/StudyViewer.css @@ -0,0 +1,58 @@ +.study-viewer { + width: 100%; + padding: 40px 20px; +} + +.study-viewer_loading { + display: flex; + justify-content: center; +} + +.study-viewer__title { + padding-top: 10px; + flex-basis: 460px; +} + +.study-viewer__space { + width: 100%; +} + +.study-viewer__card { + width: 100%; + border: 1px solid black; +} + +.study-details__descriptions { + border: 1px solid black; +} + +.study-viewer__details { + display: flex; + width: 100%; +} + +.study-viewer__details-sidebar { + width: 30%; + margin-left: 10px; + display: flex; + align-items: center; +} + +.study-viewer__details-sidebar-box { + height: fit-content; + border: 1px solid black; +} + +.study-viewer__details-sidebar-space { + width: 100%; + padding: 10px; +} + +.study-details { + width: 100%; +} + +/* temp fix, there is an official fix coming up soon https://github.com/ant-design/ant-design/pull/26721 */ +.ant-space-item:empty { + display: none; +} diff --git a/src/StudyViewer/StudyViewer.jsx b/src/StudyViewer/StudyViewer.jsx new file mode 100644 index 0000000000..6c2a3c7f9d --- /dev/null +++ b/src/StudyViewer/StudyViewer.jsx @@ -0,0 +1,118 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Space, Spin, Result } from 'antd'; +import getReduxStore from '../reduxStore'; +import { fetchDataset, fetchFiles, resetSingleStudyData, fetchStudyViewerConfig } from './reduxer'; +import './StudyViewer.css'; +import StudyCard from './StudyCard'; + + +class StudyViewer extends React.Component { + constructor(props) { + super(props); + this.state = { + dataType: undefined, + }; + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.match.params.dataType && nextProps.match.params.dataType !== prevState.dataType) { + return { dataType: nextProps.match.params.dataType }; + } + return null; + } + + getPanelExpandStatus = (openMode, index) => { + if (openMode === 'open-all') { + return true; + } else if (openMode === 'close-all') { + return false; + } + return (index === 0); + } + + render() { + if (this.props.noConfigError) { + this.props.history.push('/not-found'); + } + + if (!this.props.datasets) { + if (this.state.dataType) { + getReduxStore().then( + store => + Promise.allSettled( + [ + store.dispatch(fetchDataset(decodeURIComponent(this.state.dataType))), + store.dispatch(fetchFiles(decodeURIComponent(this.state.dataType), 'object')), + store.dispatch(resetSingleStudyData()), + ], + )); + } + return ( +
+
+ +
+
+ ); + } + + const studyViewerConfig = fetchStudyViewerConfig(this.state.dataType); + const datasets = this.props.datasets; + if (datasets.length === 0) { + return ( +
+ +
+ ); + } + if (datasets.length > 0 + && studyViewerConfig.openMode === 'open-first' + && studyViewerConfig.openFirstRowAccessor !== '') { + datasets.forEach((item, i) => { + if (item.rowAccessorValue === studyViewerConfig.openFirstRowAccessor) { + datasets.splice(i, 1); + datasets.unshift(item); + } + }); + } + + return ( +
+
+ {studyViewerConfig.title} +
+ {(datasets.length > 0) ? + + {(datasets.map((d, i) => + ( fd.rowAccessorValue === d.rowAccessorValue)} + studyViewerConfig={studyViewerConfig} + initialPanelExpandStatus={this.getPanelExpandStatus(studyViewerConfig.openMode, i)} + />)))} + + : null} +
+ ); + } +} + +StudyViewer.propTypes = { + datasets: PropTypes.array, + fileData: PropTypes.array, + noConfigError: PropTypes.string, + history: PropTypes.object.isRequired, +}; + +StudyViewer.defaultProps = { + datasets: undefined, + fileData: [], + noConfigError: undefined, +}; + +export default StudyViewer; diff --git a/src/StudyViewer/reducers.js b/src/StudyViewer/reducers.js new file mode 100644 index 0000000000..2b0c150ef3 --- /dev/null +++ b/src/StudyViewer/reducers.js @@ -0,0 +1,26 @@ +const study = (state = {}, action) => { + switch (action.type) { + case 'RECEIVE_STUDY_DATASET_LIST': + return { ...state, datasets: action.datasets }; + case 'RECEIVE_SINGLE_STUDY_DATASET': + return { ...state, dataset: action.datasets[0] }; + case 'STUDY_DATASET_ERROR': + return { ...state, error: action.error, dataset: {}, datasets: [] }; + case 'RECEIVE_OPEN_DOC_DATA': + return { ...state, docData: action.fileData }; + case 'RECEIVE_OBJECT_FILE_DATA': + return { ...state, fileData: action.fileData }; + case 'FILE_DATA_ERROR': + return { ...state, fileError: action.error }; + case 'NO_CONFIG_ERROR': + return { ...state, noConfigError: action.error }; + case 'RESET_SINGLE_STUDY_DATA': + return { ...state, dataset: undefined }; + case 'RESET_MULTIPLE_STUDY_DATA': + return { ...state, datasets: undefined }; + default: + return state; + } +}; + +export default study; diff --git a/src/StudyViewer/reduxer.js b/src/StudyViewer/reduxer.js new file mode 100644 index 0000000000..a1f8ddd703 --- /dev/null +++ b/src/StudyViewer/reduxer.js @@ -0,0 +1,251 @@ +import { connect } from 'react-redux'; +import _ from 'lodash'; +import { withRouter } from 'react-router-dom'; +import StudyDetails from './StudyDetails'; +import StudyViewer from './StudyViewer'; +import SingleStudyViewer from './SingleStudyViewer'; +import { guppyGraphQLUrl, studyViewerConfig, requestorPath } from '../localconf'; +import { fetchWithCreds } from '../actions'; + +const generateGQLQuery = (nameOfIndex, fieldsToFetch, rowAccessorField, rowAccessorValue) => { + const query = `query ($filter: JSON) { + ${nameOfIndex} (filter: $filter, first: 10000, accessibility: accessible) { + ${fieldsToFetch.join(',')} + } + }`; + const variables = { + filter: {}, + }; + if (rowAccessorValue) { + variables.filter.in = { + [rowAccessorField]: [rowAccessorValue], + }; + } + return { query, variables }; +}; + +export const fetchStudyViewerConfig = + dataType => studyViewerConfig.find(svc => svc.dataType === dataType); + +export const fetchFiles = (dataType, typeOfFileIndex, rowAccessorValue) => { + const targetStudyViewerConfig = fetchStudyViewerConfig(dataType); + if (!targetStudyViewerConfig) { + return dispatch => dispatch({ + type: 'NO_CONFIG_ERROR', + error: `No study viewer config for ${dataType} has been found`, + }); + } + let nameOfIndex; + const fieldsToFetch = ['file_name', 'file_size', 'data_format', 'data_type', targetStudyViewerConfig.rowAccessor]; + switch (typeOfFileIndex) { + case 'object': + nameOfIndex = targetStudyViewerConfig.fileDataType; + fieldsToFetch.push('object_id'); + break; + case 'open-access': + nameOfIndex = targetStudyViewerConfig.docDataType; + fieldsToFetch.push('doc_url'); + break; + default: + return dispatch => dispatch({ + type: 'FILE_DATA_ERROR', + error: 'typeOfFileIndex error', + }); + } + + if (!nameOfIndex) { + return dispatch => dispatch({ + type: 'FILE_DATA_ERROR', + error: `No index specified for file type: ${typeOfFileIndex}`, + }); + } + + const body = generateGQLQuery( + nameOfIndex, + fieldsToFetch, + targetStudyViewerConfig.rowAccessor, + rowAccessorValue); + return dispatch => fetchWithCreds({ + path: guppyGraphQLUrl, + method: 'POST', + body: JSON.stringify(body), + dispatch, + }).then(({ status, data }) => { + switch (status) { + case 200: + if (data.data && data.data[nameOfIndex]) { + const receivedFileData = data.data[nameOfIndex] + .map(d => ({ ...d, rowAccessorValue: d[targetStudyViewerConfig.rowAccessor] })); + return { + type: (typeOfFileIndex === 'object') ? 'RECEIVE_OBJECT_FILE_DATA' : 'RECEIVE_OPEN_DOC_DATA', + fileData: receivedFileData, + }; + } + return { + type: 'FILE_DATA_ERROR', + error: 'Did not get correct data from Guppy', + }; + default: + return { + type: 'FILE_DATA_ERROR', + error: data, + }; + } + }) + .then(msg => dispatch(msg)); +}; + +const fetchRequestedAccess = (receivedData) => { + const accessibleValidationValueArray = receivedData.map(d => d.auth_resource_path); + const body = { + resource_paths: accessibleValidationValueArray, + }; + return fetchWithCreds({ + path: `${requestorPath}request/user_resource_paths`, + method: 'POST', + body: JSON.stringify(body), + }).then( + ({ data }) => data, + ); +}; + +const processDataset = (nameOfIndex, receivedData, itemConfig) => { + const targetStudyViewerConfig = fetchStudyViewerConfig(nameOfIndex); + const processedDataset = []; + if (receivedData) { + return fetchRequestedAccess(receivedData).then( + (requestedAccess) => { + receivedData.forEach((dataElement) => { + const processedItem = {}; + processedItem.title = dataElement[targetStudyViewerConfig.titleField]; + processedItem.rowAccessorValue = dataElement[targetStudyViewerConfig.rowAccessor]; + processedItem.blockData = _.pick(dataElement, itemConfig.blockFields); + processedItem.tableData = _.pick(dataElement, itemConfig.tableFields); + processedItem.accessibleValidationValue = dataElement.auth_resource_path; + processedItem.accessRequested = !!(requestedAccess + && requestedAccess[dataElement.auth_resource_path]); + processedDataset.push(processedItem); + }); + }, + ).then(() => processedDataset); + } + return processedDataset; +}; + +export const fetchDataset = (dataType, rowAccessorValue) => { + const targetStudyViewerConfig = fetchStudyViewerConfig(dataType); + if (!targetStudyViewerConfig) { + return dispatch => dispatch({ + type: 'NO_CONFIG_ERROR', + error: `No study viewer config for ${dataType} has been found`, + }); + } + let itemConfig = targetStudyViewerConfig.listItemConfig; + if (rowAccessorValue && targetStudyViewerConfig.singleItemConfig) { + itemConfig = targetStudyViewerConfig.singleItemConfig; + } + + if (!itemConfig) { + return dispatch => dispatch({ + type: 'STUDY_DATASET_ERROR', + error: 'itemConfig error', + }); + } + + let fieldsToFetch = []; + fieldsToFetch.push('auth_resource_path'); + fieldsToFetch.push(targetStudyViewerConfig.titleField); + fieldsToFetch.push(targetStudyViewerConfig.rowAccessor); + fieldsToFetch = [...fieldsToFetch, + ...itemConfig.blockFields, + ...itemConfig.tableFields]; + fieldsToFetch = _.uniq(fieldsToFetch); + + const body = generateGQLQuery( + dataType, + fieldsToFetch, + targetStudyViewerConfig.rowAccessor, + rowAccessorValue); + return dispatch => + fetchWithCreds({ + path: guppyGraphQLUrl, + method: 'POST', + body: JSON.stringify(body), + dispatch, + }) + .then(({ status, data }) => { + switch (status) { + case 200: + if (data.data && data.data[dataType]) { + if (rowAccessorValue) { + return processDataset( + dataType, + data.data[dataType], + itemConfig).then(pd => ({ + type: 'RECEIVE_SINGLE_STUDY_DATASET', + datasets: pd, + })).then(msg => msg); + } + return processDataset( + dataType, + data.data[dataType], + itemConfig).then(pd => ({ + type: 'RECEIVE_STUDY_DATASET_LIST', + datasets: pd, + })).then(msg => msg); + } + return { + type: 'STUDY_DATASET_ERROR', + error: 'Did not get correct data from Guppy', + }; + default: + return { + type: 'STUDY_DATASET_ERROR', + error: (data && data.errors && data.errors[0] && data.errors[0].message) ? data.errors[0].message : 'Did not get correct data from Guppy', + }; + } + }) + .then((msg) => { + dispatch(msg); + }); +}; + +export const resetSingleStudyData = () => dispatch => dispatch({ + type: 'RESET_SINGLE_STUDY_DATA', +}); + +export const resetMultipleStudyData = () => dispatch => dispatch({ + type: 'RESET_MULTIPLE_STUDY_DATA', +}); + +export const ReduxStudyDetails = (() => { + const mapStateToProps = state => ({ + user: state.user, + userAuthMapping: state.userAuthMapping, + }); + + return withRouter(connect(mapStateToProps)(StudyDetails)); +})(); + + +export const ReduxStudyViewer = (() => { + const mapStateToProps = state => ({ + datasets: state.study.datasets, + docData: state.study.docData, + fileData: state.study.fileData, + noConfigError: state.study.noConfigError, + }); + + return withRouter(connect(mapStateToProps)(StudyViewer)); +})(); + +export const ReduxSingleStudyViewer = (() => { + const mapStateToProps = state => ({ + dataset: state.study.dataset, + docData: state.study.docData, + fileData: state.study.fileData, + noConfigError: state.study.noConfigError, + }); + + return withRouter(connect(mapStateToProps)(SingleStudyViewer)); +})(); diff --git a/src/authMappingUtils.js b/src/authMappingUtils.js index 62e3e96379..f6ca2db7c6 100644 --- a/src/authMappingUtils.js +++ b/src/authMappingUtils.js @@ -59,15 +59,17 @@ export const userHasDataUpload = (userAuthMapping = {}) => { return resource !== undefined && resource.some(actionIsFileUpload); }; +export const userHasMethodOnResource = (method, resourcePath, userAuthMapping = {}) => { + const actions = userAuthMapping[resourcePath]; + return actions !== undefined && actions.some(x => x.method === method); +}; export const userHasMethodOnProject = (method, projectID, userAuthMapping = {}) => { // method should be a string e.g. 'create' const resourcePath = resourcePathFromProjectID(projectID); - const actions = userAuthMapping[resourcePath]; - return actions !== undefined && actions.some(x => x.method === method); + return userHasMethodOnResource(method, resourcePath, userAuthMapping); }; - export const userHasMethodOnAnyProject = (method, userAuthMapping = {}) => { // method should be a string e.g. 'create' const actionHasMethod = x => (x.method === method); diff --git a/src/components/Introduction.jsx b/src/components/Introduction.jsx index f01fd42f06..beea694ec4 100644 --- a/src/components/Introduction.jsx +++ b/src/components/Introduction.jsx @@ -29,14 +29,16 @@ class Introduction extends Component {
{this.props.data.text}
- + {(this.props.data.link) ? + () + : null} ); } diff --git a/src/index.jsx b/src/index.jsx index 1e4b4a4555..068a87bc73 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -47,6 +47,7 @@ import sessionMonitor from './SessionMonitor'; import Workspace from './Workspace'; import ResourceBrowser from './ResourceBrowser'; import ErrorWorkspacePlaceholder from './Workspace/ErrorWorkspacePlaceholder'; +import { ReduxStudyViewer, ReduxSingleStudyViewer } from './StudyViewer/reduxer'; import './index.less'; import NotFound from './components/NotFound'; @@ -359,6 +360,28 @@ async function init() { /> : null } + () + } + /> + () + } + /> { + if (cfg.openMode + && !validOpenOptions.includes(cfg.openMode)) { + studyViewerConfig[i].openMode = 'open-all'; + } + }); + } + let explorerConfig = []; let useNewExplorerConfigFormat = false; // for backward compatibilities @@ -365,6 +381,8 @@ function buildConfig(opts) { explorerConfig, useNewExplorerConfigFormat, dataAvailabilityToolConfig, + requestorPath, + studyViewerConfig, covid19DashboardConfig, mapboxAPIToken, auspiceUrl, diff --git a/src/reducers.js b/src/reducers.js index 2248396034..506fb183a6 100644 --- a/src/reducers.js +++ b/src/reducers.js @@ -16,6 +16,7 @@ import login from './Login/reducers'; import bar from './Layout/reducers'; import ddgraph from './DataDictionary/reducers'; import privacyPolicy from './PrivacyPolicy/reducers'; +import study from './StudyViewer/reducers'; import { logoutListener } from './Login/ProtectedContent'; import { fetchUserAccess, fetchUserAuthMapping } from './actions'; import getReduxStore from './reduxStore'; @@ -90,7 +91,9 @@ export const removeDeletedNode = (state, id) => { return searchResult; }; -const reducers = combineReducers({ explorer, +const reducers = combineReducers({ + explorer, + study, privacyPolicy, bar, homepage,