From 881c856dce1bd96888e7217bae084c46d3da7472 Mon Sep 17 00:00:00 2001 From: Reuben Pasquini Date: Tue, 28 Nov 2017 18:08:13 -0600 Subject: [PATCH 01/15] refactor(router): bump to react-router v4 still working on some failing tests --- package-lock.json | 93 +++++----- package.json | 10 +- src/Certificate/Quiz.jsx | 5 +- src/Certificate/ReduxQuiz.js | 9 +- src/DataDictionary/DataDictionary.jsx | 2 +- src/DataDictionary/DataDictionaryNode.jsx | 2 +- src/DataDictionary/DictionaryGraph.jsx | 2 +- src/Explorer/ExplorerComponent.jsx | 4 +- src/Explorer/ExplorerTable.jsx | 2 +- src/GraphQLEditor/ReduxGqlEditor.js | 21 --- src/Homepage/AmbiHomepage.jsx | 3 +- src/Homepage/ProjectBarChart.jsx | 2 +- src/Homepage/ProjectTable.jsx | 2 +- src/Homepage/RelayHomepage.jsx | 3 +- src/Login/ProtectedContent.jsx | 170 ++++++++++++++++++ src/Login/ProtectedContent.test.jsx | 10 ++ src/Popup/ReduxAuthTimeoutPopup.jsx | 28 +-- src/QueryNode/QueryNode.jsx | 13 +- src/QueryNode/ReduxQueryNode.js | 21 +-- src/Submission/ProjectSubmission.jsx | 2 +- src/Submission/ReduxProjectSubmission.js | 71 +------- src/Submission/SubmitTSV.jsx | 2 +- src/UserProfile/UserProfile.jsx | 13 +- src/actions.js | 114 ++++++------ src/components/Footer.jsx | 11 +- src/components/NavBar.jsx | 2 +- src/history.js | 8 - src/index.js | 201 ++++++++++------------ src/queryactions.js | 77 ++++----- src/reducers.js | 4 +- src/reduxStore.js | 7 +- src/theme.js | 2 +- src/utils.js | 27 --- 33 files changed, 488 insertions(+), 455 deletions(-) create mode 100644 src/Login/ProtectedContent.jsx create mode 100644 src/Login/ProtectedContent.test.jsx delete mode 100644 src/history.js diff --git a/package-lock.json b/package-lock.json index 34d0209369..17a0f57956 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5464,14 +5464,15 @@ "integrity": "sha1-5tnb5Xy+/mB1HwKvM2GVhwyQwB4=" }, "history": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/history/-/history-2.1.2.tgz", - "integrity": "sha1-SqLeiXoOSGfkU5hDvm7Nsphr/ew=", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz", + "integrity": "sha512-1zkBRWW6XweO0NBcjiphtVJVsIQ+SXF29z9DVkceeaSLVMFXHool+fdCZD4spDCfZJCILPILc3bm7Bc+HRi0nA==", "requires": { - "deep-equal": "1.0.1", "invariant": "2.2.2", - "query-string": "3.0.3", - "warning": "2.1.0" + "loose-envify": "1.3.1", + "resolve-pathname": "2.2.0", + "value-equal": "0.4.0", + "warning": "3.0.0" } }, "hmac-drbg": { @@ -9184,7 +9185,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", - "dev": true, "requires": { "isarray": "0.0.1" }, @@ -9192,8 +9192,7 @@ "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" } } }, @@ -10283,14 +10282,6 @@ "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==", "dev": true }, - "query-string": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-3.0.3.tgz", - "integrity": "sha1-ri4UtNBQcdTpuetIc8NbDc1C5jg=", - "requires": { - "strict-uri-encode": "1.1.0" - } - }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -10785,21 +10776,30 @@ } }, "react-router": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-2.8.1.tgz", - "integrity": "sha1-c+lJH2zrMW0Pd5gpCBhj43juTtc=", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.2.0.tgz", + "integrity": "sha512-DY6pjwRhdARE4TDw7XjxjZsbx9lKmIcyZoZ+SDO7SBJ1KUeWNxT22Kara2AC7u6/c2SYEHlEDLnzBCcNhLE8Vg==", "requires": { - "history": "2.1.2", - "hoist-non-react-statics": "1.2.0", + "history": "4.7.2", + "hoist-non-react-statics": "2.3.1", "invariant": "2.2.2", "loose-envify": "1.3.1", + "path-to-regexp": "1.7.0", + "prop-types": "15.6.0", "warning": "3.0.0" }, "dependencies": { - "hoist-non-react-statics": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz", - "integrity": "sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs=" + "history": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz", + "integrity": "sha512-1zkBRWW6XweO0NBcjiphtVJVsIQ+SXF29z9DVkceeaSLVMFXHool+fdCZD4spDCfZJCILPILc3bm7Bc+HRi0nA==", + "requires": { + "invariant": "2.2.2", + "loose-envify": "1.3.1", + "resolve-pathname": "2.2.0", + "value-equal": "0.4.0", + "warning": "3.0.0" + } }, "warning": { "version": "3.0.0", @@ -10811,21 +10811,17 @@ } } }, - "react-router-redux": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz", - "integrity": "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4=" - }, - "react-router-relay": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/react-router-relay/-/react-router-relay-0.14.0.tgz", - "integrity": "sha1-ASCQRlEiVQX9XRAPHubFt5u2biA=", + "react-router-dom": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.2.2.tgz", + "integrity": "sha512-cHMFC1ZoLDfEaMFoKTjN7fry/oczMgRt5BKfMAkTu5zEuJvUiPp1J8d0eXSVTnBh6pxlbdqDhozunOOLtmKfPA==", "requires": { - "babel-runtime": "6.26.0", + "history": "4.7.2", "invariant": "2.2.2", - "lodash": "4.17.4", + "loose-envify": "1.3.1", "prop-types": "15.6.0", - "react-static-container": "1.0.1" + "react-router": "4.2.0", + "warning": "3.0.0" } }, "react-scripts": { @@ -11930,11 +11926,6 @@ "react-transition-group": "1.2.1" } }, - "react-static-container": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/react-static-container/-/react-static-container-1.0.1.tgz", - "integrity": "sha1-aUwN1oqJa4eVGa+1SDmcwZicmrA=" - }, "react-tap-event-plugin": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/react-tap-event-plugin/-/react-tap-event-plugin-2.0.1.tgz", @@ -12681,6 +12672,11 @@ "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", "dev": true }, + "resolve-pathname": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz", + "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg==" + }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -14381,6 +14377,11 @@ "spdx-expression-parse": "1.0.4" } }, + "value-equal": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz", + "integrity": "sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -14428,9 +14429,9 @@ } }, "warning": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/warning/-/warning-2.1.0.tgz", - "integrity": "sha1-ISINnGOvx3qMkhEeARr3Bc4MaQE=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", + "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=", "requires": { "loose-envify": "1.3.1" } diff --git a/package.json b/package.json index 7e54b1e8f3..e08653881f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "file-saver": "^1.3.3", "graphiql": "^0.11.5", "graphql": "^0.8.2", - "history": "^2.1.2", + "history": "^4.7.2", "ignore-loader": "^0.1.2", "isomorphic-fetch": "^2.2.1", "material-design-icons": "^3.0.1", @@ -32,12 +32,10 @@ "react-dom": "^15.6.2", "react-form": "^1.2.7", "react-highlight": "^0.10.0", - "react-redux": "^5.0.0", - "react-relay": "^1.3.0", + "react-redux": "^5.0.6", + "react-relay": "^1.4.1", "react-remarkable": "^1.1.1", - "react-router": "^2.8.0", - "react-router-redux": "^4.0.0", - "react-router-relay": "^0.14.0", + "react-router-dom": "^4.2.2", "react-select": "^1.0.0-rc.7", "react-tap-event-plugin": "^2.0.1", "recharts": "^1.0.0-apha.5", diff --git a/src/Certificate/Quiz.jsx b/src/Certificate/Quiz.jsx index d109aecec0..df248a42b1 100644 --- a/src/Certificate/Quiz.jsx +++ b/src/Certificate/Quiz.jsx @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import { Form, FormError, RadioGroup, Radio } from 'react-form'; import styled from 'styled-components'; + import { button } from '../theme'; @@ -103,13 +104,13 @@ class Quiz extends Component { this.setState({ display_error: true }); } render() { - const { questionList, title } = this.props; + const { questionList, title, history } = this.props; return (

{title}

{ this.props.onSubmitForm(values); }} + onSubmit={(values) => { this.props.onSubmitForm(values, history); }} validate={values => this.validateForm(values)} defaultValues={this.props.certificate.certificate_result} > diff --git a/src/Certificate/ReduxQuiz.js b/src/Certificate/ReduxQuiz.js index 59535ec022..5505a11d51 100644 --- a/src/Certificate/ReduxQuiz.js +++ b/src/Certificate/ReduxQuiz.js @@ -1,7 +1,6 @@ import { connect } from 'react-redux'; import Quiz from './Quiz'; import { userapiPath } from '../configs'; -import browserHistory from '../history'; import { fetchWrapper } from '../actions'; @@ -15,10 +14,10 @@ export const updateForm = data => ({ data, }); -export const receiveSubmitCert = ({ status }) => { +export const receiveSubmitCert = ({ status }, history) => { switch (status) { case 201: - browserHistory.push('/'); + history.push('/'); return { type: 'RECEIVE_CERT_SUBMIT', }; @@ -36,7 +35,7 @@ export const receiveSubmitCert = ({ status }) => { */ export const submitForm = (data, questionList) => fetchWrapper({ path: `${userapiPath}/user/cert/security_quiz?extension=txt`, - handler: receiveSubmitCert, + handler: (result, history) => { receiveSubmitCert( result, history ) }, body: JSON.stringify({ answers: data, certificate_form: questionList }, null, '\t'), method: 'PUT', }); @@ -74,7 +73,7 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ onUpdateForm: data => dispatch(updateForm(data)), - onSubmitForm: data => dispatch(submitForm(data, questionList)), + onSubmitForm: (data, history) => dispatch(submitForm(data, questionList, history)), }); const ReduxQuiz = connect(mapStateToProps, mapDispatchToProps)(Quiz); diff --git a/src/DataDictionary/DataDictionary.jsx b/src/DataDictionary/DataDictionary.jsx index 50b80ebe6b..e57ef95caa 100644 --- a/src/DataDictionary/DataDictionary.jsx +++ b/src/DataDictionary/DataDictionary.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { Table, TableData, TableRow, TableHead } from '../theme'; diff --git a/src/DataDictionary/DataDictionaryNode.jsx b/src/DataDictionary/DataDictionaryNode.jsx index 6b2a5276f0..689509cad4 100644 --- a/src/DataDictionary/DataDictionaryNode.jsx +++ b/src/DataDictionary/DataDictionaryNode.jsx @@ -1,6 +1,6 @@ import React from 'react'; import styled, { css } from 'styled-components'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { Table, TableData, TableRow, TableHead, Bullet } from '../theme'; diff --git a/src/DataDictionary/DictionaryGraph.jsx b/src/DataDictionary/DictionaryGraph.jsx index fede514602..96a5256566 100644 --- a/src/DataDictionary/DictionaryGraph.jsx +++ b/src/DataDictionary/DictionaryGraph.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import { assignNodePositions, createNodesAndEdges } from '../DataModelGraph/utils'; import { createFullGraph, createAbridgedGraph } from './GraphCreator'; diff --git a/src/Explorer/ExplorerComponent.jsx b/src/Explorer/ExplorerComponent.jsx index b69ecd7ac7..5330ebc62f 100644 --- a/src/Explorer/ExplorerComponent.jsx +++ b/src/Explorer/ExplorerComponent.jsx @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import { createRefetchContainer } from 'react-relay'; -import { withAuthTimeout, withBoxAndNav, computeLastPageSizes } from '../utils'; +import { computeLastPageSizes } from '../utils'; import { GQLHelper } from '../gqlHelper'; import { getReduxStore } from '../reduxStore'; import { ReduxExplorerTabPanel, ReduxSideBar } from './ReduxExplorer'; @@ -158,7 +158,7 @@ class ExplorerComponent extends Component { } export const RelayExplorerComponent = createRefetchContainer( - withBoxAndNav(withAuthTimeout(ExplorerComponent), BodyBackground), + ExplorerComponent, { viewer: gqlHelper.explorerPageFragment, }, diff --git a/src/Explorer/ExplorerTable.jsx b/src/Explorer/ExplorerTable.jsx index 0efb19ddf9..34f10b651f 100644 --- a/src/Explorer/ExplorerTable.jsx +++ b/src/Explorer/ExplorerTable.jsx @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { TableRow, TableHead } from '../theme'; import { TableData, TableHeadCell, diff --git a/src/GraphQLEditor/ReduxGqlEditor.js b/src/GraphQLEditor/ReduxGqlEditor.js index 644e744342..8550172f3c 100644 --- a/src/GraphQLEditor/ReduxGqlEditor.js +++ b/src/GraphQLEditor/ReduxGqlEditor.js @@ -1,29 +1,8 @@ import { connect } from 'react-redux'; -import { graphqlSchemaUrl } from '../localconf'; -import { fetchJsonOrText, connectionError } from '../actions'; import GqlEditor from './GqlEditor'; -/** - * Fetch the schema for graphi, and stuff it into redux - - * handled by router - */ -export const fetchSchema = dispatch => fetchJsonOrText({ path: graphqlSchemaUrl, dispatch }) - .then( - ({ status, data }) => { - switch (status) { - case 200: - return dispatch( - { - type: 'RECEIVE_SCHEMA_LOGIN', - schema: data, - }, - ); - } - }, - ); - const mapStateToProps = state => ({ schema: state.graphiql.schema, diff --git a/src/Homepage/AmbiHomepage.jsx b/src/Homepage/AmbiHomepage.jsx index 7f58de61ea..4d5ea34255 100644 --- a/src/Homepage/AmbiHomepage.jsx +++ b/src/Homepage/AmbiHomepage.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { RelayProjectDashboard } from './RelayHomepage'; import ReduxProjectDashboard from './ReduxProjectDashboard'; -import { withAuthTimeout, withBoxAndNav } from '../utils'; import { getReduxStore } from '../reduxStore'; @@ -43,7 +42,7 @@ class AmbidextrousDashboard extends React.Component { /** * Ambidextrous homepage */ -const AmbiHomepage = withBoxAndNav(withAuthTimeout(AmbidextrousDashboard)); +const AmbiHomepage = AmbidextrousDashboard; export default AmbiHomepage; diff --git a/src/Homepage/ProjectBarChart.jsx b/src/Homepage/ProjectBarChart.jsx index 228f9db0bc..8924c57c51 100644 --- a/src/Homepage/ProjectBarChart.jsx +++ b/src/Homepage/ProjectBarChart.jsx @@ -2,7 +2,7 @@ import { ResponsiveContainer, Legend, Tooltip, BarChart, Bar, XAxis, YAxis } fro import styled from 'styled-components'; import PropTypes from 'prop-types'; // see https://github.com/facebook/prop-types#prop-types import React from 'react'; -import { browserHistory } from 'react-router'; +import { browserHistory } from 'react-router-dom'; import Translator from './translate'; diff --git a/src/Homepage/ProjectTable.jsx b/src/Homepage/ProjectTable.jsx index f66a3b25d2..3bb40c681a 100644 --- a/src/Homepage/ProjectTable.jsx +++ b/src/Homepage/ProjectTable.jsx @@ -1,6 +1,6 @@ import React from 'react'; import FlatButton from 'material-ui/FlatButton'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { TableBarColor } from '../theme'; import Translator from './translate'; diff --git a/src/Homepage/RelayHomepage.jsx b/src/Homepage/RelayHomepage.jsx index 31c68a1cc1..e8870fa59f 100644 --- a/src/Homepage/RelayHomepage.jsx +++ b/src/Homepage/RelayHomepage.jsx @@ -3,7 +3,6 @@ import { QueryRenderer } from 'react-relay'; import environment from '../environment'; import { DashboardWith } from './ProjectDashboard'; import { RelayProjectTable } from './RelayProjectTable'; -import { withAuthTimeout, withBoxAndNav } from '../utils'; import { GQLHelper } from '../gqlHelper'; import { getReduxStore } from '../reduxStore'; import Spinner from '../components/Spinner'; @@ -107,7 +106,7 @@ export class RelayProjectDashboard extends React.Component { } -const RelayHomepage = withBoxAndNav(withAuthTimeout(RelayProjectDashboard)); +const RelayHomepage = RelayProjectDashboard; /** diff --git a/src/Login/ProtectedContent.jsx b/src/Login/ProtectedContent.jsx new file mode 100644 index 0000000000..72848874e0 --- /dev/null +++ b/src/Login/ProtectedContent.jsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; + +import { fetchUser, fetchOAuthURL, fetchJsonOrText } from '../actions'; +import Spinner from '../components/Spinner'; +import { getReduxStore } from '../reduxStore'; +import { requiredCerts, submissionApiOauthPath } from '../configs'; +import { fetchProjects } from '../queryactions'; + + +let lastAuthMs = 0; + +/** + * Avoid importing underscore just for this ... export for testing + * @method intersection + * @param aList {Array} + * @param bList {Array} + * @return list of intersecting elements + */ +export function intersection( aList, bList ) { + const db = aList.concat(bList).reduce( + (db,it) => { if (db[it]) { db[it] += 1; } else { db[it] = 1; } return db; }, + {} + ); + return Object.entries(db) + .filter(([k,v]) => v > 1) + .map(([k,v]) => k); +} + +/** + * Container for components that require authentication to access. + */ +class ProtectedContent extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + authenticated: false, + redirectTo: null, + }; + } + + /** + * Check if the user is logged in, and update state accordingly. + * @method requireAuth + * @param {ReduxStore} store + */ + requireAuth = (store) => { + const nowMs = Date.now(); + + if (nowMs - lastAuthMs < 60000) { + // assume we're still logged in after 1 minute ... + this.setState( { authenticated: true } ); + return; + } + const dispatch = store.dispatch.bind(store); + return dispatch(fetchUser) + .then( + () => { + const { user } = store.getState(); + + if (!user.username) { + this.setState( { 'redirectTo': '/login' } ); + return Promise.reject('login necessary'); + } + + const newState = { + authenticated: true, + redirectTo: null, + }; + + // user is authenticated - now check if he has certs + const isMissingCerts = + intersection(requiredCerts, user.certificates_uploaded).length !== requiredCerts.length; + // take quiz if this user doesn't have required certificate + if (this.props.location.pathname !== 'quiz' && isMissingCerts) { + newState.redirectTo = '/quiz'; + } else if (this.props.location.pathname === 'quiz' && !isMissingCerts) { + newState.redirectTo = '/'; + lastAuthMs = Date.now(); + } else { + lastAuthMs = Date.now(); + } + return newState; + }) + .then( // get a token for the gdcapi from user-api if necessary - ugh + (newState) => dispatch(fetchProjects()) + .then(() => { + // + // The assumption here is that fetchProjects either succeeds or fails. + // If it fails, then we won't have any project data, and we'll go on + // to fetchOAuthURL bla bla .. + // + const projects = store.getState().submission.projects; + if (projects) { + // user already logged in + return Promise.reject('already logged in'); + } + + return Promise.resolve(''); + }) + .then(() => dispatch(fetchOAuthURL(submissionApiOauthPath))) + .then(oauthUrl => fetchJsonOrText({ path: oauthUrl, dispatch })) + .then( + ({ status, data }) => { + switch (status) { + case 200: + return { + type: 'RECEIVE_SUBMISSION_LOGIN', + result: true, + }; + default: { + return { + type: 'RECEIVE_SUBMISSION_LOGIN', + result: false, + error: data, + }; + } + } + }, + ) + .then( + msg => dispatch(msg), + ) + .then( + // refetch the projects - since the earlier call failed with an invalid token ... + () => dispatch(fetchProjects()) + ).then( + () => newState, + () => newState, + ) + ) + .then((newState) => this.setState(newState)); + }; + + componentDidMount() { + getReduxStore().then( + store => { + return Promise.all( + [ + store.dispatch({ type: 'CLEAR_COUNTS' }), // clear some counters + store.dispatch({ type: 'CLEAR_QUERY_NODES' }), + this.requireAuth(store), + ] + ); + } + ); + } + + render() { + const Component = this.props.component; + window.scrollTo(0, 0); + if ( this.state.redirectTo ) { + return (); + } else if ( this.state.authenticated ) { + let params = {}; + let path = ''; + if ( this.props.match ) { + params = this.props.match.params || {}; + path = this.props.match.path || ''; + } + console.log('got router params', this.props); + return (); // pass through react-router matcher params ... + } else { + return (); + } + } +} + +export default ProtectedContent; + diff --git a/src/Login/ProtectedContent.test.jsx b/src/Login/ProtectedContent.test.jsx new file mode 100644 index 0000000000..37556b1cd9 --- /dev/null +++ b/src/Login/ProtectedContent.test.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import ProtectedContent, { intersection } from './ProtectedContent'; + +describe('the ProtectedContent container', () => { + it('can compute the intersection of 2 lists', () => { + expect(intersection(['a', 'b', 'c', 'd', 'e'], ['c', 'd', 'e', 'f', 'g'])).toEqual(['c', 'd', 'e']); + }); +}); \ No newline at end of file diff --git a/src/Popup/ReduxAuthTimeoutPopup.jsx b/src/Popup/ReduxAuthTimeoutPopup.jsx index 3fe1abfdc0..9bc5d1f23e 100644 --- a/src/Popup/ReduxAuthTimeoutPopup.jsx +++ b/src/Popup/ReduxAuthTimeoutPopup.jsx @@ -1,30 +1,38 @@ import React from 'react'; import { connect } from 'react-redux'; -import { browserHistory } from 'react-router'; +import { withRouter } from 'react-router-dom'; import Popup from './Popup'; +const goToLogin = (history) => { + history.push('/login'); + // Refresh the page. + window.location.reload(false); +}; + + +const AuthPopup = withRouter( + ({ history }) => + { goToLogin(history); }} + /> +); + const timeoutPopupMapState = state => ({ - auth_popup: state.popups.authPopup, + authPopup: state.popups.authPopup, }); const timeoutPopupMapDispatch = () => ({}); -const goToLogin = () => { - browserHistory.push('/login'); - // Refresh the page. - window.location.reload(false); -}; const ReduxAuthTimeoutPopup = connect(timeoutPopupMapState, timeoutPopupMapDispatch)( ({ authPopup }) => { if (authPopup) { - return ; + ; } return (null); }, ); - export default ReduxAuthTimeoutPopup; - diff --git a/src/QueryNode/QueryNode.jsx b/src/QueryNode/QueryNode.jsx index 16d0749559..21d54aaacb 100644 --- a/src/QueryNode/QueryNode.jsx +++ b/src/QueryNode/QueryNode.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styled, { css } from 'styled-components'; import { reduxForm } from 'redux-form'; import Select from 'react-select'; @@ -156,6 +156,7 @@ class QueryNode extends React.Component { }; if ( + popups && popups.view_popup && query_nodes.query_node ) { @@ -189,7 +190,7 @@ class QueryNode extends React.Component { popupEl: null, }; - if (popups.nodedelete_popup === true) { + if (popups && popups.nodedelete_popup === true) { // User clicked on node 'Delete' button popup.state = 'confirmDelete'; popup.popupEl = ( { onClearDeleteSession(); onUpdatePopup({ nodedelete_popup: false }); }} />); - } else if (query_nodes.query_node && query_nodes.delete_error) { + } else if (query_nodes && query_nodes.query_node && query_nodes.delete_error) { // Error deleting node popup.state = 'deleteFailed'; popup.popupEl = ( { onClearDeleteSession(); onUpdatePopup({ nodedelete_popup: false }); }} />); - } else if (typeof popups.nodedelete_popup === 'string' && query_nodes.query_node) { + } else if (popups && typeof popups.nodedelete_popup === 'string' && query_nodes && query_nodes.query_node) { // Waiting for node delete to finish popup.state = 'waitForDelete'; popup.popupEl = ; @@ -224,7 +225,7 @@ class QueryNode extends React.Component { render() { const { params, ownProps, submission, query_nodes, popups, onSearchFormSubmit, onUpdatePopup, onDeleteNode, onStoreNodeInfo, - onClearDeleteSession, + onClearDeleteSession, history } = this.props; const project = params.project; @@ -233,7 +234,7 @@ class QueryNode extends React.Component {

browse {project}

{this.renderViewPopup(this.props).popupEl} {this.renderDeletePopup(this.props).popupEl} - + onSearchFormSubmit(data, url, history)} project={project} nodeTypes={submission.nodeTypes} /> { query_nodes.search_status === 'succeed: 200' && Object.entries(query_nodes.search_result.data).map( value => ( +const submitSearchForm = (opts, url, history) => (dispatch) => { const nodeType = opts.node_type; const submitterId = opts.submitter_id || ''; @@ -47,7 +47,8 @@ const submitSearchForm = (opts, url) => ) .then( () => { - if (url) { return dispatch(push(url)); } + //if (url) { return dispatch(push(url)); } + if (url && history) { history.push(url); } return null; }, ); @@ -110,18 +111,6 @@ const fetchQueryNode = ({ id, project }) => }, ).then((msg) => { dispatch(msg); }); -export const clearResultAndQuery = nextState => (dispatch, getState) => { - dispatch( - { type: 'CLEAR_QUERY_NODES' }, - ); - const location = getState().routing.locationBeforeTransitions; - if (Object.keys(location.query).length > 0) { - dispatch( - submitSearchForm({ project: nextState.params.project, ...location.query }), - ); - } -}; - const mapStateToProps = (state, ownProps) => { const result = { @@ -134,7 +123,7 @@ const mapStateToProps = (state, ownProps) => { }; const mapDispatchToProps = dispatch => ({ - onSearchFormSubmit: (value, url) => dispatch(submitSearchForm(value, url)), + onSearchFormSubmit: (value, url, history) => dispatch(submitSearchForm(value, url, history)), onUpdatePopup: state => dispatch(updatePopup(state)), onClearDeleteSession: () => dispatch(clearDeleteSession), onDeleteNode: ({ id, project }) => { diff --git a/src/Submission/ProjectSubmission.jsx b/src/Submission/ProjectSubmission.jsx index 5c300faa1e..624a11a9b2 100644 --- a/src/Submission/ProjectSubmission.jsx +++ b/src/Submission/ProjectSubmission.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styled from 'styled-components'; import PropTypes from 'prop-types'; diff --git a/src/Submission/ReduxProjectSubmission.js b/src/Submission/ReduxProjectSubmission.js index 199a80a550..fd110d5233 100644 --- a/src/Submission/ReduxProjectSubmission.js +++ b/src/Submission/ReduxProjectSubmission.js @@ -5,9 +5,8 @@ import SubmitForm from './SubmitForm'; import ReduxDataModelGraph, { getCounts } from '../DataModelGraph/ReduxDataModelGraph'; -import { fetchJsonOrText, fetchOAuthURL } from '../actions'; +import { fetchJsonOrText } from '../actions'; import { predictFileType } from '../utils'; -import { fetchProjects, fetchDictionary } from '../queryactions'; import { submissionApiPath, submissionApiOauthPath } from '../localconf'; export const uploadTSV = (value, type) => (dispatch) => { @@ -24,8 +23,7 @@ export const updateFileContent = (value, fileType) => (dispatch) => { }; -const submitToServer = (methodIn = 'PUT') => (dispatch, getState) => { - const path = getState().routing.locationBeforeTransitions.pathname.split('-'); +const submitToServer = (path, methodIn = 'PUT') => (dispatch, getState) => { const program = path[0]; const project = path.slice(1).join('-'); const submission = getState().submission; @@ -60,67 +58,6 @@ const submitToServer = (methodIn = 'PUT') => (dispatch, getState) => { let lastProjectFetchMs = 0; -export const loginSubmissionAPI = () => - // Fetch projects, if unauthorized, login - (dispatch, getState) => { - { // If already have fresh data, then exit - const state = getState(); - if (state.submission && state.submission.projects - && lastProjectFetchMs + 30000 > Date.now() - ) { - return Promise.resolve(); - } - lastProjectFetchMs = Date.now(); - } - - return dispatch( - fetchDictionary(), - ).then(() => - dispatch(fetchProjects()), - ).then(() => { - // - // I think the assumption here is that fetchProjects either succeeds or fails. - // If it fails, then we won't have any project data, and we'll go on - // to fetchOAuthURL bla bla .. - // - const projects = getState().submission.projects; - if (projects) { - // user already logged in - return Promise.reject('already logged in'); - } - - return Promise.resolve(); - }) - .then(() => dispatch(fetchOAuthURL(submissionApiOauthPath))) - .then(oauthUrl => fetchJsonOrText({ path: oauthUrl, dispatch })) - .then( - ({ status, data }) => { - switch (status) { - case 200: - return { - type: 'RECEIVE_SUBMISSION_LOGIN', - result: true, - }; - default: { - return { - type: 'RECEIVE_SUBMISSION_LOGIN', - result: false, - error: data, - }; - } - } - }, - ) - .then( - msg => dispatch(msg), - ) - .then( - // why are we doing this again ? - () => dispatch(fetchProjects())) - .catch(error => console.log(error)); - } -; - const ReduxSubmitTSV = (() => { const mapStateToProps = state => ({ @@ -130,8 +67,8 @@ const ReduxSubmitTSV = (() => { const mapDispatchToProps = dispatch => ({ onUploadClick: (value, type) => dispatch(uploadTSV(value, type)), - onSubmitClick: (type, project, dictionary) => - dispatch(submitToServer()) + onSubmitClick: (type, path, dictionary) => + dispatch(submitToServer(path)) .then( () => { // Update node counts in redux diff --git a/src/Submission/SubmitTSV.jsx b/src/Submission/SubmitTSV.jsx index deb2bc1128..b365e3b288 100644 --- a/src/Submission/SubmitTSV.jsx +++ b/src/Submission/SubmitTSV.jsx @@ -91,7 +91,7 @@ const SubmitTSV = ({ path, submission, onUploadClick, onSubmitClick, onFileChang }; SubmitTSV.propTypes = { - path: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, // from react-router submission: PropTypes.shape({ file: PropTypes.string, file_type: PropTypes.string, diff --git a/src/UserProfile/UserProfile.jsx b/src/UserProfile/UserProfile.jsx index 1eed07d6d0..b5c1658cbd 100644 --- a/src/UserProfile/UserProfile.jsx +++ b/src/UserProfile/UserProfile.jsx @@ -1,6 +1,6 @@ import React from 'react'; import styled, { css } from 'styled-components'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { jsonToString } from '../utils'; @@ -98,6 +98,13 @@ export const ProjectCell = styled(Link)` padding-left: 0.5em; `; +export const ProjectCellNoAccess = styled.div` + display: block; + float: left; + width: 30%; + padding-left: 0.5em; +`; + export const RightCell = styled(Cell)` width: 70%; `; @@ -257,9 +264,9 @@ const UserProfile = ({ user, userProfile, popups, submission, onCreateKey, } { !(p in submission.projects) && - + {p} - + } {user.project_access[p].join(', ')} diff --git a/src/actions.js b/src/actions.js index 62f71efc1f..d7f1a9668f 100644 --- a/src/actions.js +++ b/src/actions.js @@ -1,6 +1,5 @@ import 'isomorphic-fetch'; -import _ from 'underscore'; -import { requiredCerts, userapiPath, headers, basename, submissionApiOauthPath, graphqlPath } from './configs'; +import { userapiPath, headers, basename, submissionApiOauthPath, graphqlPath } from './configs'; export const updatePopup = state => ({ @@ -16,19 +15,28 @@ export const connectionError = () => { }; }; + +const fetchCache = {} + /** * Little helper issues fetch, then resolves response * as text, and tries to JSON.parse the text before resolving, but * ignores JSON.parse failure and reponse.status, and returns {response, data} either way. * If dispatch is supplied, then dispatch(connectionError()) on fetch reject. + * If useCache is supplied and method is GET, then text for 200 JSON responses are cached, and re-used, and + * the result promise only includes {data, status} - where JSON data is re-parsed + * every time to avoid mutation by the client * * @method fetchJsonOrText - * @param {path,method=GET,body=null,customHeaders?, dispatch?} opts - * @return Promise<{response,data,status,headers} + * @param {path,method=GET,body=null,customHeaders?, dispatch?, useCache?} opts + * @return Promise<{response,data,status,headers}> or Promise<{data,status}> if useCache specified */ export const fetchJsonOrText = (opts) => { - const { path, method = 'GET', body = null, customHeaders, dispatch } = opts; + const { path, method = 'GET', body = null, customHeaders, dispatch, useCache } = opts; + if (useCache && (method === 'GET') && fetchCache[path]) { + return Promise.resolve({status: 200, data: JSON.parse(fetchCache[path])}); + } const request = { credentials: 'same-origin', headers: { ...headers, ...customHeaders }, @@ -43,6 +51,9 @@ export const fetchJsonOrText = (opts) => { if (data) { try { data = JSON.parse(data); + if (useCache && method === 'GET' && response.status === 200) { + fetchCache[path] = textData; + } } catch (e) { // # do nothing } @@ -134,7 +145,7 @@ export const fetchUser = dispatch => fetchJsonOrText({ case 401: return { type: 'UPDATE_POPUP', - data: { auth_popup: true }, + data: { authPopup: true }, }; default: return { @@ -145,33 +156,6 @@ export const fetchUser = dispatch => fetchJsonOrText({ }, ).then((msg) => { dispatch(msg); }); -export const requireAuth = (store, additionalHooks) => (nextState, replace, callback) => { - window.scrollTo(0, 0); - const resolvePromise = () => { - const { user } = store.getState(); - const location = nextState.location; - if (!user.username) { - const path = location.pathname === '/' ? '/' : `/${location.pathname}`; - replace({ pathname: '/login', query: { next: path + nextState.location.search } }); - return Promise.resolve(); - } - const hasCerts = - _.intersection(requiredCerts, user.certificates_uploaded).length !== requiredCerts.length; - // take quiz if this user doesn't have required certificate - if (location.pathname !== 'quiz' && hasCerts) { - replace({ pathname: '/quiz' }); - } else if (location.pathname === 'quiz' && !hasCerts) { - replace({ pathname: '/' }); - } else if (additionalHooks) { - return additionalHooks(nextState, replace); - } - return Promise.resolve(); - }; - store - .dispatch(fetchUser) - .then(resolvePromise) - .then(() => callback()); -}; export const enterHook = (store, hookAction) => (nextState, replace, callback) => store.dispatch(hookAction()).then(() => callback()); @@ -187,32 +171,42 @@ export const logoutAPI = () => dispatch => fetchJsonOrText({ () => document.location.replace(`${userapiPath}/logout?next=${basename}`), ); + +/** + * Retrieve the oath endpoint for the service under the given oathPath + * + * @param {String} oauthPath + * @return {(dispatch) => Promise} dispatch function + */ export const fetchOAuthURL = oauthPath => dispatch => -// Get cloud_middleware's authorization url fetchJsonOrText({ - path: `${oauthPath}authorization_url`, - dispatch, - }).then( - ({ status, data }) => { - switch (status) { - case 200: - return { - type: 'RECEIVE_AUTHORIZATION_URL', - url: data, - }; - default: - return { - type: 'FETCH_ERROR', - error: data.error, - }; - } - }, - ).then( - (msg) => { - dispatch(msg); - if (msg.url) { - return msg.url; - } - throw new Error('OAuth authorization failed'); - }, - ); + path: `${oauthPath}authorization_url`, + dispatch, + useCache: true + }) + .then( + ({ status, data }) => { + switch (status) { + case 200: + const result = { + type: 'RECEIVE_AUTHORIZATION_URL', + url: data, + }; + return result; + default: + return { + type: 'FETCH_ERROR', + error: data.error, + }; + } + }, + ) + .then( + (msg) => { + dispatch(msg); + if (msg.url) { + return msg.url; + } + throw new Error('OAuth authorization failed'); + }, + ); diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index b14b881af6..1fe6c266fb 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import React from 'react'; import PropTypes from 'prop-types'; import { portalVersion } from '../versions'; @@ -38,8 +38,13 @@ const Footer = ({ dictionaryVersion, apiVersion }) => ); Footer.propTypes = { - dictionaryVersion: PropTypes.string.isRequired, - apiVersion: PropTypes.string.isRequired, + dictionaryVersion: PropTypes.string, + apiVersion: PropTypes.string, +}; + +Footer.defaultProps = { + dictionarVersion: null, + apiVersion: null, }; Footer.defaultProps = { diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx index 8ffddd36b6..5fc825776f 100644 --- a/src/components/NavBar.jsx +++ b/src/components/NavBar.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import styled from 'styled-components'; import PropTypes from 'prop-types'; import FlatButton from 'material-ui/FlatButton'; diff --git a/src/history.js b/src/history.js deleted file mode 100644 index a7e5c9f28a..0000000000 --- a/src/history.js +++ /dev/null @@ -1,8 +0,0 @@ -import { basename } from './configs'; -import { useRouterHistory } from 'react-router'; -import { createHistory } from 'history'; - -export default useRouterHistory(createHistory)({ - basename, -}); - diff --git a/src/index.js b/src/index.js index 5482a1a0cf..43b3edfc5b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,8 @@ import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; -import { Router, Route } from 'react-router'; -import { syncHistoryWithStore } from 'react-router-redux'; +import { BrowserRouter, Route, Switch } from 'react-router-dom'; +// not yet compatable with react-router 4.X - import { syncHistoryWithStore } from 'react-router-redux'; import { ThemeProvider } from 'styled-components'; import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; import injectTapEventPlugin from 'react-tap-event-plugin'; @@ -10,22 +10,24 @@ import 'react-select/dist/react-select.css'; import { requireAuth, enterHook, fetchUser } from './actions'; import Login from './Login/Login'; +import ProtectedContent from './Login/ProtectedContent'; import AmbiHomepage from './Homepage/AmbiHomepage'; import ExplorerPage from './Explorer/ExplorerPage'; -import QueryNode, { clearResultAndQuery } from './QueryNode/ReduxQueryNode'; import DataDictionary from './DataDictionary/ReduxDataDictionary'; import DataDictionaryNode from './DataDictionary/ReduxDataDictionaryNode'; -import ProjectSubmission, { loginSubmissionAPI } from './Submission/ReduxProjectSubmission'; +import ProjectSubmission from './Submission/ReduxProjectSubmission'; import UserProfile, { loginUserProfile, fetchAccess } from './UserProfile/ReduxUserProfile'; import CertificateQuiz from './Certificate/ReduxQuiz'; -import GraphQLQuery, { fetchSchema } from './GraphQLEditor/ReduxGqlEditor'; -import { fetchDictionary } from './queryactions'; -import { app } from './localconf'; -import browserHistory from './history'; -import { theme } from './theme'; -import { clearCounts } from './DataModelGraph/ReduxDataModelGraph'; -import { asyncSetInterval, withBoxAndNav, withAuthTimeout } from './utils'; +import GraphQLQuery from './GraphQLEditor/ReduxGqlEditor'; +import { fetchDictionary, fetchSchema, fetchVersionInfo } from './queryactions'; +import { app, basename } from './localconf'; +import { OuterWrapper, Box, Body, Margin, theme } from './theme'; +import { asyncSetInterval } from './utils'; import { getReduxStore } from './reduxStore'; +import Nav from './Nav/ReduxNavBar'; +import Footer from './components/Footer'; +import ReduxAuthTimeoutPopup from './Popup/ReduxAuthTimeoutPopup'; +import QueryNode from './QueryNode/QueryNode'; // Needed for onTouchTap @@ -33,113 +35,84 @@ import { getReduxStore } from './reduxStore'; injectTapEventPlugin(); -let initialized = false; - // render the app after the store is configured async function init() { - if (initialized) { - console.log('WARNING: attempt to re-initialize application'); - return; - } - initialized = true; const store = await getReduxStore(); - asyncSetInterval(() => store.dispatch(fetchUser), 10000); + asyncSetInterval(() => store.dispatch(fetchUser), 60000); - const history = syncHistoryWithStore(browserHistory, store); - history.listen(location => console.log(location.pathname)); - if (app !== 'gdc') { - render( - - - - - - store.dispatch(loginSubmissionAPI()))} - component={AmbiHomepage} - /> - store.dispatch(loginSubmissionAPI()) - .then(() => store.dispatch(clearCounts)) - .then(() => store.dispatch(fetchSchema)), - ) - } - component={withBoxAndNav(withAuthTimeout(GraphQLQuery))} - /> - store.dispatch(loginUserProfile()))} - component={withBoxAndNav(withAuthTimeout(UserProfile))} - /> - - - - store.dispatch(loginSubmissionAPI()) - .then(() => store.dispatch(clearResultAndQuery(nextState))), - ) - } - component={ExplorerPage} - /> - store.dispatch(loginSubmissionAPI()) - .then(() => store.dispatch(clearCounts)), - ) - } - component={withBoxAndNav(withAuthTimeout(ProjectSubmission))} - /> - store.dispatch(loginSubmissionAPI()).then(() => store.dispatch(clearResultAndQuery(nextState))))} - component={withBoxAndNav(withAuthTimeout(QueryNode))} - /> - - - - , - document.getElementById('root'), - ); - } else { - render( - - - - - store.dispatch(fetchAccess()))} - component={withBoxAndNav(withAuthTimeout(UserProfile))} - /> - - - , - document.getElementById('root'), - ); - } + await Promise.all( + [ + store.dispatch(fetchSchema), + store.dispatch(fetchDictionary), + fetchVersionInfo(), + ] + ); + const background = null; // for now + + render( + + + + + + +