diff --git a/cbuildci.yml b/.cbuildci.yml
similarity index 83%
rename from cbuildci.yml
rename to .cbuildci.yml
index a905798..22462fb 100644
--- a/cbuildci.yml
+++ b/.cbuildci.yml
@@ -4,3 +4,4 @@ builds:
build:
timeoutInMinutes: 5
image: 'aws/codebuild/nodejs:8.11.0'
+ useCache: true
diff --git a/app/app.js b/app/app.js
index 074df2c..3610f8b 100644
--- a/app/app.js
+++ b/app/app.js
@@ -16,8 +16,14 @@ import { ConnectedRouter } from 'connected-react-router/immutable';
import createHistory from 'history/createBrowserHistory';
// import 'sanitize.css/sanitize.css';
-// Import root app
+// Import root app and constants
import App from 'containers/App';
+import {
+ WINDOW_BLUR,
+ WINDOW_FOCUS,
+ WINDOW_VISIBLE,
+ WINDOW_HIDDEN,
+} from 'containers/App/constants';
// Import providers
import LanguageProvider from 'containers/LanguageProvider';
@@ -49,6 +55,23 @@ history.listen((location, action) => {
});
const store = configureStore(initialState, history);
+
+// Dispatch events on window focus/visibility events.
+window.addEventListener('blur', () => {
+ store.dispatch({ type: WINDOW_BLUR });
+});
+window.addEventListener('focus', () => {
+ store.dispatch({ type: WINDOW_FOCUS });
+});
+window.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ store.dispatch({ type: WINDOW_VISIBLE });
+ }
+ if (document.visibilityState === 'hidden') {
+ store.dispatch({ type: WINDOW_HIDDEN });
+ }
+});
+
const MOUNT_NODE = document.getElementById('app');
const render = (messages) => {
diff --git a/app/components/CommitMeta/index.js b/app/components/CommitMeta/index.js
index a5c0d2e..ba2cf49 100644
--- a/app/components/CommitMeta/index.js
+++ b/app/components/CommitMeta/index.js
@@ -15,7 +15,7 @@ function CommitMeta({
{author.login && (
should render expected JSX for "opened" pull r
synchronize {{actionHtml} to}
other {{actionHtml}}
}
- {pullRequestLinkHtml}"
+ {pullRequestLinkHtml} by {usernameHtml}"
id="app.components.ExecutionStartMessage.startedForPullRequest"
values={
Object {
@@ -27,7 +27,7 @@ exports[` should render expected JSX for "opened" pull r
}
/>
,
- "event": "pull_request",
+ "eventType": "pull_request",
"pullRequestLinkHtml":
@@ -51,6 +51,12 @@ exports[` should render expected JSX for "opened" pull r
/>
,
+ "username": "bsmith",
+ "usernameHtml":
+ bsmith
+ ,
}
}
/>
@@ -86,6 +92,12 @@ exports[` should render expected JSX for "opened" pull r
pull request #5
+ by
+
+ bsmith
+
`;
@@ -98,7 +110,7 @@ exports[` should render expected JSX for "rerun" user ev
id="app.components.ExecutionStartMessage.startedByUserAction"
values={
Object {
- "event": "check_run",
+ "eventType": "check_run",
"identifier": "rerun",
"identifierHtml":
should render expected JSX for "rerun" user ev
/>
,
- "username": "amekkawi-office",
+ "username": "bsmith",
"usernameHtml":
- @
- amekkawi-office
+ bsmith
,
}
}
@@ -159,9 +170,9 @@ exports[` should render expected JSX for "rerun" user ev
initiated by
- @amekkawi-office
+ bsmith
`;
@@ -173,7 +184,7 @@ exports[` should render expected JSX for "synchronize" p
synchronize {{actionHtml} to}
other {{actionHtml}}
}
- {pullRequestLinkHtml}"
+ {pullRequestLinkHtml} by {usernameHtml}"
id="app.components.ExecutionStartMessage.startedForPullRequest"
values={
Object {
@@ -193,7 +204,7 @@ exports[` should render expected JSX for "synchronize" p
}
/>
,
- "event": "pull_request",
+ "eventType": "pull_request",
"pullRequestLinkHtml":
@@ -217,6 +228,12 @@ exports[` should render expected JSX for "synchronize" p
/>
,
+ "username": "bsmith",
+ "usernameHtml":
+ bsmith
+ ,
}
}
/>
@@ -252,16 +269,22 @@ exports[` should render expected JSX for "synchronize" p
pull request #5
+ by
+
+ bsmith
+
`;
exports[` should render expected JSX for unknown event 1`] = `
should render expected JSX for unknown user ev
id="app.components.ExecutionStartMessage.startedByUserAction"
values={
Object {
- "event": "check_run",
+ "eventType": "check_run",
"identifier": "foobar",
"identifierHtml":
should render expected JSX for unknown user ev
/>
,
- "username": "amekkawi-office",
+ "username": "bsmith",
"usernameHtml":
- @
- amekkawi-office
+ bsmith
,
}
}
@@ -366,9 +388,9 @@ exports[` should render expected JSX for unknown user ev
initiated by
- @amekkawi-office
+ bsmith
`;
diff --git a/app/components/ExecutionStartMessage/tests/index.test.js b/app/components/ExecutionStartMessage/tests/index.test.js
index bd14884..d0ba374 100644
--- a/app/components/ExecutionStartMessage/tests/index.test.js
+++ b/app/components/ExecutionStartMessage/tests/index.test.js
@@ -22,7 +22,7 @@ describe('', () => {
repo="bar"
createTime={1500000000000}
event={{
- event: 'foobar',
+ type: 'foobar',
}}
/>,
);
@@ -36,11 +36,16 @@ describe('', () => {
repo="bar"
createTime={1500000000000}
event={{
- event: 'pull_request',
+ type: 'pull_request',
action: 'opened',
pull_request: {
number: 5,
},
+ sender: {
+ login: 'bsmith',
+ type: 'User',
+ id: 13106037,
+ },
}}
/>,
);
@@ -54,11 +59,16 @@ describe('', () => {
repo="bar"
createTime={1500000000000}
event={{
- event: 'pull_request',
+ type: 'pull_request',
action: 'synchronize',
pull_request: {
number: 5,
},
+ sender: {
+ login: 'bsmith',
+ type: 'User',
+ id: 13106037,
+ },
}}
/>,
);
@@ -72,13 +82,13 @@ describe('', () => {
repo="bar"
createTime={1500000000000}
event={{
- event: 'check_run',
+ type: 'check_run',
action: 'requested_action',
requested_action: {
identifier: 'rerun',
},
sender: {
- login: 'amekkawi-office',
+ login: 'bsmith',
type: 'User',
id: 13106037,
},
@@ -95,13 +105,13 @@ describe('', () => {
repo="bar"
createTime={1500000000000}
event={{
- event: 'check_run',
+ type: 'check_run',
action: 'requested_action',
requested_action: {
identifier: 'foobar',
},
sender: {
- login: 'amekkawi-office',
+ login: 'bsmith',
type: 'User',
id: 13106037,
},
diff --git a/app/components/ExecutionStopMessage/tests/__snapshots__/index.test.js.snap b/app/components/ExecutionStopMessage/tests/__snapshots__/index.test.js.snap
index 6bdf77d..deefe1f 100644
--- a/app/components/ExecutionStopMessage/tests/__snapshots__/index.test.js.snap
+++ b/app/components/ExecutionStopMessage/tests/__snapshots__/index.test.js.snap
@@ -72,7 +72,6 @@ exports[` should render expected JSX when user requests s
"stopUserHtml":
- @
foo
,
}
@@ -99,7 +98,7 @@ exports[` should render expected JSX when user requests s
- @foo
+ foo
@@ -140,7 +139,6 @@ exports[` should render expected JSX when user requests s
"stopUserHtml":
- @
foo
,
}
@@ -167,7 +165,7 @@ exports[` should render expected JSX when user requests s
- @foo
+ foo
and took
diff --git a/app/components/ExecutionsBreadcrumb/index.js b/app/components/ExecutionsBreadcrumb/index.js
index c0fc5e4..f29571d 100644
--- a/app/components/ExecutionsBreadcrumb/index.js
+++ b/app/components/ExecutionsBreadcrumb/index.js
@@ -23,7 +23,7 @@ function ExecutionsBreadcrumb({
if (repo != null) {
if (commit == null) {
repoHtml = (
-
+
{owner}/{repo}
@@ -46,7 +46,7 @@ function ExecutionsBreadcrumb({
commitHtml = (
- {commit.substr(0, 10)}
+ {commit.substr(0, 10)}
);
}
@@ -55,7 +55,7 @@ function ExecutionsBreadcrumb({
- {commit.substr(0, 10)}
+ {commit.substr(0, 10)}
);
diff --git a/app/components/PageHeader/tests/__snapshots__/index.test.js.snap b/app/components/PageHeader/tests/__snapshots__/index.test.js.snap
index 3513de8..f1ee6e7 100644
--- a/app/components/PageHeader/tests/__snapshots__/index.test.js.snap
+++ b/app/components/PageHeader/tests/__snapshots__/index.test.js.snap
@@ -1,23 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` should render expected JSX 1`] = `
-
+
Foobar
-
+
`;
exports[` should render expected JSX 2`] = `
Foobar
diff --git a/app/components/PhasesTable/tests/__snapshots__/index.test.js.snap b/app/components/PhasesTable/tests/__snapshots__/index.test.js.snap
index d6d6812..ee0eb0b 100644
--- a/app/components/PhasesTable/tests/__snapshots__/index.test.js.snap
+++ b/app/components/PhasesTable/tests/__snapshots__/index.test.js.snap
@@ -10,8 +10,8 @@ exports[` should render expected JSX 1`] = `
|
|
@@ -32,9 +32,7 @@ exports[` should render expected JSX 1`] = `
-
+
|
SUBMITTED
|
@@ -49,9 +47,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
PROVISIONING
|
@@ -66,9 +62,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
DOWNLOAD_SOURCE
|
@@ -83,9 +77,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
INSTALL
|
@@ -100,9 +92,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
PRE_BUILD
|
@@ -117,9 +107,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
BUILD
|
@@ -134,9 +122,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
POST_BUILD
|
@@ -151,9 +137,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
UPLOAD_ARTIFACTS
|
@@ -168,9 +152,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
FINALIZING
|
@@ -185,9 +167,7 @@ exports[` should render expected JSX 1`] = `
/>
-
+
|
COMPLETED
|
@@ -196,7 +176,9 @@ exports[` should render expected JSX 1`] = `
status="SUCCEEDED"
/>
- |
+
+ -
+ |
@@ -212,7 +194,7 @@ exports[` should render expected JSX 2`] = `
|
- Build
+ Phase
|
@@ -442,7 +424,9 @@ exports[` should render expected JSX 2`] = `
- | |
+
+ -
+ |
diff --git a/app/components/RenderErrorPanel/index.js b/app/components/RenderErrorPanel/index.js
new file mode 100644
index 0000000..7257c01
--- /dev/null
+++ b/app/components/RenderErrorPanel/index.js
@@ -0,0 +1,98 @@
+/*
+ * RenderErrorPanel
+ */
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Panel } from '../Panel';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+
+class RenderErrorPanel extends React.Component {
+
+ constructor(props) {
+ super(props);
+
+ this.handleToggleDetail = this.handleToggleDetail.bind(this);
+ }
+
+ state = {
+ showDetail: false,
+ };
+
+ handleToggleDetail() {
+ this.setState({
+ showDetail: !this.state.showDetail,
+ });
+ }
+
+ render() {
+ const { debugMessage } = this.props;
+ const { showDetail } = this.state;
+
+ return (
+
+
+
+
+
+ {debugMessage != null && (
+
+
+
+
+
+
+ {showDetail && (
+
+ {debugMessage}
+
+ )}
+
+
+ )}
+
+ );
+ }
+}
+
+RenderErrorPanel.propTypes = {
+ debugMessage: PropTypes.string,
+};
+
+RenderErrorPanel.defaultProps = {
+ debugMessage: null,
+};
+
+export const panelErrorMessage = (error, info) => {
+ let debugMessages = [];
+
+ try {
+ if (error) {
+ debugMessages.push(`Error Stack\n-------------------------\n${error.stack || error.message || error}`);
+ }
+ }
+ catch (err) {
+ // Ignore
+ }
+
+ try {
+ if (info.componentStack) {
+ debugMessages.push(`Component Stack\n-------------------------${info.componentStack}`);
+ }
+ }
+ catch (err) {
+ // Ignore
+ }
+
+ return (
+
+ );
+};
+
+export default RenderErrorPanel;
diff --git a/app/components/RenderErrorPanel/messages.js b/app/components/RenderErrorPanel/messages.js
new file mode 100644
index 0000000..b4be40e
--- /dev/null
+++ b/app/components/RenderErrorPanel/messages.js
@@ -0,0 +1,16 @@
+/*
+ * RenderErrorPanel Messages
+ */
+
+import { defineMessages } from 'react-intl';
+
+export default defineMessages({
+ bodyMessage: {
+ id: 'app.components.RenderErrorPanel.bodyMessage',
+ defaultMessage: 'An problem was encountered while rendering this part of the page.',
+ },
+ detailButton: {
+ id: 'app.components.RenderErrorPanel.detailButton',
+ defaultMessage: 'Toggle Detail',
+ },
+});
diff --git a/app/components/RenderErrorPanel/tests/index.test.js b/app/components/RenderErrorPanel/tests/index.test.js
new file mode 100644
index 0000000..06e8e5a
--- /dev/null
+++ b/app/components/RenderErrorPanel/tests/index.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import { snapshots } from '../../../../internals/testing/snapshot-util';
+import RenderErrorPanel from '../index';
+
+describe('', () => {
+ it.skip('should render expected JSX', () => {
+ snapshots(
+ ,
+ );
+ });
+});
diff --git a/app/containers/App/LoginModal.js b/app/containers/App/LoginModal.js
index a1853cb..0dfc843 100644
--- a/app/containers/App/LoginModal.js
+++ b/app/containers/App/LoginModal.js
@@ -7,18 +7,21 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import { FormattedMessage } from 'react-intl';
+import { buildApiUrl } from '../../utils/request';
+
import messages from './messages';
import {
- selectLoggingIn,
- selectLoginUrl,
+ selectGithubHost,
+ selectIsLoginRequired,
+ selectEndpoints,
} from './selectors';
export class LoginModal extends React.Component {
static propTypes = {
- loginUrl: PropTypes.string.isRequired,
githubHost: PropTypes.string.isRequired,
+ loginUrl: PropTypes.string.isRequired,
};
constructor(props) {
@@ -81,7 +84,7 @@ export class LoginModal extends React.Component {
/>
-
+
+function LoginModalContaner({ isLoggingIn, ...props }) {
+ return isLoggingIn
+ ?
: null;
}
LoginModalContaner.propTypes = {
- loggingIn: PropTypes.bool,
+ isLoggingIn: PropTypes.bool,
};
LoginModalContaner.defaultProps = {
- loggingIn: false,
+ isLoggingIn: false,
};
const mapStateToProps = createStructuredSelector({
- loggingIn: selectLoggingIn,
- loginUrl: selectLoginUrl,
+ githubHost: selectGithubHost,
+ isLoggingIn: selectIsLoginRequired,
+ loginUrl: (state) => selectEndpoints(state).authRedirectUrl,
});
export default connect(
diff --git a/app/containers/App/StateErrorModal.js b/app/containers/App/StateErrorModal.js
new file mode 100644
index 0000000..1258c7a
--- /dev/null
+++ b/app/containers/App/StateErrorModal.js
@@ -0,0 +1,146 @@
+/**
+ * StateErrorModal
+ */
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import styled from 'styled-components';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+
+import {
+ selectIsUserLoggedIn,
+ selectHasFetchedState,
+ selectStateError,
+} from './selectors';
+
+import {
+ stateRequest,
+} from './actions';
+
+const ErrorDetail = styled.div`
+ margin-top: 1rem;
+ background-color: #EEE;
+ padding: 6px 12px;
+ border-left: 3px solid #666;
+ white-space: pre-wrap;
+`;
+
+export class StateErrorModal extends React.Component {
+
+ static propTypes = {
+ stateError: PropTypes.object.isRequired,
+ onRetry: PropTypes.func.isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ show: false,
+ };
+
+ this.buttonRef = React.createRef();
+ }
+
+ state = {
+ show: false,
+ };
+
+ componentDidMount() {
+ this.buttonRef.current.focus();
+
+ this._showTimeoutId = setTimeout(() => {
+ this.setState({
+ show: true,
+ });
+ }, 10);
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this._showTimeoutId);
+ }
+
+ render() {
+ const { stateError, onRetry } = this.props;
+ const { show } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {stateError.message}
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+function StateErrorModalContaner({
+ isUserLoggedIn,
+ hasFetchedState,
+ stateError,
+ ...props
+}) {
+ return !isUserLoggedIn && !hasFetchedState && stateError
+ ?
+ : null;
+}
+
+StateErrorModalContaner.propTypes = {
+ isUserLoggedIn: PropTypes.bool.isRequired,
+ hasFetchedState: PropTypes.bool.isRequired,
+ stateError: PropTypes.object,
+};
+
+StateErrorModalContaner.defaultProps = {
+ stateError: null,
+};
+
+const mapStateToProps = createStructuredSelector({
+ isUserLoggedIn: selectIsUserLoggedIn,
+ hasFetchedState: selectHasFetchedState,
+ stateError: selectStateError,
+});
+
+const mapDispatchToProps = (dispatch) => bindActionCreators({
+ onRetry: stateRequest,
+}, dispatch);
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(StateErrorModalContaner);
diff --git a/app/containers/App/actions.js b/app/containers/App/actions.js
index 072e62b..9a4fca0 100644
--- a/app/containers/App/actions.js
+++ b/app/containers/App/actions.js
@@ -3,27 +3,46 @@
*/
import {
- LOGIN_REQUEST,
- LOGIN_SUCCESS,
- LOGIN_FAILURE,
+ STATE_RESTORE,
+ STATE_REQUEST,
+ STATE_SUCCESS,
+ STATE_FAILURE,
} from './constants';
-export function loginRequest(loginUrl) {
+export function stateRestore(state) {
return {
- type: LOGIN_REQUEST,
- loginUrl,
+ type: STATE_RESTORE,
+ state,
};
}
-export function fetchExecutionSuccess() {
+export function stateRequest(resetUser = false) {
return {
- type: LOGIN_SUCCESS,
+ type: STATE_REQUEST,
+ resetUser,
};
}
-export function fetchExecutionFailure(error) {
+export function stateSuccess(
+ githubUrl,
+ githubHost,
+ userLogin,
+ userName,
+ endpoints,
+) {
return {
- type: LOGIN_FAILURE,
+ type: STATE_SUCCESS,
+ githubUrl,
+ githubHost,
+ userLogin,
+ userName,
+ endpoints,
+ };
+}
+
+export function stateFailure(error) {
+ return {
+ type: STATE_FAILURE,
error,
};
}
diff --git a/app/containers/App/constants.js b/app/containers/App/constants.js
index 2418c77..60c401a 100644
--- a/app/containers/App/constants.js
+++ b/app/containers/App/constants.js
@@ -2,6 +2,14 @@
* App Constants
*/
-export const LOGIN_REQUEST = 'container/App/LOGIN_REQUEST';
-export const LOGIN_SUCCESS = 'container/App/LOGIN_SUCCESS';
-export const LOGIN_FAILURE = 'container/App/LOGIN_FAILURE';
+export const SESSION_STORAGE_KEY = 'appRestoreState';
+
+export const WINDOW_BLUR = 'container/App/WINDOW_BLUR';
+export const WINDOW_FOCUS = 'container/App/WINDOW_FOCUS';
+export const WINDOW_VISIBLE = 'container/App/WINDOW_VISIBLE';
+export const WINDOW_HIDDEN = 'container/App/WINDOW_HIDDEN';
+
+export const STATE_RESTORE = 'container/App/STATE_RESTORE';
+export const STATE_REQUEST = 'container/App/STATE_REQUEST';
+export const STATE_SUCCESS = 'container/App/STATE_SUCCESS';
+export const STATE_FAILURE = 'container/App/STATE_FAILURE';
diff --git a/app/containers/App/index.js b/app/containers/App/index.js
index 53d731f..03db761 100644
--- a/app/containers/App/index.js
+++ b/app/containers/App/index.js
@@ -8,6 +8,7 @@
import React from 'react';
import { Helmet } from 'react-helmet';
+import { compose } from 'redux';
import { hot } from 'react-hot-loader';
import { Switch, Route } from 'react-router-dom';
@@ -15,12 +16,16 @@ import { ErrorContextProvider } from 'components/ErrorBoundary';
import NotFoundPage from 'containers/NotFoundPage/Loadable';
import TopNav from 'containers/TopNav';
+import reducer, { injectReducer } from './reducer';
+import saga, { injectSaga } from './saga';
+
// Pages
import RepoExecutionsPage from 'containers/RepoExecutionsPage/Loadable';
import CommitExecutionsPage from 'containers/CommitExecutionsPage/Loadable';
import ExecutionDetailPage from 'containers/ExecutionDetailPage/Loadable';
import LoginModal from './LoginModal';
+import StateErrorModal from './StateErrorModal';
const onError = (err, info) => {
// TODO: How to better handle this?
@@ -104,9 +109,17 @@ export function App() {
+
);
}
-export default hot(module)(App);
+const withReducer = injectReducer('app', reducer);
+const withSaga = injectSaga('app', saga);
+
+export default compose(
+ withReducer,
+ withSaga,
+ hot(module)
+)(App);
diff --git a/app/containers/App/messages.js b/app/containers/App/messages.js
index 8f1381a..d18ea07 100644
--- a/app/containers/App/messages.js
+++ b/app/containers/App/messages.js
@@ -5,6 +5,18 @@
import { defineMessages } from 'react-intl';
export default defineMessages({
+ stateErrorModalTitle: {
+ id: 'app.containers.App.stateErrorModalTitle',
+ defaultMessage: 'Failed to Load Application',
+ },
+ stateErrorModalBody: {
+ id: 'app.containers.App.stateErrorModalBody',
+ defaultMessage: 'There was a problem loading the application.', // TODO: Use a better message.
+ },
+ stateErrorModalButton: {
+ id: 'app.containers.App.stateErrorModalButton',
+ defaultMessage: 'Retry',
+ },
loginModalTitle: {
id: 'app.containers.App.loginModalTitle',
defaultMessage: 'Authorization Required',
diff --git a/app/containers/App/reducer.js b/app/containers/App/reducer.js
index dc42ea6..28599bc 100644
--- a/app/containers/App/reducer.js
+++ b/app/containers/App/reducer.js
@@ -1,31 +1,74 @@
+/*
+ * App reducer
+ */
+
import { fromJS } from 'immutable';
+import createInjector from 'utils/injectReducer';
import {
- LOGIN_REQUEST,
+ STATE_RESTORE,
+ STATE_REQUEST,
+ STATE_SUCCESS,
+ STATE_FAILURE,
} from './constants';
+const userInitial = {
+ userName: null,
+ userLogin: null,
+};
+
// The initial state of the App
const initialState = fromJS({
- loggingIn: false,
- loginUrl: null,
- loginError: null,
- isLoggedIn: false,
- userData: null,
- githubHost: 'github.com',
+ hasRestoredState: false,
+ hasFetchedState: false,
+ isFetchingState: false,
+ stateError: null,
+ githubUrl: null,
+ githubHost: null,
+ endpoints: null,
+ ...userInitial,
});
function appReducer(state = initialState, action) {
switch (action.type) {
- case LOGIN_REQUEST:
+ case STATE_RESTORE:
return state
- .set('loggingIn', true)
- .set('loginUrl', action.loginUrl)
- .set('loginError', null)
- .set('isLoggedIn', null)
- .set('userData', null);
+ .set('hasRestoredState', true)
+ .set('githubUrl', action.state.githubUrl)
+ .set('githubHost', action.state.githubHost)
+ .set('endpoints', action.state.endpoints)
+ .set('userName', action.state.userName)
+ .set('userLogin', action.state.userLogin);
+
+ case STATE_REQUEST:
+ state = state
+ .set('isFetchingState', true)
+ .set('stateError', null);
+
+ return action.resetUser
+ ? state.merge(userInitial)
+ : state;
+
+ case STATE_SUCCESS:
+ return state
+ .set('hasFetchedState', true)
+ .set('isFetchingState', false)
+ .set('stateError', null)
+ .set('githubUrl', action.githubUrl)
+ .set('githubHost', action.githubHost)
+ .set('endpoints', action.endpoints)
+ .set('userName', action.userName)
+ .set('userLogin', action.userLogin);
+
+ case STATE_FAILURE:
+ return state
+ .set('isFetchingState', false)
+ .set('stateError', action.error);
+
default:
return state;
}
}
+export const injectReducer = createInjector(module);
export default appReducer;
diff --git a/app/containers/App/saga.js b/app/containers/App/saga.js
new file mode 100644
index 0000000..e83cdbf
--- /dev/null
+++ b/app/containers/App/saga.js
@@ -0,0 +1,203 @@
+import { call, put, select, take, takeLatest, race, throttle } from 'redux-saga/effects';
+import createInjector from 'utils/injectSaga';
+import { requestJson, buildApiUrl } from 'utils/request';
+import { delay } from '../../utils/saga-util';
+
+import {
+ WINDOW_VISIBLE,
+ WINDOW_FOCUS,
+ SESSION_STORAGE_KEY,
+ STATE_REQUEST,
+ STATE_RESTORE,
+ STATE_SUCCESS,
+} from './constants';
+
+import {
+ stateRestore,
+ stateRequest,
+ stateSuccess,
+ stateFailure,
+} from './actions';
+
+import {
+ selectEndpoints,
+ selectHasRestoredState,
+ selectRestoreState,
+ selectIsFetchingState,
+ selectIsUserLoggedIn,
+ selectIsLoginRequired,
+ selectStateError,
+} from './selectors';
+
+export function getStoredSessionState() {
+ return JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || '{}');
+}
+
+export function setStoredSessionState(state) {
+ sessionStorage.setItem(
+ SESSION_STORAGE_KEY,
+ JSON.stringify(state),
+ );
+}
+
+export function* init() {
+ // Skip init if already restored state.
+ if (yield select(selectHasRestoredState)) {
+ return;
+ }
+
+ try {
+ yield put(stateRestore(
+ yield call(getStoredSessionState),
+ ));
+ }
+ catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn(`Failed to restore state: ${err.stack}`);
+ }
+
+ // Request the state on app init.
+ yield put(stateRequest());
+}
+
+export function* fetchState() {
+ try {
+ const { app, user, endpoints } = yield call(
+ requestJson,
+ '/api/v1',
+ );
+
+ yield put(stateSuccess(
+ app.githubUrl,
+ app.githubHost,
+ user && user.login || null,
+ user && user.name || null,
+ endpoints,
+ ));
+ }
+ catch (err) {
+ if (err.isJson && err.body) {
+ yield put(stateFailure(
+ err.body,
+ ));
+ }
+ else {
+ yield put(stateFailure(
+ {
+ message: err.message,
+ },
+ ));
+ }
+ }
+}
+
+export function* saveStateToSession() {
+ // Persist the state on success.
+ yield call(
+ setStoredSessionState,
+ yield select(selectRestoreState),
+ );
+}
+
+export function* checkForLoginSuccess() {
+ const isFetchingState = yield select(selectIsFetchingState);
+ if (isFetchingState) {
+ return;
+ }
+
+ const isLoginRequired = yield select(selectIsLoginRequired);
+ if (!isLoginRequired) {
+ return;
+ }
+
+ yield put(stateRequest());
+}
+
+export function* waitForLogin() {
+ while (!(yield select(selectIsUserLoggedIn))) {
+ yield race({
+ restore: take(STATE_RESTORE),
+ success: take(STATE_SUCCESS),
+ });
+ }
+}
+
+/**
+ * Fetch the state if needed.
+ */
+export function* requestStateIfNeeded() {
+ // Skip if already logged in.
+ if (yield select(selectIsUserLoggedIn)) {
+ return;
+ }
+
+ // Skip if already fetching state.
+ if (yield select(selectIsFetchingState)) {
+ return;
+ }
+
+ // Skip if showing the login modal.
+ if (yield select(selectIsLoginRequired)) {
+ return;
+ }
+
+ // Skip if showing a state fetch error.
+ if (yield select(selectStateError)) {
+ return;
+ }
+
+ yield put(stateRequest());
+}
+
+export function* endpointRequest(endpoint, params, ...args) {
+ let attempt = 0;
+ while (true) {
+ attempt++;
+
+ yield call(requestStateIfNeeded);
+ yield call(waitForLogin);
+
+ const endpoints = yield select(selectEndpoints);
+ const url = buildApiUrl(endpoints[endpoint], params);
+
+ try {
+ return yield call(requestJson, url, ...args);
+ }
+ catch (err) {
+ // Allow up to 10 attempts if the response indicates the user needs to login.
+ if (attempt <= 10 && err.isJson && err.status === 403 && err.body && err.body.authRedirectUrl) {
+
+ // Add a small delay
+ yield delay(100 * attempt);
+
+ yield put(stateRequest(true));
+ }
+ else {
+ throw err;
+ }
+ }
+ }
+}
+
+export const injectSaga = createInjector(module);
+
+export default function* defaultSaga() {
+ yield takeLatest(
+ STATE_REQUEST,
+ fetchState,
+ );
+
+ yield takeLatest(
+ [STATE_REQUEST, STATE_SUCCESS],
+ saveStateToSession,
+ );
+
+ // Refetch state on focus and visibility just in case
+ // the user logged in using another window.
+ yield throttle(10000, [
+ WINDOW_VISIBLE,
+ WINDOW_FOCUS,
+ ], checkForLoginSuccess);
+
+ yield call(init);
+}
diff --git a/app/containers/App/selectors.js b/app/containers/App/selectors.js
index 4573949..ba2a82b 100644
--- a/app/containers/App/selectors.js
+++ b/app/containers/App/selectors.js
@@ -2,47 +2,80 @@
* The app state selectors
*/
-import { createSelector } from 'reselect';
+import {
+ createSelector,
+ createStructuredSelector,
+} from 'reselect';
-const selectApp = (state) => state.get('app');
-const selectRoute = (state) => state.get('route');
+export const selectApp = (state) => state.get('app');
-const selectLoggingIn = createSelector(
+export const selectHasRestoredState = createSelector(
selectApp,
- (appState) => appState.get('loggingIn'),
+ (appState) => appState.get('hasRestoredState'),
);
-const selectLoginUrl = createSelector(
+export const selectHasFetchedState = createSelector(
selectApp,
- (appState) => appState.get('loginUrl'),
+ (appState) => appState.get('hasFetchedState'),
);
-const selectLoginError = createSelector(
+export const selectIsFetchingState = createSelector(
selectApp,
- (appState) => appState.get('loginError'),
+ (appState) => appState.get('isFetchingState'),
);
-const selectIsLoggedIn = createSelector(
+export const selectStateError = createSelector(
selectApp,
- (appState) => appState.get('isLoggedIn'),
+ (appState) => appState.get('stateError'),
);
-const selectGithubHost = createSelector(
+export const selectGithubUrl = createSelector(
selectApp,
- (routeState) => routeState.get('githubHost')
+ (appState) => appState.get('githubUrl')
);
-const selectLocation = createSelector(
- selectRoute,
- (routeState) => routeState.get('location').toJS()
+export const selectGithubHost = createSelector(
+ selectApp,
+ (appState) => appState.get('githubHost')
+);
+
+export const selectUserName = createSelector(
+ selectApp,
+ (appState) => appState.get('userName'),
);
-export {
+export const selectUserLogin = createSelector(
selectApp,
- selectLoggingIn,
- selectLoginUrl,
- selectLoginError,
- selectIsLoggedIn,
- selectGithubHost,
- selectLocation,
-};
+ (appState) => appState.get('userLogin'),
+);
+
+export const selectEndpoints = createSelector(
+ selectApp,
+ (appState) => appState.get('endpoints') || {},
+);
+
+export const selectIsUserLoggedIn = createSelector(
+ selectUserLogin,
+ (userLogin) => userLogin != null,
+);
+
+export const selectIsLoginRequired = createSelector(
+ selectHasFetchedState,
+ selectUserLogin,
+ (hasFetchedState, userLogin) => hasFetchedState && userLogin == null,
+);
+
+export const selectRestoreState = createStructuredSelector({
+ githubUrl: selectGithubUrl,
+ githubHost: selectGithubHost,
+ endpoints: selectEndpoints,
+ userName: selectUserName,
+ userLogin: selectUserLogin,
+});
+
+export const selectRoute = (state) => state.get('route');
+
+export const selectLocation = createSelector(
+ selectRoute,
+ (routeState) => routeState.get('location').toJS()
+);
diff --git a/app/containers/App/tests/actions.test.js b/app/containers/App/tests/actions.test.js
index e40da1d..a059953 100644
--- a/app/containers/App/tests/actions.test.js
+++ b/app/containers/App/tests/actions.test.js
@@ -1,43 +1,75 @@
-import { LOAD_REPOS, LOAD_REPOS_SUCCESS, LOAD_REPOS_ERROR } from '../constants';
+import {
+ STATE_RESTORE,
+ STATE_REQUEST,
+ STATE_SUCCESS,
+ STATE_FAILURE,
+} from '../constants';
-import { loadRepos, reposLoaded, repoLoadingError } from '../actions';
+import {
+ stateRestore,
+ stateRequest,
+ stateSuccess,
+ stateFailure,
+} from '../actions';
-describe('App Actions', () => {
- describe('loadRepos', () => {
- it('should return the correct type', () => {
- const expectedResult = {
- type: LOAD_REPOS,
- };
-
- expect(loadRepos()).toEqual(expectedResult);
+describe('App container actions', () => {
+ describe('stateRestore', () => {
+ it('should return the correct type and props', () => {
+ expect(stateRestore({
+ foobar: true,
+ })).toEqual({
+ type: STATE_RESTORE,
+ state: {
+ foobar: true,
+ },
+ });
});
});
- describe('reposLoaded', () => {
- it('should return the correct type and the passed repos', () => {
- const fixture = ['Test'];
- const username = 'test';
- const expectedResult = {
- type: LOAD_REPOS_SUCCESS,
- repos: fixture,
- username,
- };
+ describe('stateRequest', () => {
+ it('should return the correct type and props', () => {
+ expect(stateRequest()).toEqual({
+ type: STATE_REQUEST,
+ resetUser: false,
+ });
- expect(reposLoaded(fixture, username)).toEqual(expectedResult);
+ expect(stateRequest(true)).toEqual({
+ type: STATE_REQUEST,
+ resetUser: true,
+ });
});
});
- describe('repoLoadingError', () => {
- it('should return the correct type and the error', () => {
- const fixture = {
- msg: 'Something went wrong!',
- };
- const expectedResult = {
- type: LOAD_REPOS_ERROR,
- error: fixture,
- };
+ describe('stateSuccess', () => {
+ it('should return the correct type and props', () => {
+ expect(stateSuccess(
+ 'gurl',
+ 'ghost',
+ 'ulogin',
+ 'uname',
+ {
+ foo: 'bar',
+ },
+ )).toEqual({
+ type: STATE_SUCCESS,
+ githubUrl: 'gurl',
+ githubHost: 'ghost',
+ userLogin: 'ulogin',
+ userName: 'uname',
+ endpoints: {
+ foo: 'bar',
+ },
+ });
+ });
+ });
- expect(repoLoadingError(fixture)).toEqual(expectedResult);
+ describe('stateFailure', () => {
+ it('should return the correct type and error', () => {
+ const err = new Error();
+ expect(stateFailure(err)).toEqual({
+ type: STATE_FAILURE,
+ error: err,
+ });
});
});
});
diff --git a/app/containers/App/tests/reducer.test.js b/app/containers/App/tests/reducer.test.js
index 06e0277..8ea33c5 100644
--- a/app/containers/App/tests/reducer.test.js
+++ b/app/containers/App/tests/reducer.test.js
@@ -1,62 +1,126 @@
import { fromJS } from 'immutable';
import appReducer from '../reducer';
-import { loadRepos, reposLoaded, repoLoadingError } from '../actions';
-describe('appReducer', () => {
+import {
+ stateRestore,
+ stateRequest,
+ stateSuccess,
+ stateFailure,
+} from '../actions';
+
+describe('App container reducer', () => {
let state;
+
beforeEach(() => {
state = fromJS({
- loading: false,
- error: false,
- currentUser: false,
- userData: fromJS({
- repositories: false,
- }),
+ hasRestoredState: false,
+ hasFetchedState: false,
+ isFetchingState: false,
+ stateError: null,
+ githubUrl: null,
+ githubHost: null,
+ endpoints: null,
+ userName: null,
+ userLogin: null,
});
});
it('should return the initial state', () => {
- const expectedResult = state;
- expect(appReducer(undefined, {})).toEqual(expectedResult);
+ expect(appReducer(undefined, {})).toEqual(state);
});
- it('should handle the loadRepos action correctly', () => {
+ it('should handle the stateRestore action correctly', () => {
const expectedResult = state
- .set('loading', true)
- .set('error', false)
- .setIn(['userData', 'repositories'], false);
+ .set('hasRestoredState', true)
+ .set('githubUrl', 'gurl')
+ .set('githubHost', 'ghost')
+ .set('endpoints', { foo: 'bar' })
+ .set('userName', 'uname')
+ .set('userLogin', 'ulogin');
- expect(appReducer(state, loadRepos())).toEqual(expectedResult);
+ expect(appReducer(state, stateRestore({
+ githubUrl: 'gurl',
+ githubHost: 'ghost',
+ endpoints: { foo: 'bar' },
+ userName: 'uname',
+ userLogin: 'ulogin',
+ }))).toEqual(expectedResult);
});
- it('should handle the reposLoaded action correctly', () => {
- const fixture = [
- {
- name: 'My Repo',
- },
- ];
- const username = 'test';
+ it('should handle the stateRequest action correctly', () => {
+ const expectedResultA = state
+ .set('isFetchingState', true);
+
+ expect(appReducer(state, stateRequest())).toEqual(expectedResultA);
+
+ const startingStateB = state
+ .set('userName', 'foo')
+ .set('userLogin', 'bar');
+
+ const expectedResultB = state
+ .set('isFetchingState', true)
+ .set('userName', 'foo')
+ .set('userLogin', 'bar');
+
+ expect(appReducer(startingStateB, stateRequest())).toEqual(expectedResultB);
+ });
+
+ it('should handle the stateRequest action correctly if there was already an error', () => {
+ const startingState = state
+ .set('stateError', new Error());
+
const expectedResult = state
- .setIn(['userData', 'repositories'], fixture)
- .set('loading', false)
- .set('currentUser', username);
+ .set('isFetchingState', true);
- expect(appReducer(state, reposLoaded(fixture, username))).toEqual(
- expectedResult,
- );
+ expect(appReducer(startingState, stateRequest())).toEqual(expectedResult);
});
- it('should handle the repoLoadingError action correctly', () => {
- const fixture = {
- msg: 'Not found',
- };
+ it('should handle the stateRequest action correctly if resetUser is true', () => {
+ const startingState = state
+ .set('userName', 'foo')
+ .set('userLogin', 'bar');
+
+ const expectedResult = state
+ .set('isFetchingState', true);
+
+ expect(appReducer(startingState, stateRequest(true))).toEqual(expectedResult);
+ });
+
+ it('should handle the stateSuccess action correctly', () => {
+ const startingState = state
+ .set('isFetchingState', true);
+
+ const expectedResult = state
+ .set('hasFetchedState', true)
+ .set('isFetchingState', false)
+ .set('githubUrl', 'gurl')
+ .set('githubHost', 'ghost')
+ .set('endpoints', { foo: 'bar' })
+ .set('userName', 'uname')
+ .set('userLogin', 'ulogin');
+
+ expect(appReducer(startingState, stateSuccess(
+ 'gurl',
+ 'ghost',
+ 'ulogin',
+ 'uname',
+ { foo: 'bar' },
+ ))).toEqual(expectedResult);
+ });
+
+ it('should handle the stateFailure action correctly', () => {
+ const err = new Error('foobar');
+
+ const startingState = state
+ .set('isFetchingState', true);
+
const expectedResult = state
- .set('error', fixture)
- .set('loading', false);
+ .set('isFetchingState', false)
+ .set('stateError', err);
- expect(appReducer(state, repoLoadingError(fixture))).toEqual(
- expectedResult,
- );
+ expect(appReducer(startingState, stateFailure(
+ err,
+ ))).toEqual(expectedResult);
});
});
diff --git a/app/containers/App/tests/saga.test.js b/app/containers/App/tests/saga.test.js
new file mode 100644
index 0000000..894ddae
--- /dev/null
+++ b/app/containers/App/tests/saga.test.js
@@ -0,0 +1,381 @@
+import { call, put, select, take, race } from 'redux-saga/effects';
+import { cloneableGenerator } from 'redux-saga/utils';
+
+import * as saga from '../saga';
+
+import {
+ selectHasRestoredState, selectIsFetchingState, selectIsLoginRequired, selectIsUserLoggedIn, selectRestoreState, selectStateError,
+} from '../selectors';
+
+import { stateFailure, stateRequest, stateRestore, stateSuccess } from '../actions';
+import { requestJson } from '../../../utils/request';
+import { STATE_RESTORE, STATE_SUCCESS } from '../constants';
+
+describe('App container saga', () => {
+ describe('*init()', () => {
+ it('should abort if has already restored state', () => {
+ const gen = saga.init();
+
+ // Checks if the state has been restored.
+ expect(gen.next().value)
+ .toEqual(select(selectHasRestoredState));
+
+ // Done if already restored.
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+
+ it('should restore state from sessionStorage', () => {
+ const gen = saga.init();
+
+ // Checks if the state has been restored.
+ expect(gen.next().value)
+ .toEqual(select(selectHasRestoredState));
+
+ // Attempts to get state stored in sessionStorage.
+ expect(gen.next(false).value)
+ .toEqual(call(saga.getStoredSessionState));
+
+ // Puts stateRestore action with retrieved state.
+ const storedJSON = { foo: 'bar' };
+ expect(gen.next(storedJSON).value)
+ .toEqual(put(stateRestore(storedJSON)));
+
+ // Puts stateRequest action.
+ expect(gen.next().value)
+ .toEqual(put(stateRequest()));
+
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+ });
+
+ describe('*fetchState()', () => {
+ it('should request API JSON and put stateSuccess action', () => {
+ const gen = cloneableGenerator(saga.fetchState)();
+
+ // Call requestJson
+ expect(gen.next().value)
+ .toEqual(call(
+ requestJson,
+ '/api/v1',
+ ));
+
+ {
+ const genNoUser = gen.clone();
+
+ // Put STATE_SUCCESS action.
+ expect(genNoUser.next({
+ app: {
+ githubUrl: 'gurl',
+ githubHost: 'ghost',
+ },
+ endpoints: {
+ foo: 'bar',
+ },
+ user: null,
+ }).value)
+ .toEqual(put(stateSuccess(
+ 'gurl',
+ 'ghost',
+ null,
+ null,
+ {
+ foo: 'bar',
+ },
+ )));
+
+ expect(genNoUser.next(true).done)
+ .toEqual(true);
+ }
+
+ {
+ const genHasUser = gen.clone();
+
+ // Put STATE_SUCCESS action.
+ expect(genHasUser.next({
+ app: {
+ githubUrl: 'gurl',
+ githubHost: 'ghost',
+ },
+ endpoints: {
+ foo: 'bar',
+ },
+ user: {
+ login: 'ulogin',
+ name: 'uname',
+ },
+ }).value)
+ .toEqual(put(stateSuccess(
+ 'gurl',
+ 'ghost',
+ 'ulogin',
+ 'uname',
+ {
+ foo: 'bar',
+ },
+ )));
+
+ expect(genHasUser.next(true).done)
+ .toEqual(true);
+ }
+ });
+
+ it('should request API JSON and put stateFailure action', () => {
+ const gen = cloneableGenerator(saga.fetchState)();
+
+ // Call requestJson
+ expect(gen.next().value)
+ .toEqual(call(
+ requestJson,
+ '/api/v1',
+ ));
+
+ {
+ const genJsonResponse = gen.clone();
+
+ const err = new Error('Foo');
+ err.isJson = true;
+ err.body = {
+ message: 'foobar',
+ };
+
+ // Put STATE_FAILURE action.
+ expect(genJsonResponse.throw(err).value)
+ .toEqual(put(stateFailure({
+ message: 'foobar',
+ })));
+
+ expect(genJsonResponse.next(true).done)
+ .toEqual(true);
+ }
+
+ {
+ const genNonJsonResponse = gen.clone();
+
+ const err = new Error('Foo');
+
+ // Put STATE_FAILURE action.
+ expect(genNonJsonResponse.throw(err).value)
+ .toEqual(put(stateFailure({
+ message: 'Foo',
+ })));
+
+ expect(genNonJsonResponse.next(true).done)
+ .toEqual(true);
+ }
+ });
+ });
+
+ describe('*saveStateToSession()', () => {
+ it('should save the state to sessionStorage', () => {
+ const gen = saga.saveStateToSession();
+
+ // Select state to store.
+ expect(gen.next().value)
+ .toEqual(select(selectRestoreState));
+
+ const state = {
+ foo: 'bar',
+ };
+
+ // Call helper to save state.
+ expect(gen.next(state).value)
+ .toEqual(call(
+ saga.setStoredSessionState,
+ state,
+ ));
+
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+ });
+
+ describe('*checkForLoginSuccess()', () => {
+ it('should abort if already fetching state', () => {
+ const gen = saga.checkForLoginSuccess();
+
+ // Select if is fetching state already.
+ expect(gen.next().value)
+ .toEqual(select(selectIsFetchingState));
+
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+
+ it('should abort if login is not required', () => {
+ const gen = saga.checkForLoginSuccess();
+
+ // Select if is fetching state already.
+ expect(gen.next().value)
+ .toEqual(select(selectIsFetchingState));
+
+ // Select if login is required.
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsLoginRequired));
+
+ expect(gen.next(false).done)
+ .toEqual(true);
+ });
+
+ it('should put stateRequest action', () => {
+ const gen = saga.checkForLoginSuccess();
+
+ // Select if is fetching state already.
+ expect(gen.next().value)
+ .toEqual(select(selectIsFetchingState));
+
+ // Select if login is required.
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsLoginRequired));
+
+ expect(gen.next(true).value)
+ .toEqual(put(stateRequest()));
+
+ expect(gen.next().done)
+ .toEqual(true);
+ });
+ });
+
+ describe('*requestStateIfNeeded()', () => {
+ it('should not request if user is logged in', () => {
+ const gen = saga.requestStateIfNeeded();
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+
+ it('should not request if already fetching state', () => {
+ const gen = saga.requestStateIfNeeded();
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsFetchingState));
+
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+
+ it('should not request if already showing login modal', () => {
+ const gen = saga.requestStateIfNeeded();
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsFetchingState));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsLoginRequired));
+
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+
+ it('should not request if there was a state fetch error', () => {
+ const gen = saga.requestStateIfNeeded();
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsFetchingState));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsLoginRequired));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectStateError));
+
+ expect(gen.next(new Error('foobar')).done)
+ .toEqual(true);
+ });
+
+ it('should request state if needed', () => {
+ const gen = saga.requestStateIfNeeded();
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsFetchingState));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectIsLoginRequired));
+
+ expect(gen.next(false).value)
+ .toEqual(select(selectStateError));
+
+ expect(gen.next(null).value)
+ .toEqual(put(stateRequest()));
+
+ expect(gen.next().done)
+ .toEqual(true);
+ });
+ });
+
+ describe('*waitForLogin()', () => {
+ it('should loop until user is logged in', () => {
+ const gen = saga.waitForLogin();
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(
+ false // Result of selectIsUserLoggedIn
+ ).value)
+ .toEqual(race({
+ restore: take(STATE_RESTORE),
+ success: take(STATE_SUCCESS),
+ }));
+
+ // Loop repeats.
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(
+ false // Result of selectIsUserLoggedIn
+ ).value)
+ .toEqual(race({
+ restore: take(STATE_RESTORE),
+ success: take(STATE_SUCCESS),
+ }));
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ // Exits if user is now logged in.
+ expect(gen.next(
+ true // Result of selectIsUserLoggedIn
+ ).done)
+ .toEqual(true);
+ });
+
+ it('should bail immediately if user is already logged in', () => {
+ const gen = saga.waitForLogin();
+
+ expect(gen.next().value)
+ .toEqual(select(selectIsUserLoggedIn));
+
+ expect(gen.next(true).done)
+ .toEqual(true);
+ });
+ });
+
+ describe('*endpointRequest()', () => {
+ it.skip('TODO', () => {});
+ });
+
+ describe('*endpointRequest()', () => {
+ it.skip('TODO', () => {});
+ });
+
+ describe('*defaultSaga()', () => {
+ it.skip('TODO', () => {});
+ });
+});
diff --git a/app/containers/App/tests/selectors.test.js b/app/containers/App/tests/selectors.test.js
index fb0ec77..15fc51b 100644
--- a/app/containers/App/tests/selectors.test.js
+++ b/app/containers/App/tests/selectors.test.js
@@ -1,13 +1,47 @@
import { fromJS } from 'immutable';
-import {
+import * as selectors from '../selectors';
+
+describe('App container selectors', () => {
+ it('should have the expected exports', () => {
+ expect(Object.keys(selectors).sort())
+ .toEqual([
+ 'selectApp',
+ 'selectHasRestoredState',
+ 'selectHasFetchedState',
+ 'selectIsFetchingState',
+ 'selectStateError',
+ 'selectGithubUrl',
+ 'selectGithubHost',
+ 'selectUserName',
+ 'selectUserLogin',
+ 'selectEndpoints',
+ 'selectIsUserLoggedIn',
+ 'selectIsLoginRequired',
+ 'selectRestoreState',
+ 'selectRoute',
+ 'selectLocation',
+ ].sort());
+ });
+});
+
+const {
selectApp,
- makeSelectCurrentUser,
- makeSelectLoading,
- makeSelectError,
- makeSelectRepos,
- makeSelectLocation,
-} from '../selectors';
+ selectHasRestoredState,
+ selectHasFetchedState,
+ selectIsFetchingState,
+ selectStateError,
+ selectGithubUrl,
+ selectGithubHost,
+ selectUserName,
+ selectUserLogin,
+ selectEndpoints,
+ selectIsUserLoggedIn,
+ selectIsLoginRequired,
+ selectRestoreState,
+ selectRoute,
+ selectLocation,
+} = selectors;
describe('selectApp', () => {
it('should select the app state', () => {
@@ -19,71 +53,236 @@ describe('selectApp', () => {
});
});
-describe('makeSelectCurrentUser', () => {
- const currentUserSelector = makeSelectCurrentUser();
- it('should select the current user', () => {
- const username = 'mxstbr';
- const mockedState = fromJS({
+describe('selectHasRestoredState', () => {
+ it('should select if state has been restored from the sessionStore', () => {
+ const mockedStateA = fromJS({
+ app: {
+ hasRestoredState: true,
+ },
+ });
+ expect(selectHasRestoredState(mockedStateA)).toEqual(true);
+
+ const mockedStateB = fromJS({
+ app: {
+ hasRestoredState: false,
+ },
+ });
+ expect(selectHasRestoredState(mockedStateB)).toEqual(false);
+ });
+});
+
+describe('selectHasFetchedState', () => {
+ it('should select if state has been fetched successfully', () => {
+ const mockedStateA = fromJS({
+ app: {
+ hasFetchedState: true,
+ },
+ });
+ expect(selectHasFetchedState(mockedStateA)).toEqual(true);
+
+ const mockedStateB = fromJS({
app: {
- currentUser: username,
+ hasFetchedState: false,
},
});
- expect(currentUserSelector(mockedState)).toEqual(username);
+ expect(selectHasFetchedState(mockedStateB)).toEqual(false);
});
});
-describe('makeSelectLoading', () => {
- const loadingSelector = makeSelectLoading();
- it('should select the loading', () => {
- const loading = false;
+describe('selectIsFetchingState', () => {
+ it('should select if state has been fetched successfully', () => {
+ const mockedStateA = fromJS({
+ app: {
+ isFetchingState: true,
+ },
+ });
+ expect(selectIsFetchingState(mockedStateA)).toEqual(true);
+ });
+});
+
+describe('selectStateError', () => {
+ it('should select state fetch error', () => {
+ const err = new Error();
const mockedState = fromJS({
app: {
- loading,
+ stateError: err,
},
});
- expect(loadingSelector(mockedState)).toEqual(loading);
+ expect(selectStateError(mockedState)).toEqual(err);
});
});
-describe('makeSelectError', () => {
- const errorSelector = makeSelectError();
- it('should select the error', () => {
- const error = 404;
+describe('selectGithubUrl', () => {
+ it('should select Github URL', () => {
const mockedState = fromJS({
app: {
- error,
+ githubUrl: 'http://foobar.github.com',
},
});
- expect(errorSelector(mockedState)).toEqual(error);
+ expect(selectGithubUrl(mockedState)).toEqual('http://foobar.github.com');
});
});
-describe('makeSelectRepos', () => {
- const reposSelector = makeSelectRepos();
- it('should select the repos', () => {
- const repositories = fromJS([]);
+describe('selectGithubHost', () => {
+ it('should select Github host', () => {
const mockedState = fromJS({
app: {
- userData: {
- repositories,
- },
+ githubHost: 'foobar.github.com',
+ },
+ });
+ expect(selectGithubHost(mockedState)).toEqual('foobar.github.com');
+ });
+});
+
+describe('selectUserName', () => {
+ it('should select user name', () => {
+ const mockedStateA = fromJS({
+ app: {
+ userName: null,
+ },
+ });
+ expect(selectUserName(mockedStateA)).toEqual(null);
+
+ const mockedStateB = fromJS({
+ app: {
+ userName: 'foobar',
+ },
+ });
+ expect(selectUserName(mockedStateB)).toEqual('foobar');
+ });
+});
+
+describe('selectUserLogin', () => {
+ it('should select user login', () => {
+ const mockedStateA = fromJS({
+ app: {
+ userLogin: null,
+ },
+ });
+ expect(selectUserLogin(mockedStateA)).toEqual(null);
+
+ const mockedStateB = fromJS({
+ app: {
+ userLogin: 'foobar',
+ },
+ });
+ expect(selectUserLogin(mockedStateB)).toEqual('foobar');
+ });
+});
+
+describe('selectEndpoints', () => {
+ it('should select endpoints', () => {
+ const mockedStateA = fromJS({
+ app: {},
+ });
+ expect(selectEndpoints(mockedStateA)).toEqual({});
+
+ const mockedStateB = fromJS({
+ app: {},
+ })
+ .setIn(['app', 'endpoints'], { foo: true });
+ expect(selectEndpoints(mockedStateB)).toEqual({ foo: true });
+ });
+});
+
+describe('selectIsUserLoggedIn', () => {
+ it('should select if user is logged in', () => {
+ const mockedStateA = fromJS({
+ app: {
+ userLogin: null,
+ },
+ });
+ expect(selectIsUserLoggedIn(mockedStateA)).toEqual(false);
+
+ const mockedStateB = fromJS({
+ app: {
+ userLogin: 'foobar',
},
});
- expect(reposSelector(mockedState)).toEqual(repositories);
+ expect(selectIsUserLoggedIn(mockedStateB)).toEqual(true);
});
});
-describe('makeSelectLocation', () => {
- const locationStateSelector = makeSelectLocation();
- it('should select the location', () => {
- const route = fromJS({
- location: { pathname: '/foo' },
+describe('selectIsLoginRequired', () => {
+ it('should select if user is logged in', () => {
+ const mockedStateA = fromJS({
+ app: {
+ hasFetchedState: false,
+ userLogin: null,
+ },
+ });
+ expect(selectIsLoginRequired(mockedStateA)).toEqual(false);
+
+ const mockedStateB = fromJS({
+ app: {
+ hasFetchedState: false,
+ userLogin: 'foobar',
+ },
+ });
+ expect(selectIsLoginRequired(mockedStateB)).toEqual(false);
+
+ const mockedStateC = fromJS({
+ app: {
+ hasFetchedState: true,
+ userLogin: null,
+ },
});
+ expect(selectIsLoginRequired(mockedStateC)).toEqual(true);
+
+ const mockedStateD = fromJS({
+ app: {
+ hasFetchedState: true,
+ userLogin: 'foobar',
+ },
+ });
+ expect(selectIsLoginRequired(mockedStateD)).toEqual(false);
+ });
+});
+
+describe('selectRestoreState', () => {
+ it('should select state to be saved to sessionStorage', () => {
+ const mockedStateA = fromJS({
+ app: {
+ githubUrl: 'gurl',
+ githubHost: 'ghost',
+ userName: 'uname',
+ userLogin: 'ulogin',
+ },
+ })
+ .setIn(['app', 'endpoints'], { foo: 'bar' });
+
+ expect(selectRestoreState(mockedStateA)).toEqual({
+ githubUrl: 'gurl',
+ githubHost: 'ghost',
+ endpoints: { foo: 'bar' },
+ userName: 'uname',
+ userLogin: 'ulogin',
+ });
+ });
+});
+
+describe('selectRoute', () => {
+ it('should select the route state', () => {
+ const routeState = fromJS({});
const mockedState = fromJS({
- route,
+ route: routeState,
+ });
+ expect(selectRoute(mockedState)).toEqual(routeState);
+ });
+});
+
+describe('selectLocation', () => {
+ it('should select route location', () => {
+ const mockedState = fromJS({
+ route: {
+ location: {
+ path: '/foo/bar',
+ },
+ },
+ });
+
+ expect(selectLocation(mockedState)).toEqual({
+ path: '/foo/bar',
});
- expect(locationStateSelector(mockedState)).toEqual(
- route.get('location').toJS(),
- );
});
});
diff --git a/app/containers/CommitExecutionsPage/index.js b/app/containers/CommitExecutionsPage/index.js
index d55148d..2241730 100644
--- a/app/containers/CommitExecutionsPage/index.js
+++ b/app/containers/CommitExecutionsPage/index.js
@@ -76,7 +76,7 @@ export class CommitExecutionsPage extends React.Component {
{commit.substr(0, 10)}) }}
/>
@@ -91,6 +91,12 @@ export class CommitExecutionsPage extends React.Component {
)}
+ {executions && !executions.length && (
+
+ No executions found for the repo/commit.
+
+ )}
+
{executions && executions.map((execution) => (
diff --git a/app/containers/CommitExecutionsPage/saga.js b/app/containers/CommitExecutionsPage/saga.js
index 142d874..975fe7e 100644
--- a/app/containers/CommitExecutionsPage/saga.js
+++ b/app/containers/CommitExecutionsPage/saga.js
@@ -1,7 +1,10 @@
-import { take, call, put, select, takeLatest } from 'redux-saga/effects';
+import { call, put, select, takeLatest } from 'redux-saga/effects';
import createInjector from 'utils/injectSaga';
import { raceCancel } from '../../utils/saga-util';
-import { requestJson } from 'utils/request';
+
+import {
+ endpointRequest,
+} from 'containers/App/saga';
import {
PAGE_OPENED,
@@ -10,18 +13,10 @@ import {
} from './constants';
import {
- LOGIN_SUCCESS,
-} from 'containers/App/constants';
-
-import {
- loginRequest,
-} from 'containers/App/actions';
-
-import {
- selectLoggingIn,
-} from 'containers/App/selectors';
-
-import selectDomain from './selectors';
+ selectOwner,
+ selectRepo,
+ selectCommit,
+} from './selectors';
import {
fetchExecutions,
@@ -30,18 +25,10 @@ import {
} from './actions';
export function* fetchingExecution() {
- // Check if a login is in process.
- const isLoggingIn = yield select(selectLoggingIn);
- if (isLoggingIn) {
- yield take(LOGIN_SUCCESS);
- }
-
try {
- const {
- owner,
- repo,
- commit,
- } = yield select(selectDomain);
+ const owner = yield select(selectOwner);
+ const repo = yield select(selectRepo);
+ const commit = yield select(selectCommit);
if (!owner) {
return;
@@ -51,8 +38,9 @@ export function* fetchingExecution() {
executions,
lastEvaluatedKey,
} = yield call(
- requestJson,
- `/api/v1/repo/${owner}/${repo}/commit/${commit}`,
+ endpointRequest,
+ 'searchByCommitUrl',
+ { owner, repo, commit },
);
yield put(fetchExecutionsSuccess(
@@ -62,23 +50,9 @@ export function* fetchingExecution() {
}
catch (err) {
if (err.isJson && err.body) {
- if (err.status === 403 && err.body.authRedirectUrl) {
- // Start a login request.
- yield put(loginRequest(err.body.authRedirectUrl));
-
- // Wait for the login to succeed or the
- const loginSuccessResult = yield take(LOGIN_SUCCESS);
-
- if (loginSuccessResult) {
- // Try the fetch again...
- yield call(fetchExecutions());
- }
- }
- else {
- yield put(fetchExecutionsFailure(
- err.body,
- ));
- }
+ yield put(fetchExecutionsFailure(
+ err.body,
+ ));
}
else {
yield put(fetchExecutionsFailure(
diff --git a/app/containers/CommitExecutionsPage/selectors.js b/app/containers/CommitExecutionsPage/selectors.js
index 240d9b4..eb78c91 100644
--- a/app/containers/CommitExecutionsPage/selectors.js
+++ b/app/containers/CommitExecutionsPage/selectors.js
@@ -15,6 +15,21 @@ export default createSelector(
* Other specific selectors
*/
+export const selectOwner = createSelector(
+ selectDomain,
+ (domainState) => domainState.get('owner'),
+);
+
+export const selectRepo = createSelector(
+ selectDomain,
+ (domainState) => domainState.get('repo'),
+);
+
+export const selectCommit = createSelector(
+ selectDomain,
+ (domainState) => domainState.get('commit'),
+);
+
export const selectIsLoading = createSelector(
selectDomain,
(domainState) => domainState.get('isLoading'),
diff --git a/app/containers/ExecutionDetailPage/ExecutionSummaryPanel.js b/app/containers/ExecutionDetailPage/ExecutionSummaryPanel.js
index c76b259..fc41d17 100644
--- a/app/containers/ExecutionDetailPage/ExecutionSummaryPanel.js
+++ b/app/containers/ExecutionDetailPage/ExecutionSummaryPanel.js
@@ -53,10 +53,6 @@ function ExecutionSummaryPanel({
author={author}
/>
-
-
- – {commitMessage.substr(0, 100)}{commitMessage.length > 100 ? '…' : ''}
-
@@ -71,7 +67,7 @@ function ExecutionSummaryPanel({
- {commit.substr(0, 10)}
+ {commit.substr(0, 10)}
diff --git a/app/containers/ExecutionDetailPage/index.js b/app/containers/ExecutionDetailPage/index.js
index dbd3616..803a65f 100644
--- a/app/containers/ExecutionDetailPage/index.js
+++ b/app/containers/ExecutionDetailPage/index.js
@@ -9,6 +9,8 @@ import { Helmet } from 'react-helmet';
import { compose, bindActionCreators } from 'redux';
import { Switch, Route } from 'react-router-dom';
+import ErrorBoundary from 'components/ErrorBoundary';
+import { panelErrorMessage } from 'components/RenderErrorPanel';
import { WindowHeightConsumer } from 'contexts/WindowHeight';
import { Panel, PanelHeader, PanelHeaderMini } from 'components/Panel';
import LoadingIndicator from 'components/LoadingIndicator';
@@ -59,11 +61,13 @@ function BuildDetail({ execution, buildKey }) {
{(height) => (
-
+
+
+
)}
@@ -145,21 +149,23 @@ export class ExecutionDetailPage extends React.Component {
)}
{execution && (
-
+
+
+
)}
{execution && (
@@ -167,19 +173,23 @@ export class ExecutionDetailPage extends React.Component {
(
-
+
+
+
)}
/>
(
-
+
+
+
)}
/>
diff --git a/app/containers/ExecutionDetailPage/saga.js b/app/containers/ExecutionDetailPage/saga.js
index 2c8b800..f2cbe24 100644
--- a/app/containers/ExecutionDetailPage/saga.js
+++ b/app/containers/ExecutionDetailPage/saga.js
@@ -1,7 +1,10 @@
-import { take, call, put, select, takeLatest } from 'redux-saga/effects';
+import { call, put, select, takeLatest } from 'redux-saga/effects';
import createInjector from 'utils/injectSaga';
import { delay, raceCancel } from '../../utils/saga-util';
-import { requestJson } from 'utils/request';
+
+import {
+ endpointRequest,
+} from 'containers/App/saga';
import {
EXECUTION_OPENED,
@@ -12,18 +15,6 @@ import {
FETCH_BUILD_LOGS_REQUEST,
} from './constants';
-import {
- LOGIN_SUCCESS,
-} from 'containers/App/constants';
-
-import {
- loginRequest,
-} from 'containers/App/actions';
-
-import {
- selectLoggingIn,
-} from 'containers/App/selectors';
-
import selectDomain, {
selectExecution,
selectBuildKey,
@@ -41,12 +32,6 @@ import {
export function* fetchingExecution({
accessKey = null,
}) {
- // Check if a login is in process.
- const isLoggingIn = yield select(selectLoggingIn);
- if (isLoggingIn) {
- yield take(LOGIN_SUCCESS);
- }
-
try {
const {
owner,
@@ -66,8 +51,9 @@ export function* fetchingExecution({
}
const execution = yield call(
- requestJson,
- `/api/v1/repo/${owner}/${repo}/commit/${commit}/exec/${executionNum}`,
+ endpointRequest,
+ 'getExecutionUrl',
+ { owner, repo, commit, executionNum },
{ headers }
);
@@ -92,25 +78,9 @@ export function* fetchingExecution({
}
catch (err) {
if (err.isJson && err.body) {
- if (err.status === 403 && err.body.authRedirectUrl) {
- // Start a login request.
- yield put(loginRequest(err.body.authRedirectUrl));
-
- // Wait for the login to succeed or the
- const loginSuccessResult = yield take(LOGIN_SUCCESS);
-
- if (loginSuccessResult) {
- // Try the fetch again...
- yield call(fetchExecution({
- accessKey,
- }));
- }
- }
- else {
- yield put(fetchExecutionFailure(
- err.body,
- ));
- }
+ yield put(fetchExecutionFailure(
+ err.body,
+ ));
}
else {
yield put(fetchExecutionFailure(
@@ -125,12 +95,6 @@ export function* fetchingExecution({
export function* fetchingBuildLogs({
accessKey = null,
}) {
- // Check if a login is in process.
- const isLoggingIn = yield select(selectLoggingIn);
- if (isLoggingIn) {
- yield take(LOGIN_SUCCESS);
- }
-
try {
const execution = yield select(selectExecution);
if (!execution) {
@@ -145,7 +109,6 @@ export function* fetchingBuildLogs({
return;
}
- let accessKey;
if (buildState.codeBuild) {
const headers = {};
@@ -154,8 +117,9 @@ export function* fetchingBuildLogs({
}
const response = yield call(
- requestJson,
- `/api/v1/repo/${owner}/${repo}/commit/${commit}/exec/${executionNum}/build/${buildKey}/logs`,
+ endpointRequest,
+ 'getExecutionBuildLogsUrl',
+ { owner, repo, commit, executionNum, buildKey }, // limit, nextToken
{ headers }
);
@@ -185,25 +149,9 @@ export function* fetchingBuildLogs({
}
catch (err) {
if (err.isJson && err.body) {
- if (err.status === 403 && err.body.authRedirectUrl) {
- // Start a login request.
- yield put(loginRequest(err.body.authRedirectUrl));
-
- // Wait for the login to succeed or the
- const loginSuccessResult = yield take(LOGIN_SUCCESS);
-
- if (loginSuccessResult) {
- // Try the fetch again...
- yield call(fetchBuildLogs({
- accessKey,
- }));
- }
- }
- else {
- yield put(fetchBuildLogsFailure(
- err.body,
- ));
- }
+ yield put(fetchBuildLogsFailure(
+ err.body,
+ ));
}
else {
yield put(fetchBuildLogsFailure(
diff --git a/app/containers/LanguageProvider/index.js b/app/containers/LanguageProvider/index.js
index e3dd582..14652d3 100644
--- a/app/containers/LanguageProvider/index.js
+++ b/app/containers/LanguageProvider/index.js
@@ -11,7 +11,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { IntlProvider } from 'react-intl';
-import { makeSelectLocale } from './selectors';
+import { selectLocale } from './selectors';
export class LanguageProvider extends React.PureComponent {
render() {
@@ -34,7 +34,7 @@ LanguageProvider.propTypes = {
};
const mapStateToProps = createSelector(
- makeSelectLocale(),
+ selectLocale,
(locale) => ({ locale })
);
diff --git a/app/containers/LanguageProvider/selectors.js b/app/containers/LanguageProvider/selectors.js
index e0ac41c..2a0d94a 100644
--- a/app/containers/LanguageProvider/selectors.js
+++ b/app/containers/LanguageProvider/selectors.js
@@ -7,16 +7,14 @@ import { initialState } from './reducer';
* @param {object} state
* @returns {object}
*/
-const selectLanguage = (state) => state.get('language', initialState);
+export const selectLanguage = (state) => state.get('language', initialState);
/**
* Select the language locale.
*
* @returns {string}
*/
-const makeSelectLocale = () => createSelector(
+export const selectLocale = createSelector(
selectLanguage,
(languageState) => languageState.get('locale')
);
-
-export { selectLanguage, makeSelectLocale };
diff --git a/app/containers/LanguageProvider/tests/index.test.js b/app/containers/LanguageProvider/tests/index.test.js
index 92da969..472018d 100644
--- a/app/containers/LanguageProvider/tests/index.test.js
+++ b/app/containers/LanguageProvider/tests/index.test.js
@@ -2,7 +2,7 @@ import React from 'react';
import { shallow, mount } from 'enzyme';
import { FormattedMessage, defineMessages } from 'react-intl';
import { Provider } from 'react-redux';
-import { browserHistory } from 'react-router-dom';
+import createHistory from 'history/createBrowserHistory';
import ConnectedLanguageProvider, { LanguageProvider } from '../index';
import configureStore from '../../../configureStore';
@@ -33,7 +33,7 @@ describe('', () => {
let store;
beforeAll(() => {
- store = configureStore({}, browserHistory);
+ store = configureStore({}, createHistory());
});
it('should render the default language messages', () => {
diff --git a/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap b/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap
index 9b38422..734c179 100644
--- a/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap
+++ b/app/containers/NotFoundPage/tests/__snapshots__/index.test.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` should render the expected JSX 1`] = `
-
+
should render the expected JSX 1`] = `
values={Object {}}
/>
-
+
`;
exports[` should render the expected JSX 2`] = `
Array [
Page Not Found
diff --git a/app/containers/RepoExecutionsPage/index.js b/app/containers/RepoExecutionsPage/index.js
index e7529aa..3be00c3 100644
--- a/app/containers/RepoExecutionsPage/index.js
+++ b/app/containers/RepoExecutionsPage/index.js
@@ -38,7 +38,6 @@ import {
pageClosed,
} from './actions';
-/* eslint-disable react/prefer-stateless-function */
export class RepoExecutionsPage extends React.Component {
componentDidMount() {
@@ -89,6 +88,12 @@ export class RepoExecutionsPage extends React.Component {
)}
+ {executions && !executions.length && (
+
+ No executions found for the repo.
+
+ )}
+
{executions && executions.map((execution) => (
diff --git a/app/containers/RepoExecutionsPage/saga.js b/app/containers/RepoExecutionsPage/saga.js
index 81fff2f..3549e7c 100644
--- a/app/containers/RepoExecutionsPage/saga.js
+++ b/app/containers/RepoExecutionsPage/saga.js
@@ -1,7 +1,10 @@
-import { take, call, put, select, takeLatest } from 'redux-saga/effects';
+import { call, put, select, takeLatest } from 'redux-saga/effects';
import createInjector from 'utils/injectSaga';
import { raceCancel } from '../../utils/saga-util';
-import { requestJson } from 'utils/request';
+
+import {
+ endpointRequest,
+} from 'containers/App/saga';
import {
PAGE_OPENED,
@@ -9,18 +12,6 @@ import {
FETCH_EXECUTIONS_REQUEST,
} from './constants';
-import {
- LOGIN_SUCCESS,
-} from 'containers/App/constants';
-
-import {
- loginRequest,
-} from 'containers/App/actions';
-
-import {
- selectLoggingIn,
-} from 'containers/App/selectors';
-
import selectDomain from './selectors';
import {
@@ -30,12 +21,6 @@ import {
} from './actions';
export function* fetchingExecution() {
- // Check if a login is in process.
- const isLoggingIn = yield select(selectLoggingIn);
- if (isLoggingIn) {
- yield take(LOGIN_SUCCESS);
- }
-
try {
const {
owner,
@@ -50,8 +35,9 @@ export function* fetchingExecution() {
executions,
lastEvaluatedKey,
} = yield call(
- requestJson,
- `/api/v1/repo/${owner}/${repo}`,
+ endpointRequest,
+ 'searchByRepoUrl',
+ { owner, repo },
);
yield put(fetchExecutionsSuccess(
@@ -61,23 +47,9 @@ export function* fetchingExecution() {
}
catch (err) {
if (err.isJson && err.body) {
- if (err.status === 403 && err.body.authRedirectUrl) {
- // Start a login request.
- yield put(loginRequest(err.body.authRedirectUrl));
-
- // Wait for the login to succeed or the
- const loginSuccessResult = yield take(LOGIN_SUCCESS);
-
- if (loginSuccessResult) {
- // Try the fetch again...
- yield call(fetchExecutions());
- }
- }
- else {
- yield put(fetchExecutionsFailure(
- err.body,
- ));
- }
+ yield put(fetchExecutionsFailure(
+ err.body,
+ ));
}
else {
yield put(fetchExecutionsFailure(
@@ -104,7 +76,6 @@ export default function* defaultSaga() {
FETCH_EXECUTIONS_REQUEST,
raceCancel(fetchingExecution, [
PAGE_CLOSED,
- FETCH_EXECUTIONS_REQUEST,
]),
);
}
diff --git a/app/containers/TopNav/index.js b/app/containers/TopNav/index.js
index 2dfbc5d..842fa08 100644
--- a/app/containers/TopNav/index.js
+++ b/app/containers/TopNav/index.js
@@ -3,13 +3,21 @@
*/
import React from 'react';
-// import PropTypes from 'prop-types';
+import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import styled from 'styled-components';
+import { buildApiUrl } from '../../utils/request';
import messages from './messages';
+import {
+ selectGithubUrl,
+ selectUserName,
+ selectUserLogin,
+ selectEndpoints,
+} from 'containers/App/selectors';
+
const DropdownBG = styled.div`
position: fixed;
top: 0;
@@ -49,11 +57,21 @@ export class TopNav extends React.PureComponent {
handleLogOut(evt) {
evt.preventDefault();
- window.location = `/api/v1/auth/logout?redirect=${encodeURIComponent(`${window.location.origin}/app`)}`;
+ const { logoutUrl } = this.props;
+ window.location = buildApiUrl(logoutUrl, { url: `${window.location.origin}/app` });
}
render() {
- const { showNavi, showUserDropdown } = this.state;
+ const {
+ githubUrl,
+ userName,
+ userLogin,
+ } = this.props;
+
+ const {
+ showNavi,
+ showUserDropdown,
+ } = this.state;
return (
@@ -94,27 +112,30 @@ export class TopNav extends React.PureComponent {
-
-
-
-
-
- {showUserDropdown && (
-
- )}
-
+ {userLogin && (
+
+
+
+
+
+ {showUserDropdown && (
+
+ )}
+
+ )}
@@ -126,16 +147,26 @@ export class TopNav extends React.PureComponent {
}
}
-TopNav.propTypes = {};
-TopNav.defaultProps = {};
-
-function mapStateToProps(/* state, ownProps */) {
+TopNav.propTypes = {
+ githubUrl: PropTypes.string,
+ logoutUrl: PropTypes.string,
+ userName: PropTypes.string,
+ userLogin: PropTypes.string,
+};
+
+TopNav.defaultProps = {
+ githubUrl: null,
+ logoutUrl: null,
+ userName: null,
+ userLogin: null,
+};
+
+function mapStateToProps(state) {
return {
- // Route params
- // myparam: ownProps.match.params.myparam,
-
- // Store values
- // topnav: selectTopNav,
+ githubUrl: selectGithubUrl(state),
+ logoutUrl: selectEndpoints(state).logoutUrl,
+ userName: selectUserName(state),
+ userLogin: selectUserLogin(state),
};
}
diff --git a/app/reducers.js b/app/reducers.js
index 64e2a5a..dfd56ae 100644
--- a/app/reducers.js
+++ b/app/reducers.js
@@ -6,7 +6,6 @@ import { fromJS } from 'immutable';
import { combineReducers } from 'redux-immutable';
import { LOCATION_CHANGE } from 'connected-react-router';
-import appReducer from 'containers/App/reducer';
import languageProviderReducer from 'containers/LanguageProvider/reducer';
/*
@@ -47,7 +46,6 @@ export function routeReducer(state = routeInitialState, action) {
export default function createReducer(injectedReducers) {
return combineReducers({
route: routeReducer,
- app: appReducer,
language: languageProviderReducer,
...injectedReducers,
});
diff --git a/app/styles.scss b/app/styles.scss
index 06d64c7..b53ed8e 100644
--- a/app/styles.scss
+++ b/app/styles.scss
@@ -1,5 +1,7 @@
$breadcrumb-bg: transparent;
$breadcrumb-divider: quote(">") !default;
+$code-color: inherit;
+$pre-color: inherit;
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
diff --git a/app/utils/request.js b/app/utils/request.js
index 0b6f5ba..05b689d 100644
--- a/app/utils/request.js
+++ b/app/utils/request.js
@@ -20,6 +20,42 @@ export async function getBody(response) {
: response.text();
}
+export function buildApiUrl(url, params) {
+ let [
+ path,
+ query,
+ ] = url.split('?');
+
+ path = path.replace(/{(\w+)}/g, (match, key) => {
+ return params[key] == null
+ ? String(params[key])
+ : encodeURIComponent(params[key]);
+ });
+
+ query = (query || '').split('&')
+ .map((part) => {
+ const match = part.match(/^([\w_]+)={(\w+)}$/);
+
+ // Keep the part as-is if it doesn't contain a param to replace.
+ if (!match) {
+ return part;
+ }
+
+ const value = params[match[2]];
+
+ // Skip the part if the param value is undefined.
+ if (value === undefined) {
+ return null;
+ }
+
+ return `${match[1]}=${encodeURIComponent(value)}`;
+ })
+ .filter((v) => v !== null)
+ .join('&');
+
+ return `${path}${query ? `?${query}` : ''}`;
+}
+
/**
*
* @param {string} url
@@ -48,13 +84,25 @@ export async function requestJson(url, options = {}) {
}
if (response.ok) {
- return response.json();
+ try {
+ return await response.json();
+ }
+ catch (err) {
+ err.message = `Invalid JSON: ${err.message}`;
+ throw err;
+ }
}
const error = new Error(response.statusText);
error.status = response.status;
error.isJson = true;
- error.body = await response.json();
+
+ try {
+ error.body = await response.json();
+ }
+ catch (err) {
+ error.message = `Invalid JSON: ${err.message}`;
+ }
Object.defineProperty(error, 'getResponse', {
enumerable: false,
diff --git a/package-lock.json b/package-lock.json
index 4a24520..4db7ded 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19787,6 +19787,33 @@
}
}
},
+ "webpack-core": {
+ "version": "0.6.9",
+ "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.9.tgz",
+ "integrity": "sha1-/FcViMhVjad76e+23r3Fo7FyvcI=",
+ "dev": true,
+ "requires": {
+ "source-list-map": "0.1.8",
+ "source-map": "0.4.4"
+ },
+ "dependencies": {
+ "source-list-map": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-0.1.8.tgz",
+ "integrity": "sha1-xVCyq1Qn9rPyH1r+rYjE9Vh7IQY=",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz",
+ "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=",
+ "dev": true,
+ "requires": {
+ "amdefine": "1.0.1"
+ }
+ }
+ }
+ },
"webpack-dev-middleware": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.1.3.tgz",
@@ -20065,6 +20092,15 @@
}
}
},
+ "webpack-subresource-integrity": {
+ "version": "1.1.0-rc.4",
+ "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-1.1.0-rc.4.tgz",
+ "integrity": "sha1-xcTj1pD50vZKlVDgeodn+Xlqpdg=",
+ "dev": true,
+ "requires": {
+ "webpack-core": "0.6.9"
+ }
+ },
"whatwg-encoding": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.4.tgz",
diff --git a/package.json b/package.json
index 213947a..1506502 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"preinstall": "npm run npmcheckversion",
"prebuild": "npm run build:clean",
"build": "cross-env NODE_ENV=production webpack --config webpack.config.js --color -p --progress --hide-modules --display-optimization-bailout",
+ "build:ci": "cross-env NODE_ENV=production webpack --config webpack.config.js -p --hide-modules --display-optimization-bailout",
"build:clean": "rimraf ./build",
"start": "cross-env NODE_ENV=development WEBPACK_SERVE=1 webpack-serve --port 3000 --hmr",
"start:production": "npm run test && npm run build && npm run start:prod",
@@ -32,8 +33,8 @@
"generate": "plop --plopfile internals/generators/index.js",
"lint": "npm run lint:js && npm run lint:css",
"lint:css": "stylelint './app/**/*.js'",
- "lint:eslint": "eslint --ignore-path .gitignore --ignore-pattern internals/scripts",
- "lint:eslint:fix": "eslint --ignore-path .gitignore --ignore-pattern internals/scripts --fix",
+ "lint:eslint": "eslint --max-warnings 0 --ignore-path .gitignore --ignore-pattern internals/scripts",
+ "lint:eslint:fix": "eslint --max-warnings 0 --ignore-path .gitignore --ignore-pattern internals/scripts --fix",
"lint:js": "npm run lint:eslint -- . ",
"lint:staged": "lint-staged",
"pretest": "npm run test:clean && npm run lint",
@@ -46,12 +47,7 @@
},
"lint-staged": {
"*.js": [
- "npm run lint:eslint:fix",
- "git add --force"
- ],
- "*.json": [
- "prettier --write",
- "git add --force"
+ "npm run lint:eslint"
]
},
"pre-commit": "lint:staged",
@@ -190,6 +186,7 @@
"url-loader": "^1.0.1",
"webpack": "^4.16.5",
"webpack-bundle-analyzer": "^2.13.1",
- "webpack-cli": "^3.1.0"
+ "webpack-cli": "^3.1.0",
+ "webpack-subresource-integrity": "^1.1.0-rc.4"
}
}
diff --git a/webpack.config.js b/webpack.config.js
index 5e2d21c..4b7a0e6 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -2,6 +2,7 @@ const path = require('path');
const { HashedModuleIdsPlugin } = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CircularDependencyPlugin = require('circular-dependency-plugin');
+const SriPlugin = require('webpack-subresource-integrity');
const isProduction = process.env.NODE_ENV === 'production';
const isServe = !!process.env.WEBPACK_SERVE;
@@ -21,6 +22,7 @@ module.exports = {
],
output: {
+ crossOriginLoading: 'anonymous',
path: path.resolve(__dirname, 'build'),
publicPath: '/static/',
...(isProduction && !isServe ? {
@@ -176,6 +178,11 @@ module.exports = {
plugins: [
!!process.env.BUNDLE_ANALYZER && new (require('webpack-bundle-analyzer').BundleAnalyzerPlugin)(),
+ new SriPlugin({
+ hashFuncNames: ['sha256', 'sha384'],
+ enabled: process.env.NODE_ENV === 'production',
+ }),
+
new HtmlWebpackPlugin({
inject: true, // Inject all files that are generated by webpack, e.g. bundle.js
template: 'app/index.html',