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) ?
+ this.props.history.push(`${this.props.location.pathname}/${encodeURIComponent(this.props.data.rowAccessorValue)}`)}
+ />
+ : null}
+ {(displayDownloadButton) ?
+ : null}
+ {(displayRequestAccessButton) ?
+ : null}
+ ) : null
+ }
+ ,
+ ]}
+ >
+ {
+ const downloadLink = (item.object_id) ? `${userapiPath}data/download/${item.object_id}?expires_in=900&redirect` : '';
+ return (download]}
+ >
+ {`${item.file_name} (${item.data_format} - ${humanFileSize(item.file_size)})`}
+ );
+ }}
+ />
+
+ {(displayRequestAccessButton && !this.props.data.accessRequested) ?
+ : null}
+
+ {(this.props.data.blockData) ?
+
+ {(Object.entries(this.props.data.blockData).map(([k, v]) => (
+
+
{this.getLabel(k)}
+
+ {v}
+
+
)))}
+
: null }
+ {(this.props.data.tableData) ?
+
+ {(Object.entries(this.props.data.tableData).map(([k, v]) => {
+ let value = [];
+ if (_.isArray(v)) {
+ value = v;
+ } else {
+ value.push(v);
+ }
+ return (
+
+ {value.map((item) => {
+ if (_.isString(item)) {
+ if (stringIsAValidUrl(item)) {
+ return ();
+ }
+ return item;
+ }
+ // codes below are from the mockup, keeping them here since we might need then if we have the nested docs later
+ /*
+ if (item && item.link) {
+ let iconComponent = ;
+ let linkComponent = (
+ {(item.name) ? item.name : item.link}
+ );
+ if (item.type && item.type === 'file') {
+ iconComponent = (item.format === 'PDF') ? : ;
+ const linkText = `${item.name} (${item.format} - ${humanFileSize(item.size)})`;
+ linkComponent = {linkText} ;
+ }
+ return (
+ {iconComponent}
+ {linkComponent}
+
);
+ }
+ */
+ // eslint-disable-next-line no-console
+ console.warn('Unknown object found in meta data: ', item);
+ return null;
+ })}
+ );
+ }))}
+
+ : null}
+
+
+ );
+ }
+}
+
+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,