diff --git a/.gitignore b/.gitignore index cb923a46d0e..e12ded0f96a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules .DS_Store .idea/ *.iml +config.json diff --git a/README.md b/README.md index d283284bf8e..c400de716be 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,30 @@ To compile electron + the app and the installer for your current platform: npm run release ``` +## Configuration + +### Newrelic + +By default, newrelic is disabled because we don't want to check-in the license key. + +Login to newrelic and copy the [license_key](https://rpm.newrelic.com/accounts/933509). + +It can be set at build time by adding the following to `./config.json`: + +```json +{ + "newrelic": { + "license_key": "" + } +} +``` + +Alternatively to just try it out or to debug a problem use environment variables: + +```bash +newrelic__license_key='' npm start; +``` + [setup-osx]: https://github.com/mongodb-js/mongodb-js/blob/master/docs/setup.md#osx-setup [setup-windows]: https://github.com/mongodb-js/mongodb-js/blob/master/docs/setup.md#windows-setup [setup-linux]: https://github.com/mongodb-js/mongodb-js/blob/master/docs/setup.md#linux-setup diff --git a/gulpfile.js b/gulpfile.js index 04c3585b335..779a0ccf7d9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -12,6 +12,7 @@ var shell = require('gulp-shell'); var path = require('path'); var del = require('del'); var spawn = require('child_process').spawn; +var config = require('./src/electron/config'); var notify = require('./tasks/notify'); var pkg = require('./package.json'); @@ -127,7 +128,8 @@ gulp.task('pages', function() { return gulp.src('src/index.jade') .pipe(jade({ locals: { - NODE_ENV: process.env.NODE_ENV + NODE_ENV: process.env.NODE_ENV, + CONFIG: JSON.stringify(config.toJSON()) } })) .on('error', notify('jade')) @@ -147,8 +149,13 @@ gulp.task('copy:images', function() { gulp.task('copy:electron', function() { return merge( - gulp.src(['main.js', 'package.json', 'settings.json', 'README.md']) - .pipe(gulp.dest('build/')), + gulp.src([ + 'main.js', + 'package.json', + 'config.json', + 'node_modules/animate.css/animate.css', + 'README.md' + ]).pipe(gulp.dest('build/')), gulp.src(['src/electron/*']) .pipe(gulp.dest('build/src/electron')) ); diff --git a/images/logo-scout.png b/images/logo-scout.png new file mode 100644 index 00000000000..9bbc63e39f0 Binary files /dev/null and b/images/logo-scout.png differ diff --git a/images/mongodb-leaf-development.png b/images/mongodb-leaf-development.png new file mode 100644 index 00000000000..4eef572b54f Binary files /dev/null and b/images/mongodb-leaf-development.png differ diff --git a/images/mongodb-leaf.png b/images/mongodb-leaf.png new file mode 100644 index 00000000000..d177739dfbd Binary files /dev/null and b/images/mongodb-leaf.png differ diff --git a/images/mongodb-leaf_256x256.png b/images/mongodb-leaf_256x256.png new file mode 100644 index 00000000000..f55c910fea8 Binary files /dev/null and b/images/mongodb-leaf_256x256.png differ diff --git a/package.json b/package.json index 481a3830cc2..521f818e866 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,14 @@ "font-awesome", "octicons", "app", + "ipc", "auto-updater", "crash-reporter", "browser-window", "menu", "jade", - "ampersand-state" + "ampersand-state", + "animate.css" ] }, "fonts": [ @@ -72,6 +74,8 @@ "ampersand-sync-localforage": "^0.1.1", "ampersand-view": "^8.0.0", "ampersand-view-switcher": "^2.0.0", + "animate.css": "^3.2.5", + "animationend": "0.0.1", "bootstrap": "https://github.com/twbs/bootstrap/archive/v3.3.5.tar.gz", "bugsnag-js": "^2.4.8", "d3": "^3.5.5", @@ -88,16 +92,22 @@ "mongodb-language-model": "^0.2.1", "mongodb-schema": "^3.3.0", "mousetrap": "^1.5.3", + "nconf": "^0.7.1", + "newrelic": "^1.21.1", "numeral": "^1.5.3", "octicons": "https://github.com/github/octicons/archive/v2.2.0.tar.gz", + "octonode": "^0.7.1", "phantomjs-polyfill": "0.0.1", "pluralize": "^1.1.2", "qs": "^3.1.0", "raf": "^3.0.0", + "request": "^2.60.0", "scout-brain": "http://bin.mongodb.org/js/scout-brain/v0.0.2/scout-brain-0.0.2.tar.gz", "scout-client": "http://bin.mongodb.org/js/scout-client/v0.0.5/scout-client-0.0.5.tar.gz", "scout-server": "http://bin.mongodb.org/js/scout-server/v0.0.4/scout-server-0.0.4.tar.gz", "tiny-lr": "^0.1.6", + "untildify": "^2.1.0", + "uuid": "^2.0.1", "watch": "^0.16.0" }, "devDependencies": { diff --git a/src/app.js b/src/app.js index 22c8ba9e350..bf3f6c596ed 100644 --- a/src/app.js +++ b/src/app.js @@ -1,19 +1,39 @@ +// Injected into index.html by the gulp build task. +var CONFIG = window.CONFIG; + +// Do most basic setup of app here so we can get bugsnag listening +// for errors as high in the stack as possible. +var _ = require('lodash'); var pkg = require('../package.json'); var app = require('ampersand-app'); +/*eslint no-bitwise:0*/ app.extend({ + // @todo (imlucas): use http://npm.im/osx-release and include platform details + // in event tracking. meta: { 'App Version': pkg.version + }, + config: CONFIG, + /** + * feature switch, returns boolean if feature `name` is enabled. + * @param {String} name name of the feature, e.g. 'querybuilder' + * @return {Boolean} whether feature is enabled or not + */ + isFeatureEnabled: function(name) { + return Boolean(~~_.get(CONFIG, name + '.enabled')); } }); -require('./bugsnag').listen(app); -var _ = require('lodash'); +require('./bugsnag'); + var domReady = require('domready'); var qs = require('qs'); var getOrCreateClient = require('scout-client'); var ViewSwitcher = require('ampersand-view-switcher'); var View = require('ampersand-view'); var localLinks = require('local-links'); +var intercom = require('./intercom'); +var debug = require('debug')('scout:app'); /** * The top-level application singleton that brings everything together! @@ -67,11 +87,7 @@ var Application = View.extend({ /** * @see http://learn.humanjavascript.com/react-ampersand/creating-a-router-and-pages */ - router: 'object', - /** - * Enable/Disable features with one global switch - */ - features: 'object' + router: 'object' }, events: { 'click a': 'onLinkClick' @@ -85,6 +101,16 @@ var Application = View.extend({ this.listenTo(this.router, 'page', this.onPageChange); + this.router.on('page', intercom.update); + + /*eslint no-console:0*/ + app.getOrCreateUser(function(err, user) { + if (err) return console.error(err); + + this.user.set(user.serialize()); + intercom.inject(user); + }.bind(this)); + this.router.history.start({ pushState: false, root: '/' @@ -130,6 +156,7 @@ var Application = View.extend({ event.preventDefault(); this.router.history.navigate(pathname); } + intercom.update(); } }); @@ -142,27 +169,45 @@ var state = new Application({ var QueryOptions = require('./models/query-options'); var Connection = require('./models/connection'); var MongoDBInstance = require('./models/mongodb-instance'); +var User = require('./models/user'); var Router = require('./router'); var Statusbar = require('./statusbar'); +require('./context-menu-manager'); + app.extend({ - client: null, + openSetupDialog: function() { + app.ipc.send('open-setup-dialog'); + }, + openConnectDialog: function() { + app.ipc.send('open-connect-dialog'); + }, init: function() { state.statusbar = new Statusbar(); this.connection = new Connection(); this.connection.use(uri); this.queryOptions = new QueryOptions(); this.volatileQueryOptions = new QueryOptions(); - this.instance = new MongoDBInstance(); - // feature flags - this.features = { - querybuilder: true - }; + state.queryOptions = new QueryOptions(); + state.instance = new MongoDBInstance(); + state.user = new User(); state.router = new Router(); + + this.on('change:ipc', function() { + debug('ipc now available!'); + }); }, - navigate: state.navigate.bind(state) + use: function(fn) { + fn.call(null, this); + }, + intercom: intercom, + navigate: state.navigate.bind(state), + back: function() { + this.router.history.history.back(); + }, + getOrCreateUser: User.getOrCreate }); Object.defineProperty(app, 'statusbar', { @@ -171,22 +216,50 @@ Object.defineProperty(app, 'statusbar', { } }); +Object.defineProperty(app, 'user', { + get: function() { + return state.user; + } +}); + +Object.defineProperty(app, 'instance', { + get: function() { + return state.instance; + } +}); + +Object.defineProperty(app, 'queryOptions', { + get: function() { + return state.queryOptions; + } +}); + +Object.defineProperty(app, 'connection', { + get: function() { + return state.connection; + } +}); + +Object.defineProperty(app, 'router', { + get: function() { + return state.router; + } +}); + Object.defineProperty(app, 'client', { get: function() { return getOrCreateClient({ - seed: app.connection.uri + seed: state.connection.uri }); } }); app.init(); -// expose app globally for debugging purposes -window.app = app; - function render_app() { state._onDOMReady(); } domReady(render_app); +// expose app globally for debugging purposes window.app = app; diff --git a/src/bugsnag.js b/src/bugsnag.js index d6063e6cb71..4a3f82096e8 100644 --- a/src/bugsnag.js +++ b/src/bugsnag.js @@ -9,12 +9,10 @@ */ var bugsnag = require('bugsnag-js'); var redact = require('./redact'); -var app = require('ampersand-app'); var _ = require('lodash'); +var app = require('ampersand-app'); var debug = require('debug')('scout:bugsnag'); -var TOKEN = '0d11ab5f4d97452cc83d3365c21b491c'; - // @todo (imlucas): use mongodb-redact function beforeNotify(d) { d.stacktrace = redact(d.stacktrace); @@ -36,13 +34,18 @@ function beforeNotify(d) { * @todo (imlucas): When first-run branch merged, include user id: * https://github.com/bugsnag/bugsnag-js#user */ -module.exports.listen = function listen() { - if (!process.env.NODE_ENV) { - process.env.NODE_ENV = 'development'; - } - +// <<<<<<< HEAD +var enabled = _.get(app.get, 'bugsnag.enabled'); +if (!enabled) { + // ======= + // module.exports.listen = function listen() { + // if (!process.env.NODE_ENV) { + // process.env.NODE_ENV = 'development'; + // } + // + // >>>>>>> master _.assign(bugsnag, { - apiKey: TOKEN, + apiKey: _.get(app.config, 'bugsnag.token'), autoNotify: true, releaseStage: process.env.NODE_ENV, notifyReleaseStages: ['production', 'development'], @@ -50,6 +53,14 @@ module.exports.listen = function listen() { metaData: app.meta, beforeNotify: beforeNotify }); - app.bugsnag = bugsnag; -}; + + module.exports.trackError = function(err) { + return bugsnag.notifyException(err); + }; +} else { + /*eslint no-console:0*/ + module.exports.trackError = function(err) { + console.log('Error', err); + }; +} diff --git a/src/context-menu-manager.js b/src/context-menu-manager.js new file mode 100644 index 00000000000..5dfe0c3c150 --- /dev/null +++ b/src/context-menu-manager.js @@ -0,0 +1,50 @@ +/** + * Making context menus work (aka right-click menu) involes 2 exchanges + * between the web-page and the main processes: + * + * 1. `web-page` adds a listener for a document `contextmenu` event and sends + * a `show-context-menu` message with `template`. + * 2. `main` catches `show-context-menu`, adds proper click handlers for `template` + * and calls `electron#Menu.buildFromTemplate(template).popup()` to make the + * context menu actually appear. + * 3. When a menu item is actually clicked, `main` sends a `run-command` message + * to the owning `web-page`. + * 4. `web-page` calls `electron#ipc.send(item.command, item.opts)` + * 5. If `main` is listening for `item.command`, it will be executed. + * + * @see https://github.com/atom/atom/blob/master/src/context-menu-manager.coffee + */ +var app = require('ampersand-app'); +var $ = require('jquery'); +var debug = require('debug')('scout:context-menu-manager'); + +function ContextMenuManager() { + $(document).on('contextmenu', this.showForEvent.bind(this)); +} + +ContextMenuManager.prototype.showForEvent = function(event) { + debug('show for event'); + event.preventDefault(); + var menuTemplate = this.getTemplateForEvent(event); + if (menuTemplate.length > 0) { + debug('sending show-context-menu for template', menuTemplate); + app.ipc.send('show-context-menu', { + template: menuTemplate + }); + } +}; + +ContextMenuManager.prototype.getTemplateForEvent = function(event) { + var template = []; + template.push({ + label: 'Inspect Element', + command: 'devtools-inspect-element', + opts: { + x: event.pageX, + y: event.pageY + } + }); + return template; +}; + +module.exports = new ContextMenuManager(); diff --git a/src/document-list/index.less b/src/document-list/index.less index 3d52d28d0fd..8fe1a1c8e06 100644 --- a/src/document-list/index.less +++ b/src/document-list/index.less @@ -82,7 +82,7 @@ ol.document-list { cursor: pointer; display: block; - @caret-width-base: 6px; + @caret-width-base: 4px; .caret { display: inline-block; width: 12px; @@ -103,14 +103,18 @@ ol.document-list { } ol.document-property-body { - margin-left: 5px; - border-left: 1px dotted @gray5; + // margin-left: 21px; + padding-left: 36px; + // border-left: 1px dotted @gray6; display: none; } &.expanded { > .document-property-header { > .caret { .caret-down; + vertical-align: bottom; + margin-left: 4px; + margin-right: 8px; } } > ol.document-property-body { diff --git a/src/electron/auto-updater.js b/src/electron/auto-updater.js index e57822f0ff0..6e53a786176 100644 --- a/src/electron/auto-updater.js +++ b/src/electron/auto-updater.js @@ -1,12 +1,12 @@ var app = require('app'); +var ipc = require('ipc'); var updater = module.exports = require('auto-updater'); -var debug = require('debug')('scout-electron:auto-updater'); +var debug = require('debug')('scout:electron:auto-updater'); var FEED_URL = 'http://squirrel.mongodb.parts/scout/releases/latest?version=' + app.getVersion(); debug('Using feed url', FEED_URL); - updater.on('checking-for-update', function() { - debug('checking for update', arguments); + debug('checking for update'); }); updater.on('error', function(err) { @@ -14,20 +14,36 @@ updater.on('error', function(err) { }); updater.on('update-available', function() { - debug('update available', arguments); + debug('update available and downloading...'); }); updater.on('update-not-available', function() { - debug('No update available', arguments); + debug('No update available'); +}); + +updater.on('update-downloaded', function(evt, releaseNotes, releaseName, releaseDate, updateUrl) { + debug('Update downloaded!'); + ipc.send('update-downloaded', { + notes: releaseNotes, + name: releaseName, + date: releaseDate, + url: updateUrl + }); }); -updater.on('update-downloaded', function() { - debug('Update downloaded', arguments); +// When the UI gets the `update-downloaded` message, +// it will show the "Restart to upgrade!" button which +// when clicked, sends the `install-update` message that +// we handle below to actually quit the app and install +// the update. +ipc.on('install-update', function() { + debug('quitting app and updating...'); + app.quitAndUpdate(); }); updater.setFeedUrl(FEED_URL); -app.on('ready', function() { +app.on('will-finish-launching', function() { debug('checking for updates...'); updater.checkForUpdates(); }); diff --git a/src/electron/config.js b/src/electron/config.js new file mode 100644 index 00000000000..91923f08a55 --- /dev/null +++ b/src/electron/config.js @@ -0,0 +1,150 @@ +var app; +try { + app = require('app'); +} catch (e) { + app = null; +} +var nconf = require('nconf'); +var path = require('path'); +var pkg = require('../../package.json'); +var untildify = require('untildify'); +var debug = require('debug')('scout:electron:config'); +var _ = require('lodash'); +var features = {}; + +// ## feature.private +// +// *default* `off` +// +// Don't send any data to or use third-party services. I'm +// working in a high security environment and this any data +// related to mongodb or my data in it cannot leave my workstation. +features.private = { + enabled: false +}; + +// ## feature: setup +// +// *default* `off` +// +// Whether the app should show the setup wizard when the app starts up +// for the first time. +features.setup = { + enabled: false, + version: '1.0.0' +}; +if (app) { + features.setup.file = path.join(app.getPath('userData'), 'setup.json'); +} + +// ## feature: github +// +// *default* `off` +// +// Use GitHub account to fill out inputs in setup wizard and +// enables sharing via GitHub gists. +features.github = { + scope: 'user:email,gist', + // For pointing at a GitHub Enterprise installations + host: 'api.github.com', + // e.g. /api/v3 for some GitHub Enterprise installations + github_path_prefix: null, + protocol: 'https' +}; + +// ## feature: newrelic +// +// *default* `off` +// +// Send app metrics and exceptions to our New Relic account. +features.newrelic = { + app_name: pkg.product_name, + use_ssl: true, + log_enabled: true, + log_level: 'info' +}; +if (app) { + features.newrelic.log_filepath = path.join(app.getPath('temp'), 'newrelic.log'); +} + +// ## feature: bugsnag +// +// *default* `off` +features.bugsnag = {}; + +features.querybuilder = { + enabled: true +}; + +features.intercom = { + enabled: false, + app_id: 'p57suhg7' +}; + + +nconf + .defaults(_.clone(features)) + // Allow setting config values via environment variables, e.g. + // `private__enabled=1 npm start` + .env('__'); + +if (process.env.NODE_ENV === 'development') { + // Use the config json in the project's dropbox for easy + // feature sharing on dev machines. + nconf.file(untildify('~/Dropbox/10gen-scout/config/development.json')); +} + +// Use a bundled `config.json` +// @todo (imlucas): Make `npm run release` write this file from +// environment variables set on evergreen. +nconf.file('bundled', path.resolve(__dirname, '../../config.json')); +nconf.use('memory'); + +/** + * @param {String} name - The feature name to check. + * @return {Boolean} Whether the feature is enabled. + */ +/*eslint no-bitwise:0*/ +var isEnabled = nconf.isFeatureEnabled = function(name) { + return Boolean(~~nconf.get(name + ':enabled')); +}; + +// Use overrides to disable any features that are missing a dependency +// or should not be enabled if `private` is on. +nconf.overrides({ + newrelic: { + enabled: !!nconf.get('newrelic:license_key') && !isEnabled('private') + }, + github: { + enabled: !!nconf.get('github:client_secret') && !isEnabled('private') + }, + bugsnag: { + enabled: !!nconf.get('bugsnag:token') && !isEnabled('private') + }, + intercom: { + enabled: isEnabled('intercom') && !isEnabled('private') + }, + setup: { + enabled: isEnabled('setup') && !isEnabled('private') + } +}); + +var featureNames = _.keys(features); +var featuresEnabledMap = _.chain(featureNames) + .map(function(name) { + return [name, nconf.isFeatureEnabled(name)]; + }) + .zipObject() + .value(); + +nconf.toJSON = function() { + return _.chain(featureNames) + .map(function(name) { + return [name, nconf.get(name)]; + }) + .zipObject() + .value(); +}; + +debug('Config ready! Features enabled: ', featuresEnabledMap); +module.exports = nconf; diff --git a/src/electron/crash-reporter.js b/src/electron/crash-reporter.js index fa884d112c3..ba1cfbc457b 100644 --- a/src/electron/crash-reporter.js +++ b/src/electron/crash-reporter.js @@ -1,8 +1,12 @@ +var app = require('app'); var reporter = module.exports = require('crash-reporter'); -reporter.start({ - productName: 'Scout', - companyName: 'MongoDB', - submitUrl: 'http://breakpad.mongodb.parts/post', - autoSubmit: true +// @todo (imlucas): Point at flytrap. +app.on('will-finish-launching', function() { + reporter.start({ + productName: 'Scout', + companyName: 'MongoDB', + submitUrl: 'http://breakpad.mongodb.parts/post', + autoSubmit: true + }); }); diff --git a/src/electron/github-oauth-flow.js b/src/electron/github-oauth-flow.js new file mode 100644 index 00000000000..c7b12024dde --- /dev/null +++ b/src/electron/github-oauth-flow.js @@ -0,0 +1,106 @@ +var parseURL = require('url').parse; +var request = require('request'); +var octonode = require('octonode'); +var createWindow = require('./window-manager').create; +var config = require('./config'); +var format = require('util').format; +var debug = require('debug')('scout:electron:github-oauth-flow'); +var client; + +function exchangeCodeForToken(code, fn) { + var url = format('https://github.com/login/oauth/access_token' + + '?client_id=%s&client_secret=%s&code=%s', + config.get('github:client_id'), config.get('github:client_secret'), code); + + request.get({ + url: url, + json: true + }, function(err, res, body) { + debug('exchange result res', res); + if (err) { + err.body = body; + err.res = res; + return fn(err); + } + fn(null, body); + }); +} + +function getUser(access_token, done) { + client = octonode.client(access_token); + client.get('/user', {}, function(err, status, body) { + if (err) return done(err); + var res = { + avatar_url: body.avatar_url, + name: body.name, + company_name: body.company, + location: body.location, + email: body.email, + github_username: body.login, + github_score: body.public_repos + body.public_gists + body.followers + body.following, + github_last_activity_at: body.updated_at + }; + + debug('loaded github user data', res); + done(null, res); + }); +} + +function oauthCallback(url, done) { + var query = parseURL(url, true).query; + debug('oauth callback response', query); + if (query.error) { + return done(new Error('GitHub auth failed: ' + JSON.stringify(query))); + } + exchangeCodeForToken(query.code, function(err, res) { + if (err) return done(err); + + getUser(res.access_token, function(err, data) { + if (err) return done(err); + + data.github_access_token = res.access_token; + done(null, data); + }); + }); +} + +module.exports = function(done) { + if (!config.get('github:enabled')) { + return process.nextTick(function() { + done(new Error('GitHub not enabled in config')); + }); + } + var url = format('https://github.com/login/oauth/authorize?client_id=%s&scope=%s', + config.get('github:client_id'), config.get('github:scope')); + debug('redirecting to', url); + + var _window = createWindow({ + url: url, + centered: true, + 'always-on-top': true, + 'use-content-size': true, + 'node-integration': false // Important or form inputs won't work. + }); + debug('starting github oauth web flow...'); + + var pending = true; + var onClose = function() { + if (!pending) return; + var err = new Error('GitHub oAuth flow cancelled'); + err.cancelled = true; + done(err); + }; + // Handle the user starting the github oauth flow but at + // any time they could chose to close the window and cancel. + _window.on('close', onClose); + + _window.webContents.on('did-get-redirect-request', function(event, fromURL, toURL) { + debug('github redirected authWindow %s -> %s', event, fromURL, toURL); + oauthCallback(toURL, function(err, res) { + pending = false; + _window.close(); + _window = null; + done(err, res); + }); + }); +}; diff --git a/src/electron/index.js b/src/electron/index.js index 586c4fbda28..c76eba0ad9a 100644 --- a/src/electron/index.js +++ b/src/electron/index.js @@ -1,29 +1,78 @@ +require('./newrelic'); + if (process.env.NODE_ENV === 'development') { require('./livereload'); } var app = require('app'); -var debug = require('debug')('scout-electron'); -var mongo = require('./mongo'); +var BrowserWindow = require('browser-window'); +var Menu = require('menu'); +var ipc = require('ipc'); +var config = require('./config'); +var windows = require('./window-manager'); +var githubOauthFlow = require('./github-oauth-flow'); +var setup = require('./setup'); +var debug = require('debug')('scout:electron'); app.on('window-all-closed', function() { debug('All windows closed. Quitting app.'); app.quit(); }); -mongo.start(function() { - debug('mongo started!'); -}); +app.on('open-setup-dialog', windows.openSetupDialog); +app.on('open-connect-dialog', windows.openConnectDialog); -app.on('before-quit', function() { - mongo.stop(function() { - debug('mongo stopped'); +app.on('ready', function() { + ipc.on('devtools-inspect-element', function(event, opts) { + debug('devtools-inspect-element', opts); + var sender = BrowserWindow.fromWebContents(event.sender); + if (!sender.isDevToolsOpened()) { + debug('opening devtools'); + sender.openDevTools(); + } + sender.inspectElement(opts.x, opts.y); + }); + + ipc.on('show-context-menu', function(event, opts) { + debug('show-context-menu', opts); + var sender = BrowserWindow.fromWebContents(event.sender); + var template = opts.template.map(function(item) { + debug('creating click handler for ipc', item.command, item.opts); + item.click = function() { + var msg = { + command: item.command, + opts: item.opts + }; + debug('telling browser window to run command', msg); + sender.send('run-command', msg); + }; + return item; + }); + debug('building and popping up', template); + Menu.buildFromTemplate(template).popup(sender); + }); + ipc.on('open-setup-dialog', app.emit.bind(app, 'open-setup-dialog')); + ipc.on('open-connect-dialog', app.emit.bind(app, 'open-connect-dialog')); + ipc.on('open-github-oauth-flow', function(event) { + var sender = BrowserWindow.fromWebContents(event.sender); + githubOauthFlow(function(err, user) { + debug('Got github oauth response', err, user); + if (err) { + sender.send('github-oauth-flow-error', err); + } else { + sender.send('github-oauth-flow-complete', user); + } + }); }); -}); -module.exports = { - autoupdater: require('./auto-updater'), - crashreporter: require('./crash-reporter'), - windows: require('./window-manager'), - menu: require('./menu') -}; + ipc.on('mark-setup-complete', function() { + setup.markComplete(function() { + debug('setup marked complete'); + }); + }); + if (config.isFeatureEnabled('setup')) { + setup(); + } else { + windows.openConnectDialog(); + } +}); diff --git a/src/electron/inject-app-bindings.js b/src/electron/inject-app-bindings.js new file mode 100644 index 00000000000..d905d33350b --- /dev/null +++ b/src/electron/inject-app-bindings.js @@ -0,0 +1,9 @@ +// Make electron ipc available via `app`. +window.app.use(function(app) { + app.ipc = require('ipc'); + app.trigger('change:ipc'); + + app.ipc.on('run-command', function(data) { + app.ipc.send(data.command, data.opts); + }); +}); diff --git a/src/electron/livereload.js b/src/electron/livereload.js index f4f2c6bb39e..835d41e234c 100644 --- a/src/electron/livereload.js +++ b/src/electron/livereload.js @@ -2,6 +2,7 @@ var path = require('path'); var watch = require('watch'); var tinyLR = require('tiny-lr'); var debounce = require('lodash').debounce; +var _ = require('lodash'); var debug = require('debug')('scout:electron:livereload'); var NODE_MODULES_REGEX = /node_modules/; @@ -21,7 +22,11 @@ livereload.listen(opts.port, opts.host); debug('livereload server started on %s:%d', opts.host, opts.port); var onFileschanged = debounce(function(files) { - debug('File change detected! Sending reload message', Object.keys(files)); + // On startup, `files` is an `fs.Stat` of the directory + // being watched and should not send a reload message. + if (_.isPlainObject(files)) return; + + debug('File change detected! Sending reload message', files); livereload.changed({ body: { files: files diff --git a/src/electron/newrelic.js b/src/electron/newrelic.js new file mode 100644 index 00000000000..f9bcdbfc946 --- /dev/null +++ b/src/electron/newrelic.js @@ -0,0 +1,30 @@ +/** + * Sets environment variables to configure the newrelic agent + * and require the newrelic module to start the agent. + */ +var _ = require('lodash'); +var config = require('./config'); +var debug = require('debug')('scout:electron:newrelic'); + +var ENV = { + NEW_RELIC_ENABLED: config.get('newrelic:enabled'), + NEW_RELIC_APP_NAME: config.get('newrelic:app_name'), + NEW_RELIC_LICENSE_KEY: config.get('newrelic:license_key'), + NEW_RELIC_USE_SSL: config.get('newrelic:use_ssl'), + NEW_RELIC_LOG_ENABLED: config.get('newrelic:log_enabled'), + NEW_RELIC_LOG_LEVEL: config.get('newrelic:log_level'), + NEW_RELIC_LOG: config.get('newrelic:log_filepath'), + NEW_RELIC_SLOW_SQL_ENABLED: false, + NEW_RELIC_UTILIZATION_DETECT_AWS: false, + NEW_RELIC_UTILIZATION_DETECT_DOCKER: false, + NEW_RELIC_NO_CONFIG_FILE: true +}; + +_.assign(process.env, ENV); + +if (config.get('newrelic:enabled')) { + debug('newrelic enabled! view log file at `%s`', config.get('newrelic:log_filepath')); + require('newrelic'); +} else { + debug('newrelic not enabled'); +} diff --git a/src/electron/setup.js b/src/electron/setup.js new file mode 100644 index 00000000000..d8371525ed4 --- /dev/null +++ b/src/electron/setup.js @@ -0,0 +1,39 @@ +var fs = require('fs'); +var app = require('app'); +var config = require('./config'); +var debug = require('debug')('scout:electron:setup'); + +/*eslint no-console:0*/ +module.exports = function showSetupOrStart() { + fs.exists(config.get('setup:file'), function(exists) { + if (!exists) { + debug('no setup-completed.json yet'); + return app.emit('open-setup-dialog'); + } + fs.readFile(config.get('setup:file'), function(err, buf) { + if (err) return console.log(err); + + var d; + try { + d = JSON.parse(buf); + } catch (e) { + return console.log(err); + } + debug('user completed setup version %s at %s', d.version, d.completed_at); + if (d.version !== config.get('setup:version')) { + debug('new setup version available so showing setup again.'); + return app.emit('open-setup-dialog'); + } + // @todo (imlucas): Restore windows instead of bringing up connect all the time... + app.emit('open-connect-dialog'); + }); + }); +}; + +module.exports.markComplete = function(done) { + var data = { + version: config.get('setup:version'), + completed_at: new Date() + }; + fs.writeFile(config.get('setup:file'), JSON.stringify(data), done); +}; diff --git a/src/electron/window-manager.js b/src/electron/window-manager.js index 157520ca230..d74081e3537 100644 --- a/src/electron/window-manager.js +++ b/src/electron/window-manager.js @@ -1,5 +1,4 @@ var _ = require('lodash'); - var BrowserWindow = require('browser-window'); var app = require('app'); var debug = require('debug')('scout-electron:window-manager'); @@ -7,41 +6,36 @@ var attachMenu = require('./menu'); var path = require('path'); var RESOURCES = path.resolve(__dirname, '../../'); -var DEFAULT_URL = 'file://' + path.join(RESOURCES, 'index.html#connect'); +var CONNECT_URL = 'file://' + path.join(RESOURCES, 'index.html#connect'); +var SETUP_URL = 'file://' + path.join(RESOURCES, 'index.html#setup'); -var DEFAULT_WIDTH = 1024; -var DEFAULT_HEIGHT = 700; +var DEFAULT_WIDTH = 1280; +var DEFAULT_HEIGHT = 800; -var DEFAULT_HEIGHT_DIALOG; +var DEFAULT_WIDTH_DIALOG = 640; +var DEFAULT_HEIGHT_DIALOG = 480; -if (process.platform === 'win32') { - DEFAULT_HEIGHT_DIALOG = 460; -} else if (process.platform === 'linux') { - DEFAULT_HEIGHT_DIALOG = 430; -} else { - DEFAULT_HEIGHT_DIALOG = 400; -} -var DEFAULT_WIDTH_DIALOG = 600; +var ICON = path.join(__dirname, '..', '..', 'images', 'mongodb-leaf.png'); var connectWindow; -var windowsOpenCount = 0; +var setupWindow; module.exports.create = function(opts) { opts = _.defaults(opts || {}, { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, - url: DEFAULT_URL + url: CONNECT_URL, + icon: ICON, + centered: true }); - debug('creating new window'); - var _window = new BrowserWindow({ - width: opts.width, - height: opts.height, - 'web-preferences': { - 'subpixel-font-scaling': true, - 'direct-write': true - } + opts['web-preferences'] = _.defaults(opts['web-preferences'] || {}, { + 'subpixel-font-scaling': true, + 'direct-write': true }); + + debug('creating new window'); + var _window = new BrowserWindow(opts); attachMenu(_window); _window.loadUrl(opts.url); @@ -53,25 +47,28 @@ module.exports.create = function(opts) { }); }); - if (opts.url === DEFAULT_URL) { + if (opts.url === CONNECT_URL) { connectWindow = _window; connectWindow.on('closed', function() { debug('connect window closed.'); connectWindow = null; }); } - windowsOpenCount++; - _window.on('closed', function() { - windowsOpenCount--; - if (windowsOpenCount === 0) { - debug('all windows closed. quitting.'); - app.quit(); - } - }); + + if (opts.url === SETUP_URL) { + setupWindow = _window; + setupWindow.on('closed', function() { + debug('setup window closed.'); + setupWindow = null; + }); + } + + debug('emitting `window-opened`'); + app.emit('window-opened', _window); return _window; }; -app.on('show connect dialog', function(opts) { +module.exports.openConnectDialog = function(opts) { if (connectWindow) { connectWindow.focus(); return connectWindow; @@ -79,13 +76,26 @@ app.on('show connect dialog', function(opts) { opts = opts || {}; opts = _.extend(opts || {}, { + url: CONNECT_URL, height: DEFAULT_HEIGHT_DIALOG, width: DEFAULT_WIDTH_DIALOG, - url: DEFAULT_URL + resizable: false }); - module.exports.create(opts); -}); + return module.exports.create(opts); +}; + +module.exports.openSetupDialog = function(opts) { + if (setupWindow) { + setupWindow.focus(); + return setupWindow; + } -app.on('ready', function() { - app.emit('show connect dialog'); -}); + opts = opts || {}; + opts = _.extend(opts || {}, { + url: SETUP_URL, + height: 675, + width: 900, + resizable: false + }); + return module.exports.create(opts); +}; diff --git a/src/field-list/index.less b/src/field-list/index.less index 999caf4fe2b..e8c574aaf22 100644 --- a/src/field-list/index.less +++ b/src/field-list/index.less @@ -14,7 +14,9 @@ .schema-field-list { // second level - .schema-field-name, + .schema-field-name { + margin-left: 36px; + } .schema-field-type-list, hr.field-divider { margin-left: 48px; @@ -22,7 +24,9 @@ .schema-field-list { // third level - .schema-field-name, + .schema-field-name { + margin-left: 84px; + } .schema-field-type-list, hr.field-divider { margin-left: 96px; @@ -30,7 +34,9 @@ .schema-field-list { // fourth level - .schema-field-name, + .schema-field-name { + margin-left: 132px; + } .schema-field-type-list, hr.field-divider { margin-left: 144px; @@ -46,6 +52,8 @@ text-overflow: ellipsis; white-space: nowrap; display: inline-block; + padding-left: 12px; + margin-left: -12px; } &.schema-field-basic { @@ -61,7 +69,9 @@ &.expanded { .caret { .caret-down; - margin-right: 6px; + margin-right: 4px; + margin-left: -12px; + cursor: pointer; } > .schema-field-list { display: block; @@ -70,7 +80,9 @@ &.collapsed { .caret { .caret-right; - margin-right: 10px; + margin-right: 8px; + margin-left: -12px; + cursor: pointer; } > .schema-field-list { diff --git a/src/home/index.js b/src/home/index.js index fbcf275f6c9..f0a29b57886 100644 --- a/src/home/index.js +++ b/src/home/index.js @@ -3,6 +3,7 @@ var format = require('util').format; var SidebarView = require('../sidebar'); var debug = require('debug')('scout-ui:home'); var CollectionView = require('./collection'); +var intercom = require('../intercom'); var app = require('ampersand-app'); var HomeView = View.extend({ @@ -46,9 +47,12 @@ var HomeView = View.extend({ this.ns = model.getId(); this.updateTitle(model); + app.navigate(format('schema/%s', model.getId()), { silent: true }); + + intercom.track('View Schema'); }, template: require('./index.jade'), subviews: { diff --git a/src/home/index.less b/src/home/index.less index aa9e9f9c967..07a467293eb 100644 --- a/src/home/index.less +++ b/src/home/index.less @@ -1,3 +1,9 @@ +.intercom-messenger-active { + #application { + margin-right: 370px; + } +} + .page { max-height: 100%; min-height: 100%; @@ -54,7 +60,7 @@ .collection-view { header { - padding: 12px 20px; + padding: 12px 25px; position: relative; z-index: 1; } @@ -79,7 +85,7 @@ } .main { - padding: 0 0 0 20px; + padding: 0 0 0 25px; flex: 1; } @@ -118,9 +124,10 @@ .column-container.sidebar-open { .side { width: 33%; + right: 0; } .splitter { - right: -15px; + right: -2px; } .splitter-button-open { display: none; diff --git a/src/index.jade b/src/index.jade index c403c816e96..012e8398e3d 100644 --- a/src/index.jade +++ b/src/index.jade @@ -2,7 +2,7 @@ doctype html html(lang='en') head title MongoDB - meta(http-equiv="Content-Security-Policy", content="default-src *; script-src 'self' http://localhost:35729; style-src 'self' 'unsafe-inline';") + meta(http-equiv="Content-Security-Policy", content="default-src *; script-src 'unsafe-inline' 'self' http://localhost:35729 https://widget.intercom.io https://js.intercomcdn.com/; style-src 'self' 'unsafe-inline';") meta(name='viewport', content='initial-scale=1') link(rel='stylesheet', href='index.css', charset='UTF-8') @@ -14,7 +14,10 @@ html(lang='en') if NODE_ENV === 'development' //- Include the livereload client so pages reload automatically when changed. script(src='http://localhost:35729/livereload.js', charset='UTF-8') - script(src='src/electron/development-debug-header.js', charset='UTF-8') - + + script(type='text/javascript', charset='UTF-8'). + window.CONFIG = !{CONFIG} + script(src='index.js', charset='UTF-8') + script(src='src/electron/inject-app-bindings.js', charset='UTF-8') diff --git a/src/index.js b/src/index.js index 5f032383596..6aa94de360e 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,9 @@ * The main entrypoint for the application! * @see ./app.js */ +if (!process.env.NODE_ENV) { + process.env.NODE_ENV = 'development'; +} /** * @todo (imlucas): Can be removed? */ diff --git a/src/index.less b/src/index.less index d8580352043..a94d4436a1b 100644 --- a/src/index.less +++ b/src/index.less @@ -9,4 +9,5 @@ @import "./src/minicharts/index.less"; @import "./src/refine-view/index.less"; @import "./src/sampling-message/index.less"; +@import "./src/setup/index.less"; @import "./src/statusbar/index.less"; diff --git a/src/intercom.js b/src/intercom.js new file mode 100644 index 00000000000..7077b2da663 --- /dev/null +++ b/src/intercom.js @@ -0,0 +1,77 @@ +var _ = require('lodash'); +var app = require('ampersand-app'); +var debug = require('debug')('scout:intercom'); + +var i = function() { + i.c(arguments); +}; +i.q = []; +i.c = function(args) { + i.q.push(args); +}; +window.Intercom = i; + +/*eslint new-cap:0*/ +/** + * App state has been updated so notfiy intercom of it. + */ +module.exports.update = function() { + window.Intercom('update'); +}; + +// @todo (imlucas): Expose to main renderer via IPC so the server can track +// whatever events it needs to as well. +module.exports.track = function(eventName, data) { + if (!app.isFeatureEnabled('intercom')) return; + window.Intercom('trackEvent', eventName, data); +}; + +function boot() { + var config = _.extend(app.user.toJSON(), { + app_id: _.get(app.config, 'intercom.app_id') + }); + config.user_id = app.user.id; + debug('Syncing user info w/ intercom', config); + window.Intercom('boot', config); +} + +module.exports.open = function(opts) { + if (opts && opts.message) { + window.Intercom('showNewMessage', opts.message); + } else { + window.Intercom('show'); + } +}; + +module.exports.show = function() { + var el = document.querySelector('#intercom-container .intercom-launcher'); + if (el) { + el.classList.remove('hidden'); + } +}; + +module.exports.hide = function() { + var el = document.querySelector('#intercom-container .intercom-launcher'); + if (!el) { + return setTimeout(module.exports.hide, 100); + } + el.classList.add('hidden'); +}; + +/** + * Injects the intercom client script. + * @param {models.User} user - The current user. + */ +module.exports.inject = function(user) { + if (!app.isFeatureEnabled('intercom')) { + debug('intercom is not enabled'); + return; + } + + var head = document.getElementsByTagName('head')[0]; + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = 'https://widget.intercom.io/widget/p57suhg7'; + head.appendChild(script); + user.on('sync', boot); +}; diff --git a/src/livereload.js b/src/livereload.js new file mode 100644 index 00000000000..77513363c07 --- /dev/null +++ b/src/livereload.js @@ -0,0 +1,11 @@ +/** + * Inject the livereload client so windows are reloaded automatically + * when any client assets/code are changed. + */ +module.exports.inject = function() { + var head = document.getElementsByTagName('head')[0]; + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = 'http://localhost:35729/livereload.js'; + head.appendChild(script); +}; diff --git a/src/metrics.js b/src/metrics.js new file mode 100644 index 00000000000..2b1cec1c75d --- /dev/null +++ b/src/metrics.js @@ -0,0 +1,18 @@ +var intercom = require('./intercom'); +var bugsnag = require('./bugsnag'); +var pkg = require('../package.json'); +var _ = require('lodash'); +var debug = require('debug')('scout:metrics'); + +module.exports.track = function(eventName, data) { + data = _.extend(data || {}, { + 'App Version': pkg.version + }); + debug('tracking `%s`: %j', eventName, data); + intercom.track(eventName, data); +}; + +module.exports.trackError = function(err) { + debug('tracking error %j', err); + bugsnag.trackError(err); +}; diff --git a/src/minicharts/d3fns/date.js b/src/minicharts/d3fns/date.js index e8a7d0595ed..05fbf26b5b4 100644 --- a/src/minicharts/d3fns/date.js +++ b/src/minicharts/d3fns/date.js @@ -7,6 +7,7 @@ var many = require('./many'); var raf = require('raf'); // var debug = require('debug')('scout:minicharts:date'); + require('../d3-tip')(d3); function generateDefaults(n) { diff --git a/src/minicharts/index.js b/src/minicharts/index.js index 55cf63cda2d..52c04773612 100644 --- a/src/minicharts/index.js +++ b/src/minicharts/index.js @@ -60,7 +60,7 @@ module.exports = AmpersandView.extend(QueryBuilderMixin, { // otherwise, create a svg-based VizView for d3 this.subview = new VizView(this.viewOptions); } - if (app.features.querybuilder) { + if (app.isFeatureEnabled('querybuilder')) { this.listenTo(this.subview, 'querybuilder', this.handleQueryBuilderEvent); } raf(function() { diff --git a/src/models/user.js b/src/models/user.js new file mode 100644 index 00000000000..4d966351ded --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,52 @@ +var Model = require('ampersand-model'); +var localforage = require('ampersand-sync-localforage'); +var uuid = require('uuid'); + +var User = Model.extend({ + modelType: 'User', + props: { + id: 'string', + name: 'string', + email: 'string', + created_at: 'date', + avatar_url: 'string', + company_name: 'string', + github_username: 'string', + /** + * `public_repos + public_gists + followers + following` + */ + github_score: 'number', + github_last_activity_at: 'date' + }, + sync: localforage('User') +}); + +User.getOrCreate = function(done) { + var id = localStorage.getItem('user_id'); + var user; + + if (!id) { + id = uuid.v4(); + localStorage.setItem('user_id', id); + user = new User({ + id: id, + created_at: new Date() + }); + user.save(); + done(null, user); + } else { + user = new User({ + id: id + }); + user.fetch({ + success: function() { + done(null, user); + }, + error: function(model, err) { + done(err); + } + }); + } +}; + +module.exports = User; diff --git a/src/refine-view/index.less b/src/refine-view/index.less index d9ac3e03b6a..4df03906e44 100644 --- a/src/refine-view/index.less +++ b/src/refine-view/index.less @@ -1,6 +1,6 @@ .refine-view-container { background: @gray8; - padding: 12px 20px; + padding: 12px 25px; position: relative; z-index: 1; diff --git a/src/router.js b/src/router.js index 03555658931..f02fc3e3221 100644 --- a/src/router.js +++ b/src/router.js @@ -1,20 +1,26 @@ var AmpersandRouter = require('ampersand-router'); - +var intercom = require('./intercom'); var HomePage = require('./home'); var Connect = require('./connect'); +var Setup = require('./setup'); +var app = require('ampersand-app'); module.exports = AmpersandRouter.extend({ routes: { '': 'index', schema: 'index', connect: 'connect', + setup: 'setup', + 'setup/:step': 'setup', 'schema/:ns': 'schema', '(*path)': 'catchAll' }, index: function() { + intercom.track('Connected to MongoDB'); this.trigger('page', new HomePage({})); }, schema: function(ns) { + app.intercom.show(); this.trigger('page', new HomePage({ ns: ns })); @@ -23,6 +29,15 @@ module.exports = AmpersandRouter.extend({ this.redirectTo(''); }, connect: function() { + app.intercom.hide(); + intercom.track('Open Connect Dialog'); this.trigger('page', new Connect({})); + }, + setup: function(step) { + app.intercom.hide(); + intercom.track('Open Setup'); + this.trigger('page', new Setup({ + step: parseInt(step || 1, 10) + })); } }); diff --git a/src/setup/connect-mongodb.jade b/src/setup/connect-mongodb.jade new file mode 100644 index 00000000000..d775d89e082 --- /dev/null +++ b/src/setup/connect-mongodb.jade @@ -0,0 +1,21 @@ +div.animated.wizard-content + span.scout-logo + h1 Connect to MongoDB + p.lead Scout allows you to connect to multiple MongoDB deployments. Add your first connection to get started. + + .wizard-form-content: .row: .col-sm-8.col-sm-offset-2 + form.form-horizontal + .form-group + label.col-sm-3.control-label(for='hostname') Hostname + .col-sm-8: input.form-control(type='text', name='hostname', placeholder='localhost') + span.form-control-feedback + //- i.fa.fa-fw.fa-check + .form-group + label.col-sm-3.control-label(for='port') Port + .col-sm-8: input.form-control(type='number', name='port', placeholder='27017') + .form-group + label.col-sm-3.control-label(for='name') Name (optional) + .col-sm-8: input.form-control(type='text', name='name', placeholder='e.g. "Dev Database"') + + .wizard-stepper + button.btn.btn-primary(data-hook='continue') Continue diff --git a/src/setup/connect-mongodb.js b/src/setup/connect-mongodb.js new file mode 100644 index 00000000000..e723d7a4bae --- /dev/null +++ b/src/setup/connect-mongodb.js @@ -0,0 +1,19 @@ +var View = require('ampersand-view'); +// var debug = require('debug')('scout:setup:connect-mongodb'); + +module.exports = View.extend({ + events: { + 'click [data-hook=continue]': 'onSubmit' + }, + template: require('./connect-mongodb.jade'), + onSubmit: function(evt) { + evt.preventDefault(); + this.parent.set({ + hostname: this.query('input[name=hostname]').value || 'localhost', + port: parseInt(this.query('input[name=port]').value || 27017, 10), + connection_name: this.query('input[name=name]').value || 'Local' + }); + + this.parent.step++; + } +}); diff --git a/src/setup/index.jade b/src/setup/index.jade new file mode 100644 index 00000000000..97a5fb3f4da --- /dev/null +++ b/src/setup/index.jade @@ -0,0 +1,2 @@ +.page: .content.container-fluid + div(data-hook='step-container') diff --git a/src/setup/index.js b/src/setup/index.js new file mode 100644 index 00000000000..668ca7e139a --- /dev/null +++ b/src/setup/index.js @@ -0,0 +1,97 @@ +var View = require('ampersand-view'); +var ViewSwitcher = require('ampersand-view-switcher'); +var onAnimationEnd = require('animationend'); +var app = require('ampersand-app'); +var debug = require('debug')('scout:setup'); +var format = require('util').format; + +var Connection = require('../models/connection'); + +var stepKlasses = [ + require('./welcome'), + require('./user-info'), + require('./connect-mongodb') +]; + +var FirstRunView = View.extend({ + props: { + step: { + type: 'number', + default: 1 + }, + name: { + type: 'string' + }, + email: { + type: 'string' + }, + hostname: { + type: 'string', + default: 'localhost' + }, + port: { + type: 'number', + default: 27017 + }, + connection_name: { + type: 'string', + default: 'Dev Database' + } + + }, + goToStep: function(n) { + debug('going to step %d', n); + var isLast = n === this.steps.length - 1; + if (isLast) { + return this.complete(); + } + this.switcher.set(this.steps[n - 1]); + app.navigate('setup/' + n, { + silent: true + }); + }, + initialize: function() { + this.listenTo(this, 'change:step', function(view, newVal) { + this.goToStep(newVal); + }); + + this.steps = stepKlasses.map(function(Klass) { + return new Klass({ + parent: this + }); + }.bind(this)); + }, + template: require('./index.jade'), + render: function() { + this.renderWithTemplate(); + this.stepContainer = this.queryByHook('step-container'); + this.switcher = new ViewSwitcher(this.stepContainer, { + waitForRemove: true, + hide: function(oldView, cb) { + onAnimationEnd(oldView.el, function() { + setTimeout(cb, 200); + }); + oldView.el.classList.add('fadeOut'); + }, + show: function(newView) { + newView.el.classList.add('fadeIn'); + } + }); + this.goToStep(this.step); + document.title = 'Welcome to MongoDB Scout'; + }, + complete: function() { + debug('Setup complete!'); + var model = new Connection({ + name: this.connection_name, + hostname: this.hostname, + port: this.port + }); + model.save(); + window.open(format('%s?uri=%s#schema', window.location.origin, model.uri)); + + app.ipc.send('mark-setup-complete'); + setTimeout(window.close, 500); + } +}); +module.exports = FirstRunView; diff --git a/src/setup/index.less b/src/setup/index.less new file mode 100644 index 00000000000..c926aa772ca --- /dev/null +++ b/src/setup/index.less @@ -0,0 +1,70 @@ +@import "animate.css"; + +.setup-user-info { + h1 { + margin-bottom: 20px; + } + form { + .form-group { + .help-block { + .lead; + padding-top: 10px; + padding-left: 16px; + display: block; + } + .on-error, .on-success { + display: none; + } + + .on-start { + display: block; + } + + &.has-error { + .on-error { + display: block; + } + .on-start, .on-success { + display: none; + } + } + &.has-success { + .on-success { + display: block; + } + .on-start, .on-error { + display: none; + } + } + } + } +} + +.wizard-content { + padding: 50px; + + .scout-logo { + display: block; + margin: -30px 0 60px -40px; + width: 142px; + height: 28px; + background: url('images/logo-scout.png') 0 0 no-repeat; + background-size: 142px 28px; + } + p.lead { + font-size: 16px; + line-height: 28px; + } +} +.btn-lg.btn-info.btn-connect-github { + padding: 15px 30px; +} +.wizard-form-content { + margin: 50px 0; +} +.wizard-stepper { + text-align: right; + border-top: 1px solid @gray7; + padding-top: 20px; + margin-top: 50px; +} \ No newline at end of file diff --git a/src/setup/user-info.jade b/src/setup/user-info.jade new file mode 100644 index 00000000000..ebeb95596fe --- /dev/null +++ b/src/setup/user-info.jade @@ -0,0 +1,45 @@ +.setup-user-info.animated.wizard-content + span.scout-logo + h1 Tell us about yourself + p.lead Just the basics so we can check in and see how you're using Scout and how Scout can improve for you. + .wizard-form-content: .row: .col-sm-8.col-sm-offset-2 + form.form-horizontal + .form-group.has-feedback + label.col-sm-2.control-label(for='name') Name + .col-sm-8: input.form-control( + type='text', + required='required', + name='name', + minlength=2, + maxlength=128, + placeholder='What is your name?') + span.form-control-feedback.on-error: .fa.fa-fw.fa-times + p.help-block.on-error Let's not be strangers… + + span.form-control-feedback.on-success: .fa.fa-fw.fa-check + p.help-block.on-success Nice to meet you! + + p.help-block.on-start + + .form-group.has-feedback + label.col-sm-2.control-label(for='email') Email + .col-sm-8: input.form-control( + type='email', + required='required', + name='email', + minlength=2, + maxlength=128, + placeholder='And your email address?') + span.form-control-feedback.on-error: .fa.fa-fw.fa-times + p.help-block.on-error How will we contact you if we have questions? + + span.form-control-feedback.on-success: .fa.fa-fw.fa-check + p.help-block.on-success Thanks! + + p.help-block.on-start + + .wizard-stepper + button.btn.btn-primary( + type='submit', + data-hook='continue' + ) Continue diff --git a/src/setup/user-info.js b/src/setup/user-info.js new file mode 100644 index 00000000000..3c0b7f1b018 --- /dev/null +++ b/src/setup/user-info.js @@ -0,0 +1,101 @@ +var View = require('ampersand-view'); +var app = require('ampersand-app'); +var debug = require('debug')('scout:first-run:user-info'); + +module.exports = View.extend({ + props: { + active_validation: { + type: 'boolean', + default: false + }, + is_valid: { + type: 'boolean', + default: true + } + }, + bindings: { + is_valid: { + hook: 'continue', + type: 'booleanClass', + no: 'disabled' + } + }, + events: { + 'click [data-hook=continue]': 'onSubmit', + 'change input': 'onInputChanged', + 'blur input': 'onInputBlur' + }, + template: require('./user-info.jade'), + onSubmit: function(evt) { + debug('submitted'); + evt.preventDefault(); + this.active_validation = true; + + var emailValid = this.validateInput(this.emailInput); + debug('email valid?', emailValid); + + var nameValid = this.validateInput(this.nameInput); + debug('name valid?', nameValid); + + if (this.is_valid) { + app.user.save({ + name: this.nameInput.value, + email: this.emailInput.value + }); + this.parent.step++; + } + }, + onInputBlur: function(evt) { + this.validateInput(evt.delegateTarget); + }, + onInputChanged: function(evt) { + if (!this.active_validation) return; + this.validateInput(evt.delegateTarget); + }, + validateInput: function(input) { + debug('validating %s', input.name); + var isValid = input.validity.valid; + var toAdd; + var toRemove; + + if (!isValid) { + toAdd = 'has-error'; + toRemove = 'has-success'; + } else { + toAdd = 'has-success'; + toRemove = 'has-error'; + } + input.parentNode.classList.add(toAdd); + input.parentNode.classList.remove(toRemove); + this.is_valid = this.form.checkValidity(); + + return isValid; + }, + render: function() { + debug('rendering'); + this.renderWithTemplate(); + this.form = this.query('form'); + this.emailInput = this.query('input[name=email]'); + this.nameInput = this.query('input[name=name]'); + this.listenTo(app.user, 'change:name', function() { + this.nameInput.value = app.user.name; + }.bind(this)); + + if (app.user.name) { + this.nameInput.value = app.user.name; + } + + this.listenTo(app.user, 'change:email', function() { + this.emailInput.value = app.user.email; + }.bind(this)); + + if (app.user.email) { + this.emailInput.value = app.user.email; + } + + setTimeout(function() { + debug('Focusing on name input'); + this.nameInput.focus(); + }.bind(this), 400); + } +}); diff --git a/src/setup/welcome.jade b/src/setup/welcome.jade new file mode 100644 index 00000000000..151a358b6ea --- /dev/null +++ b/src/setup/welcome.jade @@ -0,0 +1,10 @@ +div.animated.wizard-content + span.scout-logo + h1 Welcome to MongoDB Scout + p.lead MongoDB Scout provides users with a graphical view of their MongoDB schema by randomly sampling a subset of documents from the entire collection. By sampling a subset of documents, MongoDB Scout has minimal impact on the database and can produce results to the user almost instantly. + .wizard-form-content.text-center + button.btn.btn-info.btn-lg.btn-connect-github(data-hook='connect-github') + i.fa.fa-fw.fa-github + | Sign In With GitHub + .wizard-stepper + button.btn.btn-default(data-hook='skip') Skip diff --git a/src/setup/welcome.js b/src/setup/welcome.js new file mode 100644 index 00000000000..d99d038b5b3 --- /dev/null +++ b/src/setup/welcome.js @@ -0,0 +1,51 @@ +var View = require('ampersand-view'); +var app = require('ampersand-app'); +var metrics = require('../metrics'); + +var debug = require('debug')('scout:setup:connect-github'); + +module.exports = View.extend({ + events: { + 'click [data-hook=connect-github]': 'onGithubClicked', + 'click [data-hook=skip]': 'onSkipClicked' + }, + + template: require('./welcome.jade'), + + onGithubClicked: function(evt) { + evt.preventDefault(); + evt.stopPropagation(); + metrics.track('Connect with GitHub started'); + app.ipc.once('github-oauth-flow-error', function(err) { + if (err.cancelled) { + metrics.track('Connect with GitHub cancelled'); + } else { + metrics.trackError(err); + } + this.parent.step++; + }.bind(this)); + + app.ipc.once('github-oauth-flow-complete', function(data) { + metrics.track('Connect with GitHub complete'); + + app.user.set(data); + app.user.save(); + + this.parent.name = data.name; + this.parent.email = data.email; + this.parent.step++; + }.bind(this)); + + app.ipc.send('open-github-oauth-flow'); + }, + + skip: function() { + debug('skipping'); + this.parent.step++; + }, + + onSkipClicked: function(evt) { + evt.preventDefault(); + this.skip(); + } +});