diff --git a/package.json b/package.json index 8b439c2..8dc29e6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bootstrap-sass": "^3.3.7", "color": "~1.0.3", "cookie-parser": "~1.4.3", + "cors": "^2.8.3", "crossroads": "~0.12.2", "diff2html": "^2.3.0", "express": "~4.15.2", @@ -60,6 +61,7 @@ "react-dom": "^15.5.4", "react-redux": "^5.0.4", "redux": "^3.6.0", + "redux-thunk": "^2.2.0", "rimraf": "~2.6.1", "semver": "~5.3.0", "serve-static": "~1.12.2", diff --git a/source/server.js b/source/server.js index 473151f..5f77050 100644 --- a/source/server.js +++ b/source/server.js @@ -3,6 +3,7 @@ const BugTracker = require('./bugtracker'); const bugtracker = new BugTracker('server'); const usageStatistics = require('./usage-statistics'); const express = require('express'); +const cors = require('cors') const gitApi = require('./git-api'); const winston = require('winston'); const sysinfo = require('./sysinfo'); @@ -107,6 +108,7 @@ const noCache = (req, res, next) => { app.use(noCache); app.use(require('body-parser').json()); +app.use(cors()); // we should consider to remove it when we complete this project if (config.autoShutdownTimeout) { let autoShutdownTimeout; @@ -270,6 +272,20 @@ app.get('/serverdata.js', (req, res) => { }); }); +app.get('/ungit/config', (req, res) => { + sysinfo.getUserHash() + .then((hash) => { + const ungitConfig = { + config, + userHash: hash, + version: config.ungitDevVersion, + platform: os.platform(), + pluginApiVersion: require('../package.json').ungitPluginApiVersion + }; + res.send(JSON.stringify(ungitConfig)); + }); +}); + app.get('/api/latestversion', (req, res) => { sysinfo.getUngitLatestVersion() .then((latestVersion) => { diff --git a/src-react/actions/bootstrap.js b/src-react/actions/bootstrap.js new file mode 100644 index 0000000..582d442 --- /dev/null +++ b/src-react/actions/bootstrap.js @@ -0,0 +1,12 @@ +import { fetchUngitConfig } from './ungit-config'; +import { fetchLatestVersion, fetchGitVersion } from './version'; +import { pending } from './common'; + +export function bootstrap() { + return dispatch => { + dispatch(pending(3)); + dispatch(fetchUngitConfig()); + dispatch(fetchLatestVersion()); + dispatch(fetchGitVersion()); + }; +} \ No newline at end of file diff --git a/src-react/actions/common.js b/src-react/actions/common.js new file mode 100644 index 0000000..de1a3c2 --- /dev/null +++ b/src-react/actions/common.js @@ -0,0 +1,16 @@ +/* This export common using actionCreator */ +import * as types from 'constants/action-types'; + +export function pending(count) { + return { + type: types.PATH_PAGE_PENDING, + payload: count || 1 + }; +}; + +export function apiError(message) { + return { + type: types.PATH_PAGE_API_ERR, + payload: message + }; +} \ No newline at end of file diff --git a/src-react/actions/ungit-config.js b/src-react/actions/ungit-config.js new file mode 100644 index 0000000..de55431 --- /dev/null +++ b/src-react/actions/ungit-config.js @@ -0,0 +1,29 @@ +import * as types from 'constants/action-types'; +import { fetchUserConfig } from './user-config'; +import { apiError, pending } from './common'; + +export function fetchUngitConfig() { + return dispatch => { + // consider wrap API call in separate modules + // it will be easy to stub module's function when testing + fetch('http://localhost:8448/ungit/config') + .then(response => response.json()) + .then(json => { + if (!json.config.bugtracking) { + dispatch(pending()); + dispatch(fetchUserConfig()); + } + dispatch(receiveUngitConfig(json)); + }) + .catch(e => { + dispatch(apiError(e.message)); + }); + }; +}; + +function receiveUngitConfig(ungitConfig) { + return { + type: types.RECEIVE_UNGIT_CONFIG, + payload: ungitConfig + }; +}; \ No newline at end of file diff --git a/src-react/actions/user-config.js b/src-react/actions/user-config.js new file mode 100644 index 0000000..03f71a6 --- /dev/null +++ b/src-react/actions/user-config.js @@ -0,0 +1,25 @@ +import * as types from 'constants/action-types'; +import { apiError } from './common'; + +export function fetchUserConfig() { + return dispatch => { + // consider wrap API call in separate modules + // it will be easy to stub module's function when testing + fetch('http://localhost:8448/api/userconfig') + .then(response => response.json()) + .then(json => { + dispatch(receiveUserConfig(json)); + }) + .catch(e => { + dispatch(apiError(e.message)); + }); + }; +}; + + +function receiveUserConfig(userConfig) { + return { + type: types.RECEIVE_USER_CONFIG, + payload: userConfig + }; +}; \ No newline at end of file diff --git a/src-react/actions/version.js b/src-react/actions/version.js new file mode 100644 index 0000000..a4c53fb --- /dev/null +++ b/src-react/actions/version.js @@ -0,0 +1,46 @@ +import * as types from 'constants/action-types'; +import { apiError } from './common'; + +export function fetchLatestVersion() { + return dispatch => { + // consider wrap API call in separate modules + // it will be easy to stub module's function when testing + fetch('http://localhost:8448/api/latestversion') + .then(response => response.json()) + .then(json => { + dispatch(receiveLatestVersion(json)); + }) + .catch(e => { + dispatch(apiError(e.message)); + }); + }; +} + +export function fetchGitVersion() { + return dispatch => { + // consider wrap API call in separate modules + // it will be easy to stub module's function when testing + fetch('http://localhost:8448/api/gitversion') + .then(response => response.json()) + .then(json => { + dispatch(receiveGitVersion(json)); + }) + .catch(e => { + dispatch(apiError(e.message)); + }); + }; +} + +function receiveGitVersion(gitVersion) { + return { + type: types.RECEIVE_GIT_VERSION, + payload: gitVersion + }; +} + +function receiveLatestVersion(latestVersion) { + return { + type: types.RECEIVE_LATEST_VERSION, + payload: latestVersion + }; +}; \ No newline at end of file diff --git a/src-react/components/alert-area/bug-tracking-nag-screen.js b/src-react/components/alert-area/bug-tracking-nag-screen.js new file mode 100644 index 0000000..a587388 --- /dev/null +++ b/src-react/components/alert-area/bug-tracking-nag-screen.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; + +class BugTrackingNagScreen extends Component { + render() { + return ( +
+ +

Help make ungit better with the press of a button!

+ + + +
+ ); + } +} + +export default BugTrackingNagScreen; \ No newline at end of file diff --git a/src-react/components/alert-area/git-version-error.js b/src-react/components/alert-area/git-version-error.js new file mode 100644 index 0000000..e164f9a --- /dev/null +++ b/src-react/components/alert-area/git-version-error.js @@ -0,0 +1,18 @@ +import React, { Component, PropTypes } from 'react'; + +class GitVersionError extends Component { + static propTypes = { + gitVersionError: PropTypes.string + } + + render() { + return ( +
+ { this.props.gitVersionError } + +
+ ); + } +} + +export default GitVersionError; \ No newline at end of file diff --git a/src-react/components/alert-area/index.js b/src-react/components/alert-area/index.js new file mode 100644 index 0000000..095d6b1 --- /dev/null +++ b/src-react/components/alert-area/index.js @@ -0,0 +1,62 @@ +import React, { Component, PropTypes } from 'react'; + +import GitVersionError from './git-version-error'; +import NewVersionAvailable from './new-version-available'; +import BugTrackingNagScreen from './bug-tracking-nag-screen'; +import NPSSurvey from './nps-survey'; + +class AlertArea extends Component { + static propTypes = { + config: PropTypes.object.isRequired, + gitVersionErrorVisible: PropTypes.bool.isRequired, + showNewVersionAvailable: PropTypes.bool.isRequired, + showBugtrackingNagscreen: PropTypes.bool.isRequired, + showNPSSurvey: PropTypes.bool.isRequired + } + + render() { + const { + config: { + ungitConfig: { platform }, + versions: { + gitVersion: { error }, + latestVersion: { + latestVersion + } + } + }, + gitVersionErrorVisible, + showNewVersionAvailable, + showBugtrackingNagscreen, + showNPSSurvey + } = this.props; + return ( +
+ { + gitVersionErrorVisible ? ( + + ) : null + } + { + showNewVersionAvailable ? ( + + ) : null + } + { + showBugtrackingNagscreen ? ( + + ) : null + } + { + showNPSSurvey ? ( + + ) : null + } +
+ ); + } +} + +export default AlertArea; \ No newline at end of file diff --git a/src-react/components/alert-area/new-version-available.js b/src-react/components/alert-area/new-version-available.js new file mode 100644 index 0000000..ed138cf --- /dev/null +++ b/src-react/components/alert-area/new-version-available.js @@ -0,0 +1,24 @@ +import React, { Component, PropTypes } from 'react'; + +class NewVersionAvailable extends Component { + static propTypes = { + latestVersion: PropTypes.string, + platform: PropTypes.string + } + + render() { + const newVersionInstallCommand = `#{this.props.platform ? '' : 'sudo -H'}npm update -g ungit`; + return ( +
+ A new version of ungit ({ this.props.latestVersion }) is + available! Run + { newVersionInstallCommand } to install. + See what's new in the + changelog. + +
+ ); + } +} + +export default NewVersionAvailable; \ No newline at end of file diff --git a/src-react/components/alert-area/nps-survey.js b/src-react/components/alert-area/nps-survey.js new file mode 100644 index 0000000..b313d9c --- /dev/null +++ b/src-react/components/alert-area/nps-survey.js @@ -0,0 +1,37 @@ +import React, { Component } from 'react'; + +class NPSSurvey extends Component { + static propTypes = { + } + + render() { + return ( +
+ + Hi! This is a one-question survey to learn more about how people use Ungit. You can dismiss it by clicking the x in the upper right corner. +

Question: How likely are you to recommend Ungit to your friends and colleagues?

+

+

+ 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 +
+
+ Not at all likely +
Extremely likely
+
+

+
+ ); + } +} + +export default NPSSurvey; \ No newline at end of file diff --git a/src-react/constants/action-types.js b/src-react/constants/action-types.js new file mode 100644 index 0000000..69ec0e4 --- /dev/null +++ b/src-react/constants/action-types.js @@ -0,0 +1,6 @@ +export const RECEIVE_GIT_VERSION = 'RECEIVE_GIT_VERSION'; +export const RECEIVE_LATEST_VERSION = 'RECEIVE_LATEST_VERSION'; +export const RECEIVE_USER_CONFIG = 'RECEIVE_USER_CONFIG'; +export const RECEIVE_UNGIT_CONFIG = 'RECEIVE_UNGIT_CONFIG'; +export const PATH_PAGE_PENDING = 'PATH_PAGE_PENDING'; +export const PATH_PAGE_API_ERR = 'PATH_PAGE_API_ERR'; \ No newline at end of file diff --git a/src-react/containers/path.js b/src-react/containers/path.js index 499d4e9..2585882 100644 --- a/src-react/containers/path.js +++ b/src-react/containers/path.js @@ -1,19 +1,41 @@ import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; import { connect } from 'react-redux' +import AlertArea from 'components/alert-area'; +import * as bootstrapActionCreators from 'actions/bootstrap'; import 'styles/styles.scss'; -@connect(state => { return { ...state } }) +@connect(state => { + return { ...state }; +}, dispatch => { + return { + actions: bindActionCreators(Object.assign({}, bootstrapActionCreators), dispatch) + }; +}) class Path extends Component { + + componentWillMount() { + const { actions } = this.props; + actions.bootstrap(); + } + render() { + const { path: { pending, errMessage }, app } = this.props; return ( -
-
-

Welcome to { this.props.app }

+
+
+
+ { + pending === 0 && errMessage.length === 0 ? ( + + ) : null + }
-

- To get started, edit src/container/Path.js and save to reload. -

); } diff --git a/src-react/reducers/app.js b/src-react/reducers/app.js new file mode 100644 index 0000000..52c889e --- /dev/null +++ b/src-react/reducers/app.js @@ -0,0 +1,48 @@ +import * as types from 'constants/action-types'; + +function app(state, action, config) { + + switch(action.type) { + case types.RECEIVE_UNGIT_CONFIG: { + const { ungitConfig } = config; + const bugtrackingNagscreenDismissed = localStorage.getItem('bugtrackingNagscreenDismissed'); + const showBugtrackingNagscreen = !ungitConfig.config.bugtracking && !bugtrackingNagscreenDismissed; + + return { ...state, showBugtrackingNagscreen }; + } + + case types.RECEIVE_GIT_VERSION: { + const { ungitConfig, versions: { gitVersion } } = config; + const gitVersionCheckOverride = ungitConfig.config && ungitConfig.config.gitVersionCheckOverride; + const gitVersionErrorDismissed = localStorage.getItem('gitVersionErrorDismissed'); + const gitVersionError = gitVersion && !gitVersion.satisfied && gitVersion.error; + const gitVersionErrorVisible = !gitVersionCheckOverride && gitVersionError && gitVersionErrorDismissed; + + return { ...state, gitVersionErrorVisible }; + } + + case types.RECEIVE_LATEST_VERSION: { + const { ungitConfig, versions: { latestVersion } } = config; + const gitVersionCheckOverride = ungitConfig.config && ungitConfig.config.gitVersionCheckOverride; + const outdated = latestVersion && latestVersion.outdated; + const showNewVersionAvailable = !gitVersionCheckOverride && outdated; + + return { ...state, showNewVersionAvailable }; + } + + case types.RECEIVE_USER_CONFIG: { + const { userConfig } = config; + const bugtrackingNagscreenDismissed = localStorage.getItem('bugtrackingNagscreenDismissed'); + const showBugtrackingNagscreen = !userConfig.bugtracking && !bugtrackingNagscreenDismissed; + + return { ...state, showBugtrackingNagscreen }; + } + default: + const NPSSurveyLastDismissed = parseInt(localStorage.getItem('NPSSurveyLastDismissed') || '0', 10); + const monthsSinceNPSLastDismissed = (Date.now() - NPSSurveyLastDismissed) / (1000 * 60 * 60 * 24 * 30); + const showNPSSurvey = monthsSinceNPSLastDismissed >= 6 && Math.random() < 0.01; + return { ...state, showNPSSurvey }; + } +} + +export default app; \ No newline at end of file diff --git a/src-react/reducers/config.js b/src-react/reducers/config.js new file mode 100644 index 0000000..fcb2a08 --- /dev/null +++ b/src-react/reducers/config.js @@ -0,0 +1,13 @@ +import { combineReducers } from 'redux'; + +import ungitConfig from './ungit-config'; +import userConfig from './user-config'; +import versions from './versions'; + +const config = combineReducers({ + ungitConfig, + userConfig, + versions +}); + +export default config; \ No newline at end of file diff --git a/src-react/reducers/index.js b/src-react/reducers/index.js index c89fed9..2659960 100644 --- a/src-react/reducers/index.js +++ b/src-react/reducers/index.js @@ -1,9 +1,14 @@ -const initialState = { - app: 'React' -}; +import config from './config'; +import app from './app'; +import path from './path'; -const ungitApp = function(state, action) { - return { ...initialState }; +function ungitApp(state, action) { + const _config = config(state.config, action); + return { + config: _config, + app: app(state.app, action, _config), + path: path(state.path, action, _config), + }; } export default ungitApp; \ No newline at end of file diff --git a/src-react/reducers/path.js b/src-react/reducers/path.js new file mode 100644 index 0000000..f3d3469 --- /dev/null +++ b/src-react/reducers/path.js @@ -0,0 +1,20 @@ +import * as types from 'constants/action-types'; + +function path(state, action) { + switch(action.type) { + case types.PATH_PAGE_PENDING: + return { ...state, pending: state.pending + action.payload }; + case types.RECEIVE_UNGIT_CONFIG: + case types.RECEIVE_USER_CONFIG: + case types.RECEIVE_GIT_VERSION: + case types.RECEIVE_LATEST_VERSION: + return { ...state, pending: state.pending - 1 }; + case types.PATH_PAGE_API_ERR: + const { payload: errMessage } = action; + return { ...state, pending: state.pending - 1, errMessage: [ ...state.errMessage, errMessage ] }; + default: + return { ...state }; + } +} + +export default path; \ No newline at end of file diff --git a/src-react/reducers/ungit-config.js b/src-react/reducers/ungit-config.js new file mode 100644 index 0000000..6bbac31 --- /dev/null +++ b/src-react/reducers/ungit-config.js @@ -0,0 +1,13 @@ +import * as types from 'constants/action-types'; + +function ungitConfig(state, action) { + switch(action.type) { + case types.RECEIVE_UNGIT_CONFIG: + const { payload: ungitConfig } = action; + return { ...ungitConfig }; + default: + return { ...state }; + } +} + +export default ungitConfig; \ No newline at end of file diff --git a/src-react/reducers/user-config.js b/src-react/reducers/user-config.js new file mode 100644 index 0000000..23f3b65 --- /dev/null +++ b/src-react/reducers/user-config.js @@ -0,0 +1,13 @@ +import * as types from 'constants/action-types'; + +function userConfig(state, action) { + switch(action.type) { + case types.RECEIVE_USER_CONFIG: + const { payload: userConfig } = action; + return { ...userConfig }; + default: + return { ...state }; + } +} + +export default userConfig; \ No newline at end of file diff --git a/src-react/reducers/versions.js b/src-react/reducers/versions.js new file mode 100644 index 0000000..9ddae17 --- /dev/null +++ b/src-react/reducers/versions.js @@ -0,0 +1,16 @@ +import * as types from 'constants/action-types'; + +function userConfig(state, action) { + switch(action.type) { + case types.RECEIVE_GIT_VERSION: + const { payload: gitVersion } = action; + return { ...state, gitVersion }; + case types.RECEIVE_LATEST_VERSION: + const { payload: latestVersion } = action; + return { ...state, latestVersion }; + default: + return { ...state }; + } +} + +export default userConfig; \ No newline at end of file diff --git a/src-react/store.js b/src-react/store.js index 5af56c6..88a8839 100644 --- a/src-react/store.js +++ b/src-react/store.js @@ -1,6 +1,28 @@ -import { createStore } from 'redux'; +import { createStore, applyMiddleware } from 'redux'; +import thunk from 'redux-thunk'; import ungitApp from './reducers'; -const store = createStore(ungitApp); +const initialState = { + config: { + ungitConfig: null, + userConfig: null, + versions: { + gitVersion: null, + latestVersion: null + } + }, + app: { + gitVersionErrorVisible: false, + showNewVersionAvailable: false, + showBugtrackingNagscreen: false, + showNPSSurvey: false + }, + path: { + pending: null, + errMessage: [] + } +}; + +const store = createStore(ungitApp, initialState, applyMiddleware(thunk)); export default store; \ No newline at end of file