diff --git a/.eslintrc.js b/.eslintrc.js index 4a193a7ad5..ec4e22911a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -36,6 +36,8 @@ module.exports = { // see https://github.com/clayne11/eslint-import-resolver-meteor/issues/17 // - seems to affect Codacy :-( "import/extensions": ["off", "never"], - "react/jsx-indent": "off" - } + "react/jsx-indent": "off", + "react/forbid-prop-types": "off", + "react/prefer-stateless-function": "off", + }, }; diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000000..b4f7e77b45 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,34 @@ +#!groovy + +pipeline { + agent any + + stages { + stage('FetchCode') { + steps { + checkout scm + } + } + stage('k8sDeploy') { + steps { + echo "GIT_BRANCH is $env.GIT_BRANCH" + echo "GIT_COMMIT is $env.GIT_COMMIT" + script { + def helper = new uchicago.cdis.KubeHelper(this); + helper.deployBranch("portal-service"); + } + } + } + } + post { + success { + slackSend color: 'good', message: "https://jenkins.planx-pla.net/job/$env.JOB_NAME/\nuc-cdis/data-portal pipeline succeeded" + } + failure { + slackSend color: 'bad', message: "https://jenkins.planx-pla.net/job/$env.JOB_NAME/\nuc-cdis/data-portal pipeline failed" + } + unstable { + slackSend color: 'bad', message: "https://jenkins.planx-pla.net/job/$env.JOB_NAME/\nuc-cdis/data-portal pipeline unstable" + } + } +} diff --git a/package-lock.json b/package-lock.json index 34d0209369..76fe9122f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3259,6 +3259,11 @@ "resolved": "https://registry.npmjs.org/declare.js/-/declare.js-0.0.8.tgz", "integrity": "sha1-BHit/5VkwAT1Hfc9i8E0AZ0o3N4=" }, + "deep-diff": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", + "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + }, "deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -5464,14 +5469,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 +9190,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 +9197,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 +10287,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", @@ -10690,12 +10686,25 @@ } }, "react-form": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/react-form/-/react-form-1.3.0.tgz", - "integrity": "sha1-1nm0O6iWyAEKlC9lEZZJU34aD0s=", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/react-form/-/react-form-2.12.1.tgz", + "integrity": "sha512-amJzBCD/1OSaKXCfBE8KNz32VU6LZKXqMt2hrMgBJ8cq6Jaqm3q0i4vx2felEFeaeK/2agcBuW+OwTEDmzjirQ==", "requires": { + "babel-runtime": "6.26.0", + "circular-json": "0.4.0", "classnames": "2.2.5", - "prop-types": "15.6.0" + "prop-types": "15.6.0", + "react-redux": "5.0.6", + "redux": "3.7.2", + "redux-logger": "3.0.6", + "redux-thunk": "2.2.0" + }, + "dependencies": { + "circular-json": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.4.0.tgz", + "integrity": "sha512-tKV502ADgm9Z37s6B1QOohegjJJrCl2iyMMb1+8ITHrh1fquW8Jdbkb4s5r4Iwutr1UfL1qvkqvc1wZZlLvwow==" + } } }, "react-highlight": { @@ -10785,21 +10794,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 +10829,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 +11944,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", @@ -12253,6 +12262,14 @@ } } }, + "redux-logger": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", + "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", + "requires": { + "deep-diff": "0.3.8" + } + }, "redux-mock-store": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.3.0.tgz", @@ -12681,6 +12698,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 +14403,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 +14455,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..ff154a4894 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", @@ -30,14 +30,12 @@ "react": "^15.6.2", "react-ace": "^4.1.6", "react-dom": "^15.6.2", - "react-form": "^1.2.7", + "react-form": "^2.12.1", "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..e1e57d4a66 100644 --- a/src/Certificate/Quiz.jsx +++ b/src/Certificate/Quiz.jsx @@ -1,6 +1,8 @@ import React, { Component } from 'react'; -import { Form, FormError, RadioGroup, Radio } from 'react-form'; +import { Form, RadioGroup, Radio } from 'react-form'; import styled from 'styled-components'; +import PropTypes from 'prop-types'; + import { button } from '../theme'; @@ -8,7 +10,6 @@ const OptionBullet = styled.p` input { margin-right: 1em; } - `; const QuestionItem = styled.section` .FormError { @@ -47,6 +48,16 @@ const Tooltip = styled.div` class Question extends Component { + static propTypes = { + content: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + showErrors: PropTypes.bool, + }; + + static defaultProps = { + showErrors: false, + }; + render() { return ( @@ -55,17 +66,24 @@ class Question extends Component {

{this.props.content.hint}

- { this.props.showErrors && } + { this.props.showErrors &&
Error!?!?
} - {this.props.content.options.map( - (option, i) => - ({option}), - )} + { + group => (
+ { + this.props.content.options.map( + (option, i) => + ({option}), + ) + } +
) + }
); @@ -76,12 +94,28 @@ class Question extends Component { * Little quiz component - roperites: questionList, title, onSubmit */ class Quiz extends Component { + static propTypes = { + questionList: PropTypes.arrayOf( + PropTypes.shape( + { + question: PropTypes.string, + id: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.string), + answer: PropTypes.number, + hint: PropTypes.string, + }, + ), + ).isRequired, + onUpdateForm: PropTypes.func.isRequired, + onSubmitForm: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + }; + constructor(props) { super(props); - this.state = { display_error: false }; - // required to be able to pass to child - this.hideError = this.hideError.bind(this); + this.state = { displayError: false }; } + validateForm(values) { // update redux store if (Object.keys(values).length > 0) { @@ -95,16 +129,20 @@ class Quiz extends Component { } // hide errors when user is updating the form hideError() { - this.setState({ display_error: false }); + if (this.state.displayError) { + this.setState({ displayError: false }); + } } // show errors when user hits submit button showError() { - this.setState({ display_error: true }); + if (!this.state.displayError) { + this.setState({ displayError: true }); + } } + render() { const { questionList, title } = this.props; - return (

{title}

@@ -121,10 +159,10 @@ class Quiz extends Component { (item, i) => ( this.hideError()} index={i} key={i} - showErrors={this.state.display_error} + showErrors={this.state.displayError} />), ) } diff --git a/src/Certificate/ReduxQuiz.js b/src/Certificate/ReduxQuiz.js index 59535ec022..82fa1052bc 100644 --- a/src/Certificate/ReduxQuiz.js +++ b/src/Certificate/ReduxQuiz.js @@ -1,24 +1,23 @@ import { connect } from 'react-redux'; import Quiz from './Quiz'; import { userapiPath } from '../configs'; -import browserHistory from '../history'; import { fetchWrapper } from '../actions'; /** * Redux action triggered by quiz form update * @method updateForm - * @param {*} data + * @param {*} data */ export const updateForm = data => ({ type: 'UPDATE_CERTIFICATE_FORM', data, }); -export const receiveSubmitCert = ({ status }) => { +export const receiveSubmitCert = ({ status }, history) => { switch (status) { case 201: - browserHistory.push('/'); + history.push('/'); return { type: 'RECEIVE_CERT_SUBMIT', }; @@ -31,12 +30,12 @@ export const receiveSubmitCert = ({ status }) => { /** * Redux action triggered by quiz submit - * @param {*} data - * @param {*} questionList + * @param {*} data + * @param {*} questionList */ -export const submitForm = (data, questionList) => fetchWrapper({ +export const submitForm = (data, questionList, history) => fetchWrapper({ path: `${userapiPath}/user/cert/security_quiz?extension=txt`, - handler: receiveSubmitCert, + handler: (result) => { receiveSubmitCert(result, history); }, body: JSON.stringify({ answers: data, certificate_form: questionList }, null, '\t'), method: 'PUT', }); @@ -44,7 +43,7 @@ export const submitForm = (data, questionList) => fetchWrapper({ /** * answer is the index of the correct option - */ + */ const questionList = [ { question: 'As a registered user, I can:', @@ -72,9 +71,10 @@ const mapStateToProps = state => ({ title, }); -const mapDispatchToProps = dispatch => ({ +const mapDispatchToProps = (dispatch, ownProps) => ({ onUpdateForm: data => dispatch(updateForm(data)), - onSubmitForm: data => dispatch(submitForm(data, questionList)), + // ownProps.history from react-router + onSubmitForm: data => dispatch(submitForm(data, questionList, ownProps.history)), }); const ReduxQuiz = connect(mapStateToProps, mapDispatchToProps)(Quiz); diff --git a/src/Certificate/reducers.js b/src/Certificate/reducers.js index 6b3cbdb479..fafa446aa1 100644 --- a/src/Certificate/reducers.js +++ b/src/Certificate/reducers.js @@ -1,4 +1,4 @@ -export const certificate = (state = {}, action) => { +const certificate = (state = {}, action) => { switch (action.type) { case 'UPDATE_CERTIFICATE_FORM': return { ...state, certificate_result: action.data }; @@ -6,3 +6,5 @@ export const certificate = (state = {}, action) => { return state; } }; + +export default certificate; diff --git a/src/DataDictionary/DataDictionary.jsx b/src/DataDictionary/DataDictionary.jsx index 50b80ebe6b..2e57e01661 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'; @@ -73,8 +73,8 @@ CategoryTable.propTypes = { * Just exported for testing * Little helper that extacts a mapping of category-name to * the list of nodes in that category given a dictionary definition object - * - * @param {Object} dictionary + * + * @param {Object} dictionary * @return {} mapping from category to node list */ export function category2NodeList(dictionary) { @@ -96,8 +96,8 @@ export function category2NodeList(dictionary) { /** * Little components presents an overview of the types in a dictionary organized by category - * - * @param {dictionary} params + * + * @param {dictionary} params */ const DataDictionary = ({ dictionary }) => { const c2nl = category2NodeList(dictionary); diff --git a/src/DataDictionary/DataDictionary.test.jsx b/src/DataDictionary/DataDictionary.test.jsx index b17c83cae7..6df8886c42 100644 --- a/src/DataDictionary/DataDictionary.test.jsx +++ b/src/DataDictionary/DataDictionary.test.jsx @@ -1,5 +1,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import { StaticRouter } from 'react-router-dom'; + import DataDictionary, { category2NodeList } from './DataDictionary'; describe('the DataDictionary component', () => { @@ -45,7 +47,11 @@ describe('the DataDictionary component', () => { }); it('renders category tables', () => { - const ux = mount(); + const ux = mount( + + + , + ); expect(ux.find('table').length).toBe(2); }); }); diff --git a/src/DataDictionary/DataDictionaryNode.jsx b/src/DataDictionary/DataDictionaryNode.jsx index 6b2a5276f0..1abd8699de 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'; @@ -57,7 +57,7 @@ const LinkTable = ({ links }) => { * @param {Object} property one of the properties of a dictionary node * @return {String|Array} string for scalar types, array for enums * and other listish types or 'UNDEFINED' if no - * type information availabale + * type information availabale */ export const getType = (property) => { let type = 'UNDEFINED'; @@ -116,7 +116,7 @@ const NodeTable = ({ node }) => ( (key) => { const compoundKey = key.join(', '); return {compoundKey}; - } + }, ) } @@ -246,8 +246,8 @@ const DownloadButton = styled.a` /** * Component renders a view with details of a particular dictionary type (node - /dd/typename) or * of the whole dictionary (/dd/graph). - * - * @param {*} param0 + * + * @param {*} param0 */ const DataDictionaryNode = ({ params, submission }) => { const node = params.node; @@ -301,12 +301,12 @@ const DataDictionaryNode = ({ params, submission }) => { DataDictionaryNode.propTypes = { params: PropTypes.shape({ - dictionary: PropTypes.object.isRequired, node: PropTypes.string.isRequired, }).isRequired, submission: PropTypes.shape( { counts_search: PropTypes.objectOf(PropTypes.number), + dictionary: PropTypes.object.isRequired, links_search: PropTypes.objectOf(PropTypes.number), }, ), diff --git a/src/DataDictionary/DictionaryGraph.jsx b/src/DataDictionary/DictionaryGraph.jsx index fede514602..5de393b54a 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'; @@ -63,7 +63,7 @@ class DictionaryGraph extends React.Component { }; // Note: svg#data_model_graph is popuplated by createFull|AbridedGraph above return ( -
+
Explore dictionary as a table

Bold, italicized properties are required

diff --git a/src/DataDictionary/DictionaryGraph.test.jsx b/src/DataDictionary/DictionaryGraph.test.jsx index 443c3ee23a..581d8f914d 100644 --- a/src/DataDictionary/DictionaryGraph.test.jsx +++ b/src/DataDictionary/DictionaryGraph.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; +import { StaticRouter } from 'react-router-dom'; import DictionaryGraph from './DictionaryGraph'; import { buildTestData } from '../DataModelGraph/testData'; @@ -10,21 +11,23 @@ describe('the DictionaryGraph', () => { const data = buildTestData(); // Material-UI components require the Mui theme ... const $dom = mount( - , + + + , ); - const $graph = $dom; // .find('DataModelGraph'); + const $graph = $dom.find(DictionaryGraph); return { ...data, $graph, $dom }; } it('boots to a full view', () => { const { $graph } = buildTest(); expect($graph.length).toBe(1); - expect($graph.state('fullToggle')).toBe(true); + expect(!!$graph.find('div[data-toggle="full"]')).toBe(true); }); it('toggles between full and compact views', () => { @@ -32,7 +35,7 @@ describe('the DictionaryGraph', () => { const $toggleButton = $dom.find('a#toggle_button'); expect($toggleButton.length).toBe(1); $toggleButton.simulate('click'); - expect($graph.state('fullToggle')).toBe(false); + expect(!!$graph.find('div[data-toggle="abridged"]')).toBe(true); expect(document.querySelector('#data_model_graph')).toBeDefined(); // jsdom does not yet support svg // const ellipseList = document.querySelectorAll('ellipse'); diff --git a/src/DataDictionary/GraphCreator.js b/src/DataDictionary/GraphCreator.js index bf754d75d1..164c1c652e 100644 --- a/src/DataDictionary/GraphCreator.js +++ b/src/DataDictionary/GraphCreator.js @@ -12,7 +12,7 @@ const d3 = { /** * createDDGraph: Creates a Data Dictionary graph (rectangular nodes). - * Needs position as property of each node (as fraction of 1 e.g. [0.5, 0.1] + * Needs position as property of each node (as fraction of 1 e.g. [0.5, 0.1] * for placement at (0.5*svgWidth, 0.1*svgHeight)) */ function createDDGraph(nodesIn, edges, radius = 60, boxHeightMult, boxWidthMult, svgHeightMult) { @@ -148,7 +148,7 @@ function createDDGraph(nodesIn, edges, radius = 60, boxHeightMult, boxWidthMult, } /** - * formatField: Recurisvely inserts newline characters into strings that are + * formatField: Recurisvely inserts newline characters into strings that are * too long after underscores */ function formatField(name) { @@ -171,8 +171,8 @@ function formatField(name) { return name; } -/** - * formatType: Turn different ways used to represent type in data dictionary +/** + * formatType: Turn different ways used to represent type in data dictionary * into a string */ function formatType(type) { diff --git a/src/DataModelGraph/DataModelGraph.test.jsx b/src/DataModelGraph/DataModelGraph.test.jsx index 9638dd14da..3748f31c9a 100644 --- a/src/DataModelGraph/DataModelGraph.test.jsx +++ b/src/DataModelGraph/DataModelGraph.test.jsx @@ -38,7 +38,7 @@ describe('the DataModelGraph', () => { expect($graph.state('fullToggle')).toBe(true); expect(document.querySelector('#data_model_graph')).toBeDefined(); // Not sure why this doesn't work ...? - // Could be jsdom does not support svg properly. + // Could be jsdom does not support svg properly. // expect(d3.selectAll('ellipse').size()).toBe(nodes.length); }); diff --git a/src/DataModelGraph/ReduxDataModelGraph.js b/src/DataModelGraph/ReduxDataModelGraph.js index 1663af739a..381be1e067 100644 --- a/src/DataModelGraph/ReduxDataModelGraph.js +++ b/src/DataModelGraph/ReduxDataModelGraph.js @@ -11,9 +11,9 @@ export const clearCounts = { /** - * Compose and send a single graphql query to get a count of how + * Compose and send a single graphql query to get a count of how * many of each node and edge are in the current state - * + * * @method getCounts * @param {Array} typeList * @param {string} project @@ -46,7 +46,7 @@ export const getCounts = (typeList, project, dictionary) => { // Add links to query Object.keys(dictionary).filter( name => (!name.startsWith('_' && dictionary[name].links)), - ).reduce( // extract links from each node + ).reduce( // extract links from each node (linkList, name) => { const node = dictionary[name]; const newLinks = node.links; diff --git a/src/DataModelGraph/SvgGraph.jsx b/src/DataModelGraph/SvgGraph.jsx index 2aac8333e6..01845dfbee 100644 --- a/src/DataModelGraph/SvgGraph.jsx +++ b/src/DataModelGraph/SvgGraph.jsx @@ -11,12 +11,12 @@ const d3 = { }; /** - * createSvgGraph: builds an SVG graph (oval nodes) in the SVG DOM + * createSvgGraph: builds an SVG graph (oval nodes) in the SVG DOM * node with selector: svg#data_model_graph. - * Needs position as property of each node (as fraction of 1 e.g. [0.5, 0.1] + * Needs position as property of each node (as fraction of 1 e.g. [0.5, 0.1] * for placement at (0.5*svg_width, 0.1*svg_height)) * Side effect - decorates each node in 'nodes' with a 'position' property - * + * * @param nodes * @param edges */ diff --git a/src/DataModelGraph/utils.js b/src/DataModelGraph/utils.js index b948e3956d..3ceca0f811 100644 --- a/src/DataModelGraph/utils.js +++ b/src/DataModelGraph/utils.js @@ -4,10 +4,10 @@ * and edges, returns the nodes and edges in correct format * * @method createNodesAndEdges - * @param props: Object (normally taken from redux state) that includes dictionary - * property defining the dictionary as well as other optional properties + * @param props: Object (normally taken from redux state) that includes dictionary + * property defining the dictionary as well as other optional properties * such as counts_search and links_search (created by getCounts) with - * information about the number of each type (node) and link (between + * information about the number of each type (node) and link (between * nodes with a link's source and target types) that actually * exist in the data * @param createAll: Include all nodes and edges or only those that are populated in @@ -40,7 +40,7 @@ export function createNodesAndEdges(props, createAll, nodesToHide = ['program']) const edges = nodes.filter( node => node.links && node.links.length > 0, - ).reduce( // add each node's links to the edge list + ).reduce( // add each node's links to the edge list (list, node) => { const newLinks = node.links.map( link => ({ source: node.name, target: link.target_type, exists: 1, ...link }), @@ -62,12 +62,12 @@ export function createNodesAndEdges(props, createAll, nodesToHide = ['program']) return result; }, [], ).filter( - // target type exist and is not in hide list + // target type exist and is not in hide list link => (link.target && nameToNode[link.target] && !hideDb[link.target]), ) .map( (link) => { - // decorate each link with its "exists" count if available + // decorate each link with its "exists" count if available // (number of instances of link between source and target types in the data) link.exists = props.links_search ? props.links_search[`${link.source}_${link.name}_to_${link.target}_link`] : undefined; return link; @@ -95,7 +95,7 @@ export function createNodesAndEdges(props, createAll, nodesToHide = ['program']) export function findRoot(nodes, edges) { const couldBeRoot = edges.reduce( (db, edge) => { - // At some point the d3 force layout converts + // At some point the d3 force layout converts // edge.source and edge.target into node references ... const sourceName = typeof edge.source === 'object' ? edge.source.name : edge.source; if (db[sourceName]) { @@ -103,7 +103,7 @@ export function findRoot(nodes, edges) { } return db; }, - // initialize emptyDb - any node could be the root + // initialize emptyDb - any node could be the root nodes.reduce((emptyDb, node) => { emptyDb[node.name] = true; return emptyDb; }, {}), ); const rootNode = nodes.find(n => couldBeRoot[n.name]); @@ -114,13 +114,13 @@ export function findRoot(nodes, edges) { * Arrange nodes in dictionary graph breadth first, and build level database. * If a node links to multiple parents, then place it under the highest parent ... * Exported for testing. - * - * @param {Array} nodes + * + * @param {Array} nodes * @param {Array} edges - * @return { nodesBreadthFirst, treeLevel2Names, name2Level } where + * @return { nodesBreadthFirst, treeLevel2Names, name2Level } where * nodesBreadthFirst is array of node names, and - * treeLevel2Names is an array of arrays of node names, - * and name2Level is a mapping of node name to level + * treeLevel2Names is an array of arrays of node names, + * and name2Level is a mapping of node name to level */ export function nodesBreadthFirst(nodes, edges) { const result = { @@ -132,7 +132,7 @@ export function nodesBreadthFirst(nodes, edges) { // mapping of node name to edges that point into that node const name2EdgesIn = edges.reduce( (db, edge) => { - // At some point the d3 force layout converts edge.source + // At some point the d3 force layout converts edge.source // and edge.target into node references ... const targetName = typeof edge.target === 'object' ? edge.target.name : edge.target; if (db[targetName]) { @@ -142,7 +142,7 @@ export function nodesBreadthFirst(nodes, edges) { } return db; }, - // initialize emptyDb - include nodes that have no incoming edges (leaves) + // initialize emptyDb - include nodes that have no incoming edges (leaves) nodes.reduce((emptyDb, node) => { emptyDb[node.name] = []; return emptyDb; }, {}), ); @@ -163,7 +163,7 @@ export function nodesBreadthFirst(nodes, edges) { } // queue.shift is O(n), so just keep pushing, and move the head - for (let head = 0; head < queue.length; head++) { + for (let head = 0; head < queue.length; head += 1) { const { query, level } = queue[head]; // breadth first result.bfOrder.push(query); processedNodes.add(query); @@ -174,7 +174,7 @@ export function nodesBreadthFirst(nodes, edges) { result.name2Level[query] = level; name2EdgesIn[query].forEach( (edge) => { - // At some point the d3 force layout converts edge.source + // At some point the d3 force layout converts edge.source // and edge.target into node references ... const sourceName = typeof edge.source === 'object' ? edge.source.name : edge.source; if (name2EdgesIn[sourceName]) { @@ -200,13 +200,13 @@ export function nodesBreadthFirst(nodes, edges) { /** * Decorate the nodes of a graph with a position based on the node's position in the graph * Exported for testing. Decorates nodes with position property array [x,y] on a [0,1) space - * + * * @method assignNodePositions * @param nodes * @param edges - * @param opts {breadthFirstInfo,numPerRow} breadthFirstInfo is output + * @param opts {breadthFirstInfo,numPerRow} breadthFirstInfo is output * from nodesBreadthFirst - otherwise call it ourselves, - * numPerRow specifies number of nodes per row if we want a + * numPerRow specifies number of nodes per row if we want a * grid under the root rather than the tree structure */ export function assignNodePositions(nodes, edges, opts) { diff --git a/src/Explorer/ExplorerComponent.jsx b/src/Explorer/ExplorerComponent.jsx index b69ecd7ac7..66206ee77b 100644 --- a/src/Explorer/ExplorerComponent.jsx +++ b/src/Explorer/ExplorerComponent.jsx @@ -1,17 +1,16 @@ 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'; -import { BodyBackground } from './style'; const gqlHelper = GQLHelper.getGQLHelper(); class ExplorerComponent extends Component { /** * Subscribe to Redux updates at mount time - */ + */ componentWillMount() { getReduxStore().then( (store) => { @@ -145,11 +144,11 @@ class ExplorerComponent extends Component { render() { this.updateFilesMap(); - const flexBox = { + const style = { display: 'flex', }; return ( -
+
@@ -158,7 +157,7 @@ class ExplorerComponent extends Component { } export const RelayExplorerComponent = createRefetchContainer( - withBoxAndNav(withAuthTimeout(ExplorerComponent), BodyBackground), + ExplorerComponent, { viewer: gqlHelper.explorerPageFragment, }, diff --git a/src/Explorer/ExplorerSideBar.jsx b/src/Explorer/ExplorerSideBar.jsx index bebfc92b5b..086f9eb75f 100644 --- a/src/Explorer/ExplorerSideBar.jsx +++ b/src/Explorer/ExplorerSideBar.jsx @@ -55,21 +55,21 @@ class ExplorerSideBar extends Component { listItems={projects} title="Projects" selectedItems={this.props.selectedFilters.projects} - group_name="projects" + groupName="projects" onChange={state => this.props.onChange({ ...this.props.selectedFilters, ...state })} /> this.props.onChange({ ...this.props.selectedFilters, ...state })} /> this.props.onChange({ ...this.props.selectedFilters, ...state })} /> 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/Explorer/ReduxExplorer.test.jsx b/src/Explorer/ReduxExplorer.test.jsx index bd72fe9130..5f53c1e205 100644 --- a/src/Explorer/ReduxExplorer.test.jsx +++ b/src/Explorer/ReduxExplorer.test.jsx @@ -3,6 +3,7 @@ import { mount } from 'enzyme'; import { Provider } from 'react-redux'; import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; import { ThemeProvider } from 'styled-components'; +import { StaticRouter } from 'react-router-dom'; import { getReduxStore } from '../reduxStore'; import { theme } from '../theme'; @@ -18,7 +19,9 @@ function renderComponent(ComponentClass, props) { - + + + , diff --git a/src/Explorer/style.js b/src/Explorer/style.js index dbfc2ca057..53ad51de90 100644 --- a/src/Explorer/style.js +++ b/src/Explorer/style.js @@ -114,8 +114,6 @@ export const ExplorerTabBox = styled.div` display:${props => (props.active ? 'block' : 'none')}; `; -export const BodyBackground = '#ecebeb'; - export const ExplorerTableRow = styled.tr` ${TableRow}; overflow: visible; diff --git a/src/GraphQLEditor/ReduxGqlEditor.js b/src/GraphQLEditor/ReduxGqlEditor.js index 644e744342..046ed9b67e 100644 --- a/src/GraphQLEditor/ReduxGqlEditor.js +++ b/src/GraphQLEditor/ReduxGqlEditor.js @@ -1,30 +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..d6eee14b54 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'; @@ -20,7 +20,7 @@ const FloatBox = styled.div` /** * Component shows stacked-bars - one stacked-bar for each project in props.projectList - * where experiments are stacked on top of cases. projectList looks like: - * + * * const data = [ * {name: 'bpa-test', experimentCount: 4000, caseCount: 2400, aliquotCount: 2400}, * ... diff --git a/src/Homepage/ProjectDashboard.jsx b/src/Homepage/ProjectDashboard.jsx index abef17b448..9250509180 100644 --- a/src/Homepage/ProjectDashboard.jsx +++ b/src/Homepage/ProjectDashboard.jsx @@ -91,15 +91,15 @@ class CountCard extends React.Component { * props { caseCount, experimnentCount, fileCount, aliquoteCount, projectList * } * where - * - * const projectList = [ + * + * const projectList = [ * {name: 'bpa-test', experiments: 4000, cases: 2400, amt: 2400}, * {name: 'ProjectB', experiments: 3000, cases: 1398, amt: 2210}, * {name: 'ProjectC', experiments: 2000, cases: 9800, amt: 2290}, * {name: 'ProjectD', experiments: 2780, cases: 3908, amt: 2000}, * {name: 'ProjectE', experiments: 1890, cases: 4800, amt: 2181}, * {name: 'ProjectRye', experiments: 2390, cases: 3800, amt: 2500}, - * + * * ]; */ export class LittleProjectDashboard extends React.Component { diff --git a/src/Homepage/ProjectTable.jsx b/src/Homepage/ProjectTable.jsx index f66a3b25d2..e73e91c1e3 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'; @@ -90,9 +90,9 @@ export class ProjectTR extends React.Component { */ /** - * Table of projects. + * Table of projects. * Has projectList property where each entry has the properties - * for a project detail, and a summaryCounts property with + * for a project detail, and a summaryCounts property with * prefetched totals (property details may be fetched lazily via Relay, whatever ...) */ export class ProjectTable extends React.Component { diff --git a/src/Homepage/ProjectTable.test.jsx b/src/Homepage/ProjectTable.test.jsx index c3e1767fac..fbbee45084 100644 --- a/src/Homepage/ProjectTable.test.jsx +++ b/src/Homepage/ProjectTable.test.jsx @@ -1,7 +1,9 @@ import React from 'react'; -import { ProjectTable } from './ProjectTable'; import { mount } from 'enzyme'; import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; +import { StaticRouter } from 'react-router-dom'; + +import { ProjectTable } from './ProjectTable'; test('Project table renders', () => { @@ -22,7 +24,9 @@ test('Project table renders', () => { // Material-UI components require the Mui theme ... const table = mount( - + + + , ); console.log(`ProjectTable looks like this: ${table.html()}`); diff --git a/src/Homepage/ReduxProjectBarChart.js b/src/Homepage/ReduxProjectBarChart.js index 21a957b1c1..88e2250ed7 100644 --- a/src/Homepage/ReduxProjectBarChart.js +++ b/src/Homepage/ReduxProjectBarChart.js @@ -5,7 +5,7 @@ import Translator from './translate'; const tor = Translator.getTranslator(); -// Map state.homepage.projectsByName to projectList +// Map state.homepage.projectsByName to projectList const mapStateToProps = (state) => { if (state.homepage && state.homepage.projectsByName) { const projectList = Object.values(state.homepage.projectsByName) diff --git a/src/Homepage/ReduxProjectDashboard.js b/src/Homepage/ReduxProjectDashboard.js index 2f43cd4d37..cbd775c293 100644 --- a/src/Homepage/ReduxProjectDashboard.js +++ b/src/Homepage/ReduxProjectDashboard.js @@ -3,7 +3,7 @@ import { DashboardWith } from './ProjectDashboard'; import { ProjectTable } from './ProjectTable'; -// Map state.homepage.projectsByName to projectList +// Map state.homepage.projectsByName to projectList const mapStateToProps = (state) => { if (state.homepage && state.homepage.projectsByName) { const projectList = Object.values(state.homepage.projectsByName); diff --git a/src/Homepage/RelayHomepage.jsx b/src/Homepage/RelayHomepage.jsx index 31c68a1cc1..969af90988 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'; @@ -17,7 +16,7 @@ const DashboardWithRelayTable = DashboardWith(RelayProjectTable); * Relay modern QueryRenderer rendered ProjectDashboard. * Note - this is exported to support testing - it's really an module-private class * that corrdinates data-collection on the homepage. - * + * * @see https://medium.com/@ven_korolyov/relay-modern-refetch-container-c886296448c7 * @see https://facebook.github.io/relay/docs/query-renderer.html */ @@ -27,8 +26,8 @@ export class RelayProjectDashboard extends React.Component { * The ReduxProjectBarChart renders a graph with project-details * as data flows into redux from Relay (RelayProjectTable supplements * redux with per-project details). - * - * @param {Array} projectList + * + * @param {Array} projectList */ static async updateRedux({ projectList, summaryCounts }) { // Update redux store if data is not already there @@ -51,7 +50,7 @@ export class RelayProjectDashboard extends React.Component { /** * Translate relay properties to {summaryCounts, projectList} structure * that is friendly to underlying components. - * + * * @param relayProps * @return {projectList, summaryCounts} */ @@ -107,7 +106,7 @@ export class RelayProjectDashboard extends React.Component { } -const RelayHomepage = withBoxAndNav(withAuthTimeout(RelayProjectDashboard)); +const RelayHomepage = RelayProjectDashboard; /** diff --git a/src/Homepage/RelayProjectTable.jsx b/src/Homepage/RelayProjectTable.jsx index dab656ed53..0b02f870ed 100644 --- a/src/Homepage/RelayProjectTable.jsx +++ b/src/Homepage/RelayProjectTable.jsx @@ -18,13 +18,13 @@ const gqlHelper = GQLHelper.getGQLHelper(); * Not a normal relay fragment container. * Overrides rowRender in ProjectTable parent class to fetch row data via Relay QueryRender. * Assumes higher level container injects the original undetailed list of projects. - * + * */ export class RelayProjectTable extends ProjectTable { /** * Overrides rowRender in ProjectTable parent class to fetch row data via Relay QueryRender. - * - * @param {Object} proj + * + * @param {Object} proj */ rowRender(proj) { return ( { let next = basename; - if (Object.keys(props.location.query).length !== 0) { - next = basename === '/' ? props.location.query.next : basename + props.location.query.next; + const location = props.location; // this is the react-router "location" + const queryParams = querystring.parse(location.search ? location.search.replace(/^\?+/, '') : ''); + if (queryParams.next) { + next = basename === '/' ? queryParams.next : basename + queryParams.next; } return (

{appname}

- {login.title} + {login.title}
diff --git a/src/Login/ProtectedContent.jsx b/src/Login/ProtectedContent.jsx new file mode 100644 index 0000000000..8693afbe3e --- /dev/null +++ b/src/Login/ProtectedContent.jsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; + +import { fetchUser, fetchOAuthURL, fetchJsonOrText, fetchProjects } from '../actions'; +import Spinner from '../components/Spinner'; +import { getReduxStore } from '../reduxStore'; +import { requiredCerts, submissionApiOauthPath } from '../configs'; +import ReduxAuthTimeoutPopup from '../Popup/ReduxAuthTimeoutPopup'; + +let lastAuthMs = 0; +let lastTokenRefreshMs = 0; + +const Body = styled.div` + background: ${props => props.background}; + padding: ${props => props.padding || '50px 100px'}; +`; + +/** + * Redux listener - just clears auth-cache on logout + */ +export function logoutListener(state = {}, action) { + switch (action.type) { + case 'RECEIVE_API_LOGOUT': + lastAuthMs = 0; + lastTokenRefreshMs = 0; + break; + default: // noop + } + return state; +} + +/** + * 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 key2Count = aList.concat(bList).reduce( + (db, it) => { if (db[it]) { db[it] += 1; } else { db[it] = 1; } return db; }, + {}, + ); + return Object.entries(key2Count) + .filter(kv => kv[1] > 1) + .map(([k]) => k); +} + +/** + * Container for components that require authentication to access. + * Takes a few properties + * @param component required child component + * @param location from react-router + * @param history from react-router + * @param match from react-router.match + * @param public default false - set true to disable auth-guard + * @param background passed through to wrapper for page-level background + * @param filter {() => Promise} optional filter to apply before rendering the child component + */ +class ProtectedContent extends React.Component { + static propTypes = { + component: PropTypes.func.isRequired, + location: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + match: PropTypes.shape( + { + params: PropTypes.object, + path: PropTypes.string, + }, + ).isRequired, + public: PropTypes.bool, + background: PropTypes.string, + filter: PropTypes.func, + }; + + static defaultProps = { + public: false, + background: null, + filter: () => Promise.resolve('ok'), + }; + + constructor(props, context) { + super(props, context); + this.state = { + authenticated: false, + redirectTo: null, + }; + } + + + /** + * We start out in an unauthenticatd state - after mount do + * the checks to see if the current session is authenticated + * in the various ways we want it to be. + */ + componentDidMount() { + if (!this.props.public) { + getReduxStore().then( + store => + Promise.all( + [ + store.dispatch({ type: 'CLEAR_COUNTS' }), // clear some counters + store.dispatch({ type: 'CLEAR_QUERY_NODES' }), + ], + ).then( + () => this.checkLoginStatus(store) + .then(newState => this.checkQuizStatus(newState)) + .then(newState => this.checkApiToken(store, newState)), + ).then( + (newState) => { + const filterPromise = (newState.authenticated && typeof this.props.filter === 'function') ? this.props.filter() : Promise.resolve('ok'); + const finish = () => this.setState(newState); // finally update the component state + return filterPromise.then(finish, finish); + }, + ), + ); + } + } + + /** + * Start filter the 'newState' for the checkLoginStatus component. + * Check if the user is logged in, and update state accordingly. + * @method checkLoginStatus + * @param {ReduxStore} store + * @return Promise<{redirectTo, authenticated, user}> + */ + checkLoginStatus = (store) => { + const nowMs = Date.now(); + const newState = { + authenticated: true, + redirectTo: null, + user: store.getState().user, + }; + + if (nowMs - lastAuthMs < 60000) { + // assume we're still logged in after 1 minute ... + return Promise.resolve(newState); + } + + return store.dispatch(fetchUser) // make an API call to see if we're still logged in ... + .then( + () => { + const { user } = store.getState(); + newState.user = user; + if (!user.username) { // not authenticated + newState.redirectTo = '/login'; + newState.authenticated = false; + } else { // auth ok - cache it + lastAuthMs = Date.now(); + } + return newState; + }, + ); + }; + + /** + * Filter refreshes the gdc-api token (acquired via oauth with user-api) if necessary. + * @method checkApiToken + * @param store Redux store + * @param initialState + * @return newState passed through + */ + checkApiToken = (store, initialState) => { + const nowMs = Date.now(); + const newState = Object.assign({}, initialState); + + if (!newState.authenticated) { + return Promise.resolve(newState); + } + if (nowMs - lastTokenRefreshMs < 41000) { + return Promise.resolve(newState); + } + return store.dispatch(fetchProjects()) + .then(() => { + // + // The assumption here is that fetchProjects either succeeds or fails. + // If it fails (we won't have any project data), then we need to refresh our api token ... + // + const projects = store.getState().submission.projects; + if (projects) { + // user already has a valid token + return Promise.resolve(newState); + } + // else do the oauth dance + return store.dispatch(fetchOAuthURL(submissionApiOauthPath)) + .then( + oauthUrl => fetchJsonOrText({ path: oauthUrl, dispatch: store.dispatch.bind(store) })) + .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 => store.dispatch(msg), + ) + .then( + // refetch the projects - since the earlier call failed with an invalid token ... + () => store.dispatch(fetchProjects()), + ) + .then( + () => { + lastTokenRefreshMs = Date.now(); + return newState; + }, + () => { + // something went wront - better just re-login + newState.authenticated = false; + newState.redirectTo = '/login'; + return newState; + }, + ); + }); + }; + + /** + * Filter the 'newState' for the ProtectedComponent. + * User needs to take a security quiz before he/she can acquire tokens ... + * something like that + */ + checkQuizStatus = (initialState) => { + const newState = Object.assign(initialState); + + if (!(newState.authenticated && newState.user && newState.user.username)) { + return newState; // NOOP for unauthenticated session + } + const { user } = newState; + // 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.match.path !== '/quiz' && isMissingCerts) { + newState.redirectTo = '/quiz'; + // do not update lastAuthMs (indicates time of last successful auth) + } else if (this.props.match.path === '/quiz' && !isMissingCerts) { + newState.redirectTo = '/'; + } + return newState; + }; + + render() { + const Component = this.props.component; + let params = {}; // router params + if (this.props.match) { + params = this.props.match.params || {}; + } + + window.scrollTo(0, 0); + if (this.state.redirectTo) { + return (); + } else if (this.props.public) { + return ( + + + + ); + } else if (this.state.authenticated) { + return ( + + + + + ); + } + return (); + } +} + +export default ProtectedContent; + diff --git a/src/Login/ProtectedContent.test.jsx b/src/Login/ProtectedContent.test.jsx new file mode 100644 index 0000000000..33ca1c0b5b --- /dev/null +++ b/src/Login/ProtectedContent.test.jsx @@ -0,0 +1,7 @@ +import { 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']); + }); +}); diff --git a/src/Popup/ReduxAuthTimeoutPopup.jsx b/src/Popup/ReduxAuthTimeoutPopup.jsx index 3fe1abfdc0..54043b9821 100644 --- a/src/Popup/ReduxAuthTimeoutPopup.jsx +++ b/src/Popup/ReduxAuthTimeoutPopup.jsx @@ -1,30 +1,39 @@ 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 (); } - return (null); + return null; }, ); - export default ReduxAuthTimeoutPopup; - diff --git a/src/QueryNode/QueryNode.jsx b/src/QueryNode/QueryNode.jsx index 16d0749559..88c992e787 100644 --- a/src/QueryNode/QueryNode.jsx +++ b/src/QueryNode/QueryNode.jsx @@ -1,8 +1,8 @@ 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'; +import PropTypes from 'prop-types'; import { jsonToString, getSubmitPath } from '../utils'; import Popup from '../Popup/Popup'; @@ -65,6 +65,10 @@ const Dropdown = styled(Select)` `; class QueryForm extends React.Component { + static propTypes = { + project: PropTypes.string.isRequired, + }; + constructor(props) { super(props); this.state = { @@ -78,16 +82,16 @@ class QueryForm extends React.Component { event.preventDefault(); const form = event.target; const data = { project: this.props.project }; - const query_param = []; + const queryParam = []; - for (let i = 0; i < form.length; i++) { + for (let i = 0; i < form.length; i += 1) { const input = form[i]; if (input.name && input.value) { - query_param.push(`${input.name}=${input.value}`); + queryParam.push(`${input.name}=${input.value}`); data[input.name] = input.value; } } - const url = `/${this.props.project}/search?${query_param.join('&')}`; + const url = `/${this.props.project}/search?${queryParam.join('&')}`; this.props.onSearchFormSubmit(data, url); } @@ -99,8 +103,8 @@ class QueryForm extends React.Component { } render() { - const nodes_for_query = this.props.nodeTypes.filter(nt => !['program', 'project'].includes(nt)); - const options = nodes_for_query.map(node_type => ({ value: node_type, label: node_type })); + const nodesForQuery = this.props.nodeTypes.filter(nt => !['program', 'project'].includes(nt)); + const options = nodesForQuery.map(nodeType => ({ value: nodeType, label: nodeType })); const state = this.state || {}; return (
@@ -114,7 +118,9 @@ class QueryForm extends React.Component { const Entity = ({ value, project, onUpdatePopup, onStoreNodeInfo }) => { const onDelete = () => { - onStoreNodeInfo({ project, id: value.id }).then(() => onUpdatePopup({ nodedelete_popup: true })); + onStoreNodeInfo({ project, id: value.id }).then( + () => onUpdatePopup({ nodedelete_popup: true }), + ); }; const onView = () => { onStoreNodeInfo({ project, id: value.id }).then(() => onUpdatePopup({ view_popup: true })); @@ -129,23 +135,37 @@ const Entity = ({ value, project, onUpdatePopup, onStoreNodeInfo }) => { ); }; +Entity.propTypes = { + project: PropTypes.string.isRequired, + onUpdatePopup: PropTypes.func, + onStoreNodeInfo: PropTypes.func, +}; + +Entity.defaultProps = { + onUpdatePopup: null, + onStoreNodeInfo: null, +}; + const Entities = ({ value, project, onUpdatePopup, onStoreNodeInfo }) => (
    {value.map(value => )}
); +Entities.propTypes = { + project: PropTypes.string.isRequired, +}; /** * QueryNode shows the details of a particular node */ class QueryNode extends React.Component { - /** + /** * Internal helper to render the "view node" popup if necessary * based on the popups and query_nodes properties attached to this component. - * + * * @param {popups, query_nodes, onUpdatePopup} props including props.popups.view_popup and props.query_nodes state passed into the component by Redux - * @return { state, popupEl } where state (just used for testing) is string one of [viewNode, noPopup], and + * @return { state, popupEl } where state (just used for testing) is string one of [viewNode, noPopup], and * popupEl is either null or a properly configured to render */ renderViewPopup(props) { @@ -156,6 +176,7 @@ class QueryNode extends React.Component { }; if ( + popups && popups.view_popup && query_nodes.query_node ) { @@ -174,12 +195,14 @@ class QueryNode extends React.Component { return popup; } - /** + /** * Internal helper to render the "delete node" popup if necessary * based on the popups and query_nodes properties attached to this component. - * - * @param {params, popups, query_nodes, onUpdatePopup, onDeleteNode, onClearDeleteSession} props including params.project, props.popups and props.query_nodes state passed into the component by Redux - * @return { state, popupEl } where state (just used for testing) is string one of [confirmDelete, waitForDelete, deleteFailed, noPopup], and + * + * @param {params, popups, query_nodes, onUpdatePopup, onDeleteNode, onClearDeleteSession} props including + * params.project, props.popups and props.query_nodes state passed into the component by Redux + * @return { state, popupEl } where state (just used for testing) is + * string one of [confirmDelete, waitForDelete, deleteFailed, noPopup], and * popupEl is either null or a properly configured to render */ renderDeletePopup(props) { @@ -189,7 +212,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 = ; @@ -222,9 +245,9 @@ class QueryNode extends React.Component { } render() { - const { params, ownProps, submission, query_nodes, popups, onSearchFormSubmit, onUpdatePopup, + const { params, submission, query_nodes, popups, onSearchFormSubmit, onUpdatePopup, onDeleteNode, onStoreNodeInfo, - onClearDeleteSession, + onClearDeleteSession, history, } = this.props; const project = params.project; @@ -233,7 +256,11 @@ 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 => ( +export const submitSearchForm = (opts, url, history) => (dispatch) => { const nodeType = opts.node_type; const submitterId = opts.submitter_id || ''; @@ -47,7 +47,7 @@ const submitSearchForm = (opts, url) => ) .then( () => { - if (url) { return dispatch(push(url)); } + if (url && history) { history.push(url); } return null; }, ); @@ -62,7 +62,7 @@ const deleteNode = ({ id, project }) => }) .then( ({ status, data }) => { - // console.log('receive delete'); + // console.log('receive delete'); dispatch(updatePopup({ nodedelete_popup: false, view_popup: false })); switch (status) { @@ -110,18 +110,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 +122,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/QueryNode/reducers.js b/src/QueryNode/reducers.js index 7f8e1403f6..3d7e61f980 100644 --- a/src/QueryNode/reducers.js +++ b/src/QueryNode/reducers.js @@ -1,6 +1,6 @@ import { removeDeletedNode } from '../reducers'; -export const query_nodes = (state = {}, action) => { +const queryNodes = (state = {}, action) => { switch (action.type) { case 'SUBMIT_SEARCH_FORM': return { ...state, search_form: action.data }; @@ -23,3 +23,4 @@ export const query_nodes = (state = {}, action) => { } }; +export default queryNodes; diff --git a/src/Submission/ProjectSubmission.jsx b/src/Submission/ProjectSubmission.jsx index 5c300faa1e..d18cb561c5 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'; @@ -26,33 +26,33 @@ export const Title = styled.h2` const ProjectSubmission = (props) => { // hack to detect if dictionary data is available, and to trigger fetch if not if (!props.dataIsReady) { - props.onGetCounts(props.typeList, props.params.project, props.dictionary); + props.onGetCounts(props.typeList, props.project, props.dictionary); } + // Passing children in as props allows us to swap in different containers - + // dumb, redux, ... const MySubmitForm = props.submitForm; const MySubmitTSV = props.submitTSV; const MyDataModelGraph = props.dataModelGraph; return (
- {props.params.project} + {props.project} { - browse nodes + browse nodes } - + { !props.dataIsReady ? : - } + }
); }; ProjectSubmission.propTypes = { + project: PropTypes.string.isRequired, dataIsReady: PropTypes.bool, - params: PropTypes.shape({ - project: PropTypes.string.isRequired, - }).isRequired, dictionary: PropTypes.object.isRequired, submitForm: PropTypes.func, submitTSV: PropTypes.func, diff --git a/src/Submission/ReduxProjectSubmission.js b/src/Submission/ReduxProjectSubmission.js index 199a80a550..679f5d70d4 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,8 @@ export const updateFileContent = (value, fileType) => (dispatch) => { }; -const submitToServer = (methodIn = 'PUT') => (dispatch, getState) => { - const path = getState().routing.locationBeforeTransitions.pathname.split('-'); +const submitToServer = (fullProject, methodIn = 'PUT') => (dispatch, getState) => { + const path = fullProject.split('-'); const program = path[0]; const project = path.slice(1).join('-'); const submission = getState().submission; @@ -57,71 +56,6 @@ const submitToServer = (methodIn = 'PUT') => (dispatch, getState) => { ).then(msg => dispatch(msg)); }; - -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 => ({ submission: state.submission, @@ -131,7 +65,7 @@ const ReduxSubmitTSV = (() => { const mapDispatchToProps = dispatch => ({ onUploadClick: (value, type) => dispatch(uploadTSV(value, type)), onSubmitClick: (type, project, dictionary) => - dispatch(submitToServer()) + dispatch(submitToServer(project)) .then( () => { // Update node counts in redux @@ -159,13 +93,14 @@ const ReduxSubmitForm = (() => { const ReduxProjectSubmission = (() => { - const mapStateToProps = state => ({ + const mapStateToProps = (state, ownProps) => ({ typeList: state.submission.nodeTypes, dataIsReady: !!state.submission.counts_search, dictionary: state.submission.dictionary, submitForm: ReduxSubmitForm, submitTSV: ReduxSubmitTSV, dataModelGraph: ReduxDataModelGraph, + project: ownProps.params.project, }); const mapDispatchToProps = dispatch => ({ diff --git a/src/Submission/SubmissionResult.jsx b/src/Submission/SubmissionResult.jsx index c5637a9d3c..efab70a014 100644 --- a/src/Submission/SubmissionResult.jsx +++ b/src/Submission/SubmissionResult.jsx @@ -36,9 +36,9 @@ const summaryListStyle = { /** * Present summary of a submission success or failure given * the HTTP status code and response data object. - * + * * @param {number} status - * @param {object} data + * @param {object} data */ class SubmissionResult extends React.Component { constructor(props, context) { diff --git a/src/Submission/SubmitForm.jsx b/src/Submission/SubmitForm.jsx index 2e1fc8b628..6198288175 100644 --- a/src/Submission/SubmitForm.jsx +++ b/src/Submission/SubmitForm.jsx @@ -333,9 +333,9 @@ const SubmitNodeForm = ({ node, form, properties, requireds, onChange, onChangeE /** - * Form-based data submission. The results of this form submission are subsequently + * Form-based data submission. The results of this form submission are subsequently * processed by the SubmitTSV component, and treated - * the same way uploaded tsv/json data is treated. + * the same way uploaded tsv/json data is treated. */ class SubmitForm extends Component { static propTypes = { diff --git a/src/Submission/SubmitTSV.jsx b/src/Submission/SubmitTSV.jsx index deb2bc1128..cb39e87575 100644 --- a/src/Submission/SubmitTSV.jsx +++ b/src/Submission/SubmitTSV.jsx @@ -12,11 +12,11 @@ import SubmissionResult from './SubmissionResult'; /** * Manage TSV/JSON submission - * - * @param {string} path usually just the project id + * + * @param {string} project of form program-project * @param {Function} onFileChange triggered when user edits something in tsv/json AceEditor */ -const SubmitTSV = ({ path, submission, onUploadClick, onSubmitClick, onFileChange }) => { +const SubmitTSV = ({ project, submission, onUploadClick, onSubmitClick, onFileChange }) => { // // Reads the bytes from the tsv/json file the user submits, // then notify onUploadClick listener which might stuff data @@ -57,7 +57,7 @@ const SubmitTSV = ({ path, submission, onUploadClick, onSubmitClick, onFileChang }; const onSubmitClickEvent = () => { - onSubmitClick(submission.nodeTypes, path, submission.dictionary); + onSubmitClick(submission.nodeTypes, project, submission.dictionary); }; const onChange = (newValue) => { @@ -91,7 +91,7 @@ const SubmitTSV = ({ path, submission, onUploadClick, onSubmitClick, onFileChang }; SubmitTSV.propTypes = { - path: PropTypes.string.isRequired, + project: PropTypes.string.isRequired, // from react-router submission: PropTypes.shape({ file: PropTypes.string, file_type: PropTypes.string, diff --git a/src/Submission/SubmitTSV.test.jsx b/src/Submission/SubmitTSV.test.jsx index 6d34525ee4..7f30c68e1e 100644 --- a/src/Submission/SubmitTSV.test.jsx +++ b/src/Submission/SubmitTSV.test.jsx @@ -9,19 +9,19 @@ describe('the TSV submission componet', () => { /** * Little helper for constructing a jest/enzyme test - * - * @param {file, submit_result, submit_status} submission property passed through to + * + * @param {file, submit_result, submit_status} submission property passed through to * @param {function} submitCallback invoked by onSubmitClick property on - * @return enzymejs wrapper of with properties from params + * @return enzymejs wrapper of with properties from params */ function buildTest(submission = { file: '', submit_result: '', submit_status: 200 }, submitCallback = () => {}) { const $dom = mount( { console.log('onUpload'); }} - onSubmitClick={(typeStr, path, dict) => { console.log('onSubmitClick'); submitCallback(typeStr, path, dict); }} + onSubmitClick={(typeStr, project, dict) => { console.log('onSubmitClick'); submitCallback(typeStr, project, dict); }} onFileChange={() => { console.log('onFileChange'); }} /> , @@ -40,9 +40,9 @@ describe('the TSV submission componet', () => { it('shows a "submit" button when a tsv or json file has been uploaded', () => { const state = { file: JSON.stringify({ type: 'whatever', submitter_id: 'frickjack' }), submit_result: '', submit_status: 200 }; return new Promise((resolve) => { - const { $dom } = buildTest(state, (typeStr, path) => { + const { $dom } = buildTest(state, (typeStr, project) => { // This function runs when the 'Submit' button is clicked - expect(path).toBe(testProjName); + expect(project).toBe(testProjName); resolve('ok'); }); expect($dom.find('label[id="cd-submit-tsv__upload-button"]').length).toBe(1); diff --git a/src/UserProfile/ReduxUserProfile.js b/src/UserProfile/ReduxUserProfile.js index 7c95a97ee0..8d67d1446c 100644 --- a/src/UserProfile/ReduxUserProfile.js +++ b/src/UserProfile/ReduxUserProfile.js @@ -1,12 +1,11 @@ import { connect } from 'react-redux'; import UserProfile from './UserProfile'; -import { fetchOAuthURL, fetchJsonOrText, updatePopup } from '../actions'; +import { fetchJsonOrText, updatePopup } from '../actions'; import { submissionApiOauthPath, credentialCdisPath } from '../localconf'; -import { fetchProjects } from '../queryactions'; -const fetchAccess = () => +export const fetchAccess = () => dispatch => fetchJsonOrText({ path: credentialCdisPath, @@ -31,58 +30,6 @@ const fetchAccess = () => .then(msg => dispatch(msg)); -const fetchUserApiLogin = userOauthUrl => - dispatch => - fetchJsonOrText( - { - path: userOauthUrl, - dispatch, - }, - ) - .then( - ({ status, data }) => { - switch (status) { - case 200: - return { - type: 'RECEIVE_USERAPI_LOGIN', - result: true, - }; - default: - return { - type: 'RECEIVE_USERAPI_LOGIN', - result: false, - error: data, - }; - } - }) - .then(msg => dispatch(msg)) - ; - -export const loginUserProfile = () => - // Fetch projects, if unauthorized, login - (dispatch, getState) => - dispatch(fetchAccess()) - .then( - () => { - const keypair = getState().userProfile.access_key_pair; - if (keypair) { - // user already logged in - return Promise.reject('already logged in'); - } - return Promise.resolve(); - }) - .then( - () => dispatch(fetchOAuthURL(submissionApiOauthPath)), - ) - .then( - oauthUrl => dispatch(fetchUserApiLogin(oauthUrl)), - ) - .then(() => dispatch(fetchAccess())) - .then(() => dispatch(fetchProjects())) - .catch(error => console.log(error)) - ; - - const requestDeleteKey = accessKey => ({ type: 'REQUEST_DELETE_KEY', access_key: accessKey, 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/UserProfile/UserProfile.test.jsx b/src/UserProfile/UserProfile.test.jsx index 3e303b7d4a..4f6fba64a4 100644 --- a/src/UserProfile/UserProfile.test.jsx +++ b/src/UserProfile/UserProfile.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; +import { StaticRouter } from 'react-router-dom'; import UserProfile, { AccessKeyCell, DeleteButton, RequestButton } from './UserProfile'; @@ -28,29 +29,34 @@ describe('the UserProfile component', () => { it('lists access keys', () => { const $vdom = mount( - ); + + + , + ); expect($vdom.find(AccessKeyCell)).toHaveLength(testProps.userProfile.access_key_pairs.length); }); it('triggers create-key events', (done) => { const $vdom = mount( - { done(); }} - onClearCreationSession={noop} - onUpdatePopup={noop} - onDeleteKey={noop} - onRequestDeleteKey={noop} - onClearDeleteSession={noop} - />); + + { done(); }} + onClearCreationSession={noop} + onUpdatePopup={noop} + onDeleteKey={noop} + onRequestDeleteKey={noop} + onClearDeleteSession={noop} + /> + ); const $createBtn = $vdom.find(RequestButton); expect($createBtn).toHaveLength(1); $createBtn.simulate('click'); @@ -59,15 +65,18 @@ describe('the UserProfile component', () => { it('triggers delete-key events', (done) => { const $vdom = mount( - { done(); }} - onClearDeleteSession={noop} - />); + + { done(); }} + onClearDeleteSession={noop} + /> + , + ); const $deleteBtn = $vdom.find(DeleteButton); expect($deleteBtn).toHaveLength(2); $deleteBtn.at(0).simulate('click'); diff --git a/src/actions.js b/src/actions.js index 62f71efc1f..dfe35c7756 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 { apiPath, userapiPath, headers, basename, submissionApiOauthPath, submissionApiPath, graphqlPath, graphqlSchemaUrl } from './configs'; export const updatePopup = state => ({ @@ -16,19 +15,29 @@ 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 +52,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 } @@ -57,15 +69,15 @@ export const fetchJsonOrText = (opts) => { }; /** - * Redux 'thunk' wrapper around fetchJsonOrText + * Redux 'thunk' wrapper around fetchJsonOrText * invokes dispatch(handler( { status, data, headers} ) and callback() - * and propagates {response,data, status, headers} on resolved fetch, + * and propagates {response,data, status, headers} on resolved fetch, * otherwise dipatch(connectionError()) on fetch rejection. * May prefer this over straight call to fetchJsonOrText in Redux context due to - * conectionError() dispatch on fetch rejection. - * + * conectionError() dispatch on fetch rejection. + * * @param {path,method=GET,body=null,customerHeaders,handler,callback} opts - * @return Promise + * @return Promise */ export const fetchWrapper = ({ path, method = 'GET', body = null, customHeaders, handler, callback = () => (null) }) => dispatch => fetchJsonOrText({ path, method, body, customHeaders, dispatch }, @@ -134,7 +146,7 @@ export const fetchUser = dispatch => fetchJsonOrText({ case 401: return { type: 'UPDATE_POPUP', - data: { auth_popup: true }, + data: { authPopup: true }, }; default: return { @@ -145,37 +157,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()); - export const logoutAPI = () => dispatch => fetchJsonOrText({ path: `${submissionApiOauthPath}logout`, @@ -187,32 +168,122 @@ 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( + useCache: true, + }) + .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'); + }, + ); + + +/* + * redux-thunk support asynchronous redux actions via 'thunks' - + * lambdas that accept dispatch and getState functions as arguments + */ + +export const fetchProjects = () => dispatch => + fetchJsonOrText({ + path: `${submissionApiPath}graphql`, + body: JSON.stringify({ + query: 'query Test { project(first:10000) {code, project_id}}', + }), + method: 'POST', + }) + .then( + ({ status, data }) => { + switch (status) { + case 200: + return { + type: 'RECEIVE_PROJECTS', + data: data.data.project, + }; + default: + return { + type: 'FETCH_ERROR', + error: data, + }; + } + }) + .then(msg => dispatch(msg)); + + +/** + * 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 { - type: 'RECEIVE_AUTHORIZATION_URL', - url: data, - }; + return dispatch( + { + type: 'RECEIVE_SCHEMA_LOGIN', + schema: data, + }, + ); default: - return { - type: 'FETCH_ERROR', - error: data.error, - }; + return Promise.resolve('NOOP'); } }, - ).then( - (msg) => { - dispatch(msg); - if (msg.url) { - return msg.url; - } - throw new Error('OAuth authorization failed'); - }, ); + + +export const fetchDictionary = dispatch => + fetchJsonOrText({ + path: `${submissionApiPath}_dictionary/_all`, + method: 'GET', + useCache: true, + }) + .then( + ({ status, data }) => { + switch (status) { + case 200: + return { + type: 'RECEIVE_DICTIONARY', + data, + }; + default: + return { + type: 'FETCH_ERROR', + error: data, + }; + } + }) + .then(msg => dispatch(msg)); + + +export const fetchVersionInfo = () => + fetchJsonOrText({ path: `${apiPath}_version`, method: 'GET', useCache: true }); diff --git a/src/components/CheckBox.jsx b/src/components/CheckBox.jsx index 597ffef5c9..426fafbdbc 100644 --- a/src/components/CheckBox.jsx +++ b/src/components/CheckBox.jsx @@ -23,8 +23,8 @@ const CheckBox = styled.div` export class CheckBoxGroup extends Component { static propTypes = { - listItems: PropTypes.array.isRequired, - group_name: PropTypes.string.isRequired, + listItems: PropTypes.arrayOf(PropTypes.string).isRequired, + groupName: PropTypes.string.isRequired, selectedItems: PropTypes.array.isRequired, title: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, @@ -50,7 +50,7 @@ export class CheckBoxGroup extends Component { } else { selectedItems = [...this.props.selectedItems.slice(0, pos), ...this.props.selectedItems.slice(pos + 1)]; } - const state = { [this.props.group_name]: selectedItems }; + const state = { [this.props.groupName]: selectedItems }; this.props.onChange(state); }; @@ -68,7 +68,7 @@ export class CheckBoxGroup extends Component {

{payload[0].payload.name}

@@ -31,12 +31,18 @@ class CustomTooltip extends React.Component { return null; } -} -CustomTooltip.propTypes = { - payload: PropTypes.array, - label: PropTypes.string, -}; + static propTypes = { + payload: PropTypes.array.isRequired, + label: PropTypes.string, + active: PropTypes.bool, + }; + + static defaultProps = { + label: '', + active: false, + }; +} const COLORS = ['#8884d8', '#00C49F', '#FFBB28', '#FF8042']; diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index b14b881af6..ddb5f1a285 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 = { + dictionaryVersion: null, + apiVersion: null, }; Footer.defaultProps = { diff --git a/src/components/Footer.test.jsx b/src/components/Footer.test.jsx index 456b226d1c..7a9f349846 100644 --- a/src/components/Footer.test.jsx +++ b/src/components/Footer.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; +import { StaticRouter } from 'react-router-dom'; import Footer from './Footer'; @@ -8,7 +9,11 @@ describe('The Footer component', () => { Object.assign(Footer.defaultProps, { dictionaryVersion: 'test.test.test', apiVersion: 'api.api.api' }, ); - const footer = mount(