diff --git a/package.json b/package.json index 4d65efc54ad..40bee772cb5 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "debug": "mongodb-js/debug#v2.2.3", "debug-menu": "^0.3.0", "electron-squirrel-startup": "^1.0.0", + "detect-coordinates": "^0.1.0", "font-awesome": "https://github.com/FortAwesome/Font-Awesome/archive/v4.4.0.tar.gz", "get-object-path": "azer/get-object-path#74eb42de0cfd02c14ffdd18552f295aba723d394", "hadron-action": "^0.1.0", @@ -124,7 +125,7 @@ "mongodb-data-service": "^1.1.1", "mongodb-database-model": "^0.1.2", "mongodb-explain-plan-model": "^0.2.0", - "mongodb-extended-json": "^1.6.0", + "mongodb-extended-json": "^1.7.0", "mongodb-js-metrics": "^1.2.0", "mongodb-language-model": "^0.3.3", "mongodb-ns": "^1.0.3", @@ -136,8 +137,12 @@ "qs": "^5.2.0", "raf": "^3.1.0", "react": "^15.2.1", - "react-bootstrap": "0.29.5", + "react-bootstrap": "0.30.2", "react-dom": "^15.2.1", + "react-native-listener": "^1.0.1", + "react-tooltip": "^2.0.3", + "reflux": "0.4.1", + "reflux-state-mixin": "^0.7.0", "semver": "^5.1.0", "storage-mixin": "^0.8.0", "tunnel-ssh": "^3.2.1-beta", @@ -147,10 +152,11 @@ "uuid": "^2.0.1" }, "devDependencies": { + "babel-eslint": "^6.0.4", "chai": "^3.4.1", "chai-as-promised": "^5.1.0", "electron-prebuilt": "1.2.8", - "eslint-config-mongodb-js": "^1.0.6", + "eslint-config-mongodb-js": "^2.0.1", "eslint-plugin-react": "^4.1.0", "hadron-build": "^0.7.2", "mocha": "^2.3.4", diff --git a/src/app/connect/index.js b/src/app/connect/index.js index 9ac5cb17568..c4c06fb5d18 100644 --- a/src/app/connect/index.js +++ b/src/app/connect/index.js @@ -3,7 +3,6 @@ var ConnectFormView = require('./connect-form-view'); var Connection = require('../models/connection'); var ConnectionCollection = require('../models/connection-collection'); var MongoDBConnection = require('mongodb-connection-model'); - var SidebarWrapperView = require('./sidebar'); var View = require('ampersand-view'); @@ -38,6 +37,9 @@ var sslMethods = require('./ssl'); */ var sshTunnelMethods = require('./ssh-tunnel'); + +var StatusAction = app.appRegistry.getAction('StatusAction'); + var ConnectView = View.extend({ template: indexTemplate, screenName: 'Connect', @@ -428,7 +430,7 @@ var ConnectView = View.extend({ this.dispatch('error received'); return; } - app.statusbar.show(); + StatusAction.showIndeterminateProgressBar(); var onSave = function() { this.connections.add(this.connection, { merge: true }); @@ -437,8 +439,8 @@ var ConnectView = View.extend({ }; connection.test(function(err) { - app.statusbar.hide(); if (!err) { + StatusAction.hide(); // now save connection this.connection = connection; this.connection.save({ last_used: new Date() }, { success: onSave.bind(this) }); @@ -459,7 +461,7 @@ var ConnectView = View.extend({ */ useConnection: function(connection) { connection = connection || this.connection; - app.statusbar.hide(); + StatusAction.hide(); metrics.track('Connection', 'used', { authentication: connection.authentication, ssl: connection.ssl, diff --git a/src/app/home/collection.js b/src/app/home/collection.js index 27a9ed458e2..6f672ed7599 100644 --- a/src/app/home/collection.js +++ b/src/app/home/collection.js @@ -1,11 +1,13 @@ var View = require('ampersand-view'); +var Action = require('hadron-action'); var CollectionStatsView = require('../collection-stats'); var DocumentView = require('../documents'); var SchemaView = require('../schema'); var IndexView = require('../indexes'); -var RefineBarView = require('../refine-view'); var ExplainView = require('../explain-plan'); var MongoDBCollection = require('../models/mongodb-collection'); +var React = require('react'); +var ReactDOM = require('react-dom'); var NamespaceStore = require('hadron-reflux-store').NamespaceStore; var _ = require('lodash'); @@ -15,6 +17,14 @@ var debug = require('debug')('mongodb-compass:home:collection'); var collectionTemplate = require('./collection.jade'); +// map tab label to correct view and switch views +var tabToViewMap = { + 'DOCUMENTS': 'documentView', + 'SCHEMA': 'schemaView', + 'EXPLAIN PLAN': 'explainView', + 'INDEXES': 'indexView' +}; + var MongoDBCollectionView = View.extend({ // modelType: 'Collection', template: collectionTemplate, @@ -68,6 +78,7 @@ var MongoDBCollectionView = View.extend({ }, documentView: { hook: 'document-subview', + waitFor: 'ns', prepareView: function(el) { return new DocumentView({ el: el, @@ -78,6 +89,7 @@ var MongoDBCollectionView = View.extend({ }, schemaView: { hook: 'schema-subview', + waitFor: 'ns', prepareView: function(el) { return new SchemaView({ el: el, @@ -88,6 +100,7 @@ var MongoDBCollectionView = View.extend({ }, indexView: { hook: 'index-subview', + waitFor: 'ns', prepareView: function(el) { return new IndexView({ el: el, @@ -105,52 +118,59 @@ var MongoDBCollectionView = View.extend({ model: this.model }); } - }, - refineBarView: { - hook: 'refine-bar-subview', - prepareView: function(el) { - var view = new RefineBarView({ - el: el, - parent: this, - queryOptions: app.queryOptions, - volatileQueryOptions: app.volatileQueryOptions - }); - view.on('submit', function() { - this.trigger('submit:query'); - }.bind(this)); - return view; - } } + // refineBarView: { + // hook: 'refine-bar-subview', + // prepareView: function(el) { + // var view = new RefineBarView({ + // el: el, + // parent: this, + // queryOptions: app.queryOptions, + // volatileQueryOptions: app.volatileQueryOptions + // }); + // view.on('submit', function() { + // this.trigger('submit:query'); + // }.bind(this)); + // return view; + // } + // } }, initialize: function() { this.model = new MongoDBCollection(); - this.listenToAndRun(this.parent, 'change:ns', this.onCollectionChanged.bind(this)); + NamespaceStore.listen( this.onCollectionChanged.bind(this) ); + this.schemaActions = app.appRegistry.getAction('SchemaAction'); + // this.listenToAndRun(this.parent, 'change:ns', this.onCollectionChanged.bind(this)); + }, + render: function() { + this.renderWithTemplate(this); + // render query bar here for now + var queryBarComponent = app.appRegistry.getComponent('App:QueryBar'); + ReactDOM.render(React.createElement(queryBarComponent), this.queryByHook('refine-bar-subview')); }, onTabClicked: function(e) { e.preventDefault(); e.stopPropagation(); - - // map tab label to correct view and switch views - var tabToViewMap = { - 'DOCUMENTS': 'documentView', - 'SCHEMA': 'schemaView', - 'EXPLAIN PLAN': 'explainView', - 'INDEXES': 'indexView' - }; this.switchView(tabToViewMap[e.target.innerText]); }, switchView: function(viewStr) { + debug('switching to', viewStr); // disable all views but the active one - _.each(this._subviews, function(subview) { - subview.visible = false; - }); - if (this[viewStr]) { - this[viewStr].visible = true; - } this.activeView = viewStr; + _.each(_.values(tabToViewMap), function(subview) { + if (!this[subview]) return; + if (subview === viewStr) { + this[viewStr].el.classList.remove('hidden'); + } else { + this[subview].el.classList.add('hidden'); + } + }.bind(this)); + // Temporary hack to generate a resize when the schema is clicked. + if (viewStr === 'schemaView') { + this.schemaActions.resizeMiniCharts(); + } }, onCollectionChanged: function() { - this.ns = this.parent.ns; + this.ns = NamespaceStore.ns; if (!this.ns) { this.visible = false; debug('No active collection namespace so no schema has been requested yet.'); @@ -158,10 +178,9 @@ var MongoDBCollectionView = View.extend({ } this.visible = true; this.model._id = this.ns; - // Need to keep the global state in sync. - NamespaceStore.ns = this.ns; this.model.once('sync', this.onCollectionFetched.bind(this)); this.model.fetch(); + Action.filterChanged(app.queryOptions.query.serialize()); }, onCollectionFetched: function(model) { this.switchView(this.activeView); diff --git a/src/app/home/index.js b/src/app/home/index.js index 3f5cf908a92..983688d00e6 100644 --- a/src/app/home/index.js +++ b/src/app/home/index.js @@ -5,6 +5,7 @@ var IdentifyView = require('../identify'); var CollectionView = require('./collection'); var InstancePropertyView = require('./instance-properties'); var CollectionListItemView = require('./collection-list-item'); +var NamespaceStore = require('hadron-reflux-store').NamespaceStore; var TourView = require('../tour'); var NetworkOptInView = require('../network-optin'); var app = require('ampersand-app'); @@ -141,6 +142,7 @@ var HomeView = View.extend({ } this.ns = model.getId(); + NamespaceStore.ns = ns.ns; this.updateTitle(model); this.showNoCollectionsZeroState = false; this.showDefaultZeroState = false; diff --git a/src/app/index.js b/src/app/index.js index 5bce8a457f8..44e2e7e9bc9 100644 --- a/src/app/index.js +++ b/src/app/index.js @@ -50,11 +50,13 @@ var Preferences = require('./models/preferences'); var ApplicationStore = require('hadron-reflux-store').ApplicationStore; var User = require('./models/user'); var Router = require('./router'); -var Statusbar = require('./statusbar'); +// var Statusbar = require('./statusbar'); var migrateApp = require('./migrations'); var metricsSetup = require('./metrics'); var metrics = require('mongodb-js-metrics')(); +var React = require('react'); +var ReactDOM = require('react-dom'); var AutoUpdate = require('../auto-update'); var addInspectElementMenu = require('debug-menu').install; @@ -254,7 +256,8 @@ var Application = View.extend({ pushState: false, root: '/' }); - app.statusbar.hide(); + var StatusAction = app.appRegistry.getAction('StatusAction'); + StatusAction.hide(); }, onFatalError: function(id, err) { debug('clearing client stall timeout...'); @@ -262,7 +265,8 @@ var Application = View.extend({ console.error('Fatal Error!: ', id, err); metrics.error(err); - app.statusbar.fatal(err); + var StatusAction = app.appRegistry.getAction('StatusAction'); + StatusAction.setMessage(err); }, // ms we'll wait for a `mongodb-scope-client` instance // to become readable before giving up and showing @@ -317,10 +321,13 @@ var Application = View.extend({ }); debug('rendering statusbar...'); - this.statusbar = new Statusbar({ - el: this.queryByHook('statusbar') - }); - this.statusbar.render(); + // this.statusbar = new Statusbar({ + // el: this.queryByHook('statusbar') + // }); + // this.statusbar.render(); + + this.statusComponent = app.appRegistry.getComponent('App:Status'); + ReactDOM.render(React.createElement(this.statusComponent), this.queryByHook('statusbar')); this.autoUpdate = new AutoUpdate({ el: this.queryByHook('auto-update') @@ -376,10 +383,13 @@ app.extend({ state.startRouter(); return; } - - app.statusbar.show({ + var StatusAction = app.appRegistry.getAction('StatusAction'); + StatusAction.configure({ + visible: true, message: 'Retrieving connection details...', - staticSidebar: true + progressbar: true, + progress: 100, + sidebar: true }); state.connection = new Connection({ @@ -392,9 +402,7 @@ app.extend({ state.onFatalError('fetch connection', err); return; } - app.statusbar.show({ - message: 'Connecting to MongoDB...' - }); + StatusAction.setMessage('Connecting to MongoDB...'); var DataService = require('mongodb-data-service'); app.dataService = new DataService(state.connection) @@ -434,11 +442,11 @@ app.extend({ } }); -Object.defineProperty(app, 'statusbar', { - get: function() { - return state.statusbar; - } -}); +// Object.defineProperty(app, 'statusbar', { +// get: function() { +// return state.statusbar; +// } +// }); Object.defineProperty(app, 'autoUpdate', { get: function() { diff --git a/src/app/index.less b/src/app/index.less index 7329ff02206..0c85e28a73a 100644 --- a/src/app/index.less +++ b/src/app/index.less @@ -9,10 +9,7 @@ @import "documents/index.less"; @import "schema/index.less"; @import "home/index.less"; -@import "minicharts/index.less"; -@import "refine-view/index.less"; @import "sampling-message/index.less"; -@import "statusbar/index.less"; @import "tour/index.less"; @import "sidebar/index.less"; @import "network-optin/index.less"; @@ -25,4 +22,9 @@ @import "metrics/index.less"; @import "./styles/mapbox-gl.css"; +// Packages +// @todo don't hard-code these, style manager needs to handle package styles @import "../internal-packages/crud/styles/crud.less"; +@import "../internal-packages/status/styles/index.less"; +@import "../internal-packages/query/styles/index.less"; +@import "../internal-packages/schema/styles/index.less"; diff --git a/src/app/minicharts/d3fns/few.js b/src/app/minicharts/d3fns/few.js index c4d8efa6949..5fbe03b2869 100644 --- a/src/app/minicharts/d3fns/few.js +++ b/src/app/minicharts/d3fns/few.js @@ -145,7 +145,14 @@ var minicharts_d3fns_few = function() { function chart(selection) { selection.each(function(data) { - var values = _.pluck(data, 'count'); + var values = _.map(data, 'count'); + _.each(data, (d, i) => { + data[i].xpos = _.sum(_(data) + .slice(0, i) + .map('count') + .value() + ); + }); var sumValues = d3.sum(values); var maxValue = d3.max(values); var percentFormat = shared.friendlyPercentFormat(maxValue / sumValues * 100); diff --git a/src/app/minicharts/index.less b/src/app/minicharts/index.less index f424fc4a60c..9e201684a46 100644 --- a/src/app/minicharts/index.less +++ b/src/app/minicharts/index.less @@ -144,9 +144,11 @@ svg.minichart { &.selected { fill: @mc-fg-selected; - &.half { - mask: url(#mask-stripe); - } + } + + &.half-selected { + fill: @mc-fg-selected; + mask: url(#mask-stripe); } &.unselected { diff --git a/src/app/refine-view/index.js b/src/app/refine-view/index.js index af5c5f057db..e13b4924261 100644 --- a/src/app/refine-view/index.js +++ b/src/app/refine-view/index.js @@ -3,13 +3,14 @@ var _ = require('lodash'); var AmpersandView = require('ampersand-view'); var EditableQuery = require('../models/editable-query'); var EJSON = require('mongodb-extended-json'); +var QueryStore = require('../../internal-packages/schema/lib/store'); var Query = require('mongodb-language-model').Query; var QueryOptions = require('../models/query-options'); var app = require('ampersand-app'); var metrics = require('mongodb-js-metrics')(); // var metrics = require('mongodb-js-metrics')(); -// var debug = require('debug')('scout:refine-view:index'); +var debug = require('debug')('scout:refine-view:index'); var indexTemplate = require('./index.jade'); @@ -92,17 +93,20 @@ module.exports = AmpersandView.extend({ 'submit form': 'submit' }, initialize: function() { - this.volatileQuery = this.volatileQueryOptions.query; - this.listenToAndRun(this.volatileQueryOptions, 'change:query', this.updateQueryListener); + // this.volatileQuery = this.volatileQueryOptions.query; + // this.listenToAndRun(this.volatileQueryOptions, 'change:query', this.updateQueryListener); + QueryStore.listen(this.onQueryBufferChanged.bind(this)); }, updateQueryListener: function() { - this.stopListening(this.volatileQuery, 'change:buffer', this.onQueryBufferChanged); - this.volatileQuery = this.volatileQueryOptions.query; - this.listenTo(this.volatileQuery, 'change:buffer', this.onQueryBufferChanged); - this.editableQuery.rawString = this.volatileQueryOptions.queryString; + // this.stopListening(this.volatileQuery, 'change:buffer', this.onQueryBufferChanged); + // this.volatileQuery = this.volatileQueryOptions.query; + // this.listenTo(this.volatileQuery, 'change:buffer', this.onQueryBufferChanged); + // this.editableQuery.rawString = this.volatileQueryOptions.queryString; }, - onQueryBufferChanged: function() { - this.editableQuery.rawString = EJSON.stringify(this.volatileQuery.serialize()); + onQueryBufferChanged: function(store) { + debug('store', store); + this.editableQuery.rawString = EJSON.stringify(store.query); + // this.editableQuery.rawString = EJSON.stringify(this.volatileQuery.serialize()); }, /** * when user changes the text in the input field, copy the value into editableQuery. If the diff --git a/src/app/schema/field-list.jade b/src/app/schema/field-list.jade deleted file mode 100644 index fd44592d995..00000000000 --- a/src/app/schema/field-list.jade +++ /dev/null @@ -1 +0,0 @@ -.schema-field-list(data-hook='fields') diff --git a/src/app/schema/field-list.js b/src/app/schema/field-list.js deleted file mode 100644 index 7cd5ce67a73..00000000000 --- a/src/app/schema/field-list.js +++ /dev/null @@ -1,158 +0,0 @@ -var View = require('ampersand-view'); -var TypeListView = require('./type-list'); -var MinichartView = require('../minicharts'); -var ViewSwitcher = require('ampersand-view-switcher'); -var $ = require('jquery'); -var _ = require('lodash'); -var raf = require('raf'); -var SampledSchema = require('../models/sampled-schema'); - -var fieldTemplate = require('./field.jade'); -var fieldListTemplate = require('./field-list.jade'); -// var debug = require('debug')('mongodb-compass:schema:field-list'); - -function handleCaret(el) { - var $el = $(el); - // only apply to own caret, not children carets - if ($el.next().text() !== this.model.name) { - return; - } - if (this.model.fields || this.model.arrayFields) { - $el.addClass('caret'); - $el.next().css('cursor', 'pointer'); - } else { - $el.removeClass('caret'); - } -} - -var FieldListView; - -var FieldView = View.extend({ - modelType: 'FieldView', - session: { - expanded: { - type: 'boolean', - default: false - }, - type_model: 'state', - visible: { - type: 'boolean', - default: false - }, - minichartView: 'any' - }, - bindings: { - 'model.name': { - hook: 'name' - }, - 'model.fields': { - type: handleCaret, - hook: 'caret' - }, - 'model.arrayFields': { - type: handleCaret, - hook: 'caret' - }, - expanded: { - type: 'booleanClass', - yes: 'expanded', - no: 'collapsed' - }, - visible: { - type: 'booleanClass', - no: 'hidden' - } - }, - events: { - 'click .schema-field-name': 'click' - }, - template: fieldTemplate, - subviews: { - types: { - hook: 'types-subview', - waitFor: 'visible', - prepareView: function(el) { - return new TypeListView({ - el: el, - parent: this, - collection: this.model.types - }); - } - }, - fieldListView: { - hook: 'fields-subview', - waitFor: 'model.fields', - prepareView: function(el) { - return new FieldListView({ - el: el, - parent: this, - collection: this.model.fields - }); - } - }, - arrayFieldListView: { - hook: 'arrayfields-subview', - waitFor: 'model.arrayFields', - prepareView: function(el) { - return new FieldListView({ - el: el, - parent: this, - collection: this.model.arrayFields - }); - } - } - }, - initialize: function() { - this.listenTo(this, 'change:visible', this.renderMinicharts); - }, - render: function() { - this.renderWithTemplate(this); - this.viewSwitcher = new ViewSwitcher(this.queryByHook('minichart-container')); - }, - renderMinicharts: function() { - if (!this.type_model) { - this.type_model = this.model.types.at(0); - } - this.minichartView = new MinichartView({ - model: this.type_model, - parent: this - }); - this.viewSwitcher.set(this.minichartView); - }, - click: function(evt) { - this.toggle('expanded'); - evt.preventDefault(); - evt.stopPropagation(); - } -}); - -FieldListView = View.extend({ - modelType: 'FieldListView', - session: { - fieldCollectionView: 'object' - }, - template: fieldListTemplate, - initialize: function() { - if (this.collection.parent instanceof SampledSchema) { - this.listenTo(this.collection.parent, 'sync', this.makeFieldVisible); - } else { - // lazy rendering of nested subviews - this.listenTo(this.parent, 'change:expanded', this.makeFieldVisible); - } - }, - makeFieldVisible: function() { - var views = this.fieldCollectionView.views; - _.each(_.filter(views, 'visible', false), function(fieldView) { - raf(function() { - fieldView.visible = true; - }); - }); - }, - render: function() { - this.renderWithTemplate(); - this.fieldCollectionView = this.renderCollection(this.collection, - FieldView, this.queryByHook('fields')); - } -}); - -module.exports = FieldListView; diff --git a/src/app/schema/field.jade b/src/app/schema/field.jade deleted file mode 100644 index 7e2c9c95d7e..00000000000 --- a/src/app/schema/field.jade +++ /dev/null @@ -1,11 +0,0 @@ -.schema-field.schema-field-basic - .row - .col-sm-4 - .schema-field-name - span(data-hook='caret') - span(data-hook='name') - div(data-hook='types-subview') - .col-sm-7.col-sm-offset-1 - div(data-hook='minichart-container') - div(data-hook='fields-subview') - div(data-hook='arrayfields-subview') \ No newline at end of file diff --git a/src/app/schema/index.js b/src/app/schema/index.js index 1cfee526180..a29634ef59a 100644 --- a/src/app/schema/index.js +++ b/src/app/schema/index.js @@ -1,165 +1,30 @@ var View = require('ampersand-view'); -var FieldListView = require('./field-list.js'); -var SampledSchema = require('../models/sampled-schema'); -var SamplingMessageView = require('../sampling-message'); var app = require('ampersand-app'); -var electron = require('electron'); -var remote = electron.remote; -var dialog = remote.dialog; -var BrowserWindow = remote.BrowserWindow; -var clipboard = remote.clipboard; -var format = require('util').format; -var metrics = require('mongodb-js-metrics')(); -var ipc = require('hadron-ipc'); -var eJSON = require('mongodb-extended-json'); - -var debug = require('debug')('mongodb-compass:schema:index'); - -var indexTemplate = require('./index.jade'); +var React = require('react'); +var ReactDOM = require('react-dom'); var SchemaView = View.extend({ - // modelType: 'Collection', - template: indexTemplate, props: { - visible: { - type: 'boolean', - default: false - }, - sampling: { + loading: { type: 'boolean', default: false - }, - hasRefineBar: ['boolean', true, true] - }, - derived: { - is_empty: { - deps: ['schema.sample_size', 'schema.is_fetching'], - fn: function() { - return this.schema.sample_size === 0 && !this.schema.is_fetching; - } } }, - events: { - 'click .splitter': 'onSplitterClick' - }, bindings: { - visible: { - type: 'booleanClass', - no: 'hidden' - }, - is_empty: [ - { - hook: 'empty', - type: 'booleanClass', - no: 'hidden' - }, - { - hook: 'column-container', - type: 'booleanClass', - yes: 'hidden' - } - ] - }, - children: { - schema: SampledSchema + loading: { + type: 'toggle', + hook: 'loading' + } }, initialize: function() { - this.listenTo(this.schema, 'sync error', this.schemaIsSynced.bind(this)); - this.listenTo(this.schema, 'request', this.schemaIsRequested.bind(this)); - this.listenTo(this.model, 'sync', this.onCollectionFetched.bind(this)); - this.listenTo(this.parent, 'submit:query', this.onQueryChanged.bind(this)); - this.on('change:visible', this.onVisibleChanged.bind(this)); - - ipc.on('window:menu-share-schema-json', this.onShareSchema.bind(this)); + this.schemaView = app.appRegistry.getComponent('Collection:Schema'); }, render: function() { - this.renderWithTemplate(this); - }, - schemaIsSynced: function() { - // only listen to share menu events if we have a sync'ed schema - this.sampling = false; - ipc.call('window:show-share-submenu'); - }, - schemaIsRequested: function() { - ipc.call('window:hide-share-submenu'); - this.sampling = true; - }, - onVisibleChanged: function() { - if (this.visible) { - this.parent.refineBarView.visible = this.hasRefineBar; - } - if (this.visible && this.sampling) { - app.statusbar.visible = true; - } else { - app.statusbar.visible = false; - } + ReactDOM.render(React.createElement(this.schemaView), this.queryByHook('schema-subview')); + return this; }, - onShareSchema: function() { - clipboard.writeText(eJSON.stringify(this.schema.serialize(), null, ' ')); - - var detail = format('The schema definition of %s has been copied to your ' - + 'clipboard in JSON format.', this.model._id); - - dialog.showMessageBox(BrowserWindow.getFocusedWindow(), { - type: 'info', - message: 'Share Schema', - detail: detail, - buttons: ['OK'] - }); - - metrics.track('Share Schema', 'used'); - }, - onCollectionFetched: function() { - debug('collection fetched in schema'); - // track collection information - - // @todo thomasr fix this, maybe this.model._id ? - // if (!ns) { - // this.visible = false; - // debug('No active collection namespace so no schema has been requested yet.'); - // return; - // } - - // this.visible = true; - app.queryOptions.reset(); - app.volatileQueryOptions.reset(); - - this.schema.ns = this.model._id; - this.schema.reset(); - var options = app.volatileQueryOptions.serialize(); - if (this.visible) { - app.statusbar.visible = true; - } - this.schema.fetch(options); - }, - onQueryChanged: function() { - var options = app.queryOptions.serialize(); - if (this.visible) { - app.statusbar.visible = true; - } - this.schema.refine(options); - }, - subviews: { - sampling_message: { - hook: 'sampling-message-subview', - prepareView: function(el) { - return new SamplingMessageView({ - el: el, - parent: this, - model: this.schema - }); - } - }, - fields: { - hook: 'fields-subview', - prepareView: function(el) { - return new FieldListView({ - el: el, - parent: this, - collection: this.schema.fields - }); - } - } + remove: function() { + View.prototype.remove.call(this); } }); diff --git a/src/app/schema/index.less b/src/app/schema/index.less index f3453ccfc9d..b4f2d2466be 100644 --- a/src/app/schema/index.less +++ b/src/app/schema/index.less @@ -26,8 +26,6 @@ } } - - .schema-field { // second level border-bottom: 3px solid @gray8; diff --git a/src/app/schema/type-list-item.jade b/src/app/schema/type-list-item.jade deleted file mode 100644 index ab90325b556..00000000000 --- a/src/app/schema/type-list-item.jade +++ /dev/null @@ -1,9 +0,0 @@ -.schema-field-wrapper(data-hook='bar') - if isSubtype - .schema-field-type(data-toggle='tooltip') - span.schema-field-type-label - = model && model.getId() - if !isSubtype - .schema-field-type(data-toggle='tooltip') - .array-subtypes - div(data-hook='array-subtype-subview') diff --git a/src/app/schema/type-list.jade b/src/app/schema/type-list.jade deleted file mode 100644 index 0edc21a0f3d..00000000000 --- a/src/app/schema/type-list.jade +++ /dev/null @@ -1 +0,0 @@ -.schema-field-type-list(data-hook='types') diff --git a/src/app/schema/type-list.js b/src/app/schema/type-list.js deleted file mode 100644 index 290e00575c8..00000000000 --- a/src/app/schema/type-list.js +++ /dev/null @@ -1,180 +0,0 @@ -var View = require('ampersand-view'); -var format = require('util').format; -var numeral = require('numeral'); -var tooltipMixin = require('../tooltip-mixin'); -var _ = require('lodash'); - -var typeListTemplate = require('./type-list.jade'); -var typeListItemTemplate = require('./type-list-item.jade'); -// var debug = require('debug')('mongodb-compass:field-list:type-list'); - -var TypeListView; - -var TypeListItem = View.extend(tooltipMixin, { - template: typeListItemTemplate, - modelType: 'TypeListItem', - bindings: { - active: { - type: 'booleanClass', - name: 'active' - }, - selected: { - type: 'booleanClass', - name: 'selected' - }, - 'model.name': [ - { - hook: 'name' - }, - { - hook: 'bar', - type: function(el) { - el.classList.add('schema-field-type-' + this.model.getId().toLowerCase()); - } - } - ], - probability_percentage: { - hook: 'bar', - type: function(el) { - el.style.width = this.probability_percentage; - } - }, - tooltip_message: { - type: function() { - // need to set `title` and `data-original-title` due to bug in bootstrap's tooltip - // @see https://github.com/twbs/bootstrap/issues/14769 - this.tooltip({ - title: this.tooltip_message, - placement: this.isSubtype ? 'bottom' : 'top', - container: 'body' - }).attr('data-original-title', this.tooltip_message); - } - } - }, - session: { - parent: 'state', - active: { - type: 'boolean', - default: false - }, - selected: { - type: 'boolean', - default: false - } - }, - derived: { - probability_percentage: { - deps: ['model.probability'], - fn: function() { - // no rounding, use exact proportions for relative widths - return this.model.probability * 100 + '%'; - } - }, - tooltip_message: { - deps: ['model.probability'], - fn: function() { - return format('%s (%s)', this.model.getId(), numeral(this.model.probability).format('%')); - } - }, - isSubtype: { - deps: ['parent'], - fn: function() { - return this.parent.hasSubtypes; - } - } - }, - events: { - 'click .schema-field-type-document': 'documentTypeClicked', - 'click .schema-field-wrapper': 'typeClicked' - }, - subviews: { - subtypeView: { - hook: 'array-subtype-subview', - waitFor: 'model.types', - prepareView: function(el) { - return new TypeListView({ - el: el, - parent: this, - hasSubtypes: true, - collection: this.model.types - }); - } - } - }, - documentTypeClicked: function(evt) { - // expands the nested subdocument fields by triggering click in FieldView - var fieldView = this.parent.parent; - if (fieldView.modelType === 'FieldView') { - fieldView.click(evt); - } - }, - typeClicked: function(evt) { - evt.stopPropagation(); - - if (this.active) { - // @todo rueckstiess: already active, query building mode - // this.toggle('selected'); - } else { - // no clicks on Undefined allowed - if (this.model.getId() === 'Undefined') { - return; - } - - // find the field view, at most 2 levels up - var fieldView = this.parent.parent; - if (fieldView.getType() !== 'FieldView') { - fieldView = fieldView.parent.parent; - } - // if type model has changed, render its minichart - if (fieldView.type_model !== this.model) { - fieldView.types.deactivateAll(); - this.active = true; - this.selected = false; - fieldView.type_model = this.model; - fieldView.renderMinicharts(); - } - } - } -}); - - -TypeListView = module.exports = View.extend({ - modelType: 'TypeListView', - session: { - collectionView: 'object', - hasSubtypes: { - type: 'boolean', - default: false - }, - parent: 'state' - }, - template: typeListTemplate, - deactivateAll: function() { - if (!this.collectionView) { - return; - } - _.each(this.collectionView.views, function(typeView) { - typeView.active = false; - typeView.selected = false; - }); - // also deactivate the array subtypes - if (!_.get(this, 'parent.isSubtype')) { - var arrayView = _.find(this.collectionView.views, function(view) { - return view.model.name === 'Array'; - }); - if (arrayView) { - arrayView.subtypeView.deactivateAll(); - } - } - }, - render: function() { - if (!_.get(this, 'parent.isSubtype')) { - this.renderWithTemplate(this); - this.collectionView = this.renderCollection(this.collection, TypeListItem, - this.queryByHook('types')); - } - if (!this.hasSubtypes) { - this.collectionView.views[0].active = true; - } - } -}); diff --git a/src/help/index.js b/src/help/index.js index 0b83bbf3e86..c1f76b07635 100644 --- a/src/help/index.js +++ b/src/help/index.js @@ -16,6 +16,8 @@ var tagsTemplate = require('./tags.jade'); var entries = new HelpEntryCollection(); +var StatusAction = app.appRegistry.getAction('StatusAction'); + var HelpPage = View.extend({ template: indexTemplate, screenName: 'Help', @@ -128,11 +130,11 @@ var HelpPage = View.extend({ if (!entry) { debug('Unknown help entry', entryId); this.viewSwitcher.clear(); - app.statusbar.showMessage('Help entry not found.'); + StatusAction.setMessage('Help entry not found.'); return; } - app.statusbar.hide(); + StatusAction.hide(); if (!entries.select(entry)) { debug('already selected'); diff --git a/src/internal-packages/.eslintrc b/src/internal-packages/.eslintrc new file mode 100644 index 00000000000..4fb1f9df647 --- /dev/null +++ b/src/internal-packages/.eslintrc @@ -0,0 +1,15 @@ +{ + "env": { + "mocha": true, + "node": true, + }, + "rules": { + "camelcase": 1 + }, + "extends": [ + "mongodb-js/node", + "mongodb-js/browser", + "mongodb-js/react" + ], + "plugins": ["react"] +} diff --git a/src/internal-packages/crud/index.js b/src/internal-packages/crud/index.js index 6412a88b019..b6ae3a28a91 100644 --- a/src/internal-packages/crud/index.js +++ b/src/internal-packages/crud/index.js @@ -4,6 +4,8 @@ const app = require('ampersand-app'); const DocumentList = require('./lib/component/document-list'); const Actions = require('./lib/actions'); const InsertDocumentStore = require('./lib/store/insert-document-store'); +const ResetDocumentListStore = require('./lib/store/reset-document-list-store'); +const LoadMoreDocumentsStore = require('./lib/store/load-more-documents-store'); /** * Activate all the components in the CRUD package. @@ -12,6 +14,8 @@ function activate() { app.appRegistry.registerComponent('Component::CRUD::DocumentList', DocumentList); app.appRegistry.registerAction('Action::CRUD::DocumentRemoved', Actions.documentRemoved); app.appRegistry.registerStore('Store::CRUD::InsertDocumentStore', InsertDocumentStore); + app.appRegistry.registerStore('Store::CRUD::ResetDocumentListStore', ResetDocumentListStore); + app.appRegistry.registerStore('Store::CRUD::LoadMoreDocumentsStore', LoadMoreDocumentsStore); } /** @@ -21,6 +25,8 @@ function deactivate() { app.appRegistry.deregisterComponent('Component::CRUD::DocumentList'); app.appRegistry.deregisterAction('Action::CRUD::DocumentRemoved'); app.appRegistry.deregisterStore('Store::CRUD::InsertDocumentStore'); + app.appRegistry.deregisterStore('Store::CRUD::ResetDocumentListStore'); + app.appRegistry.deregisterStore('Store::CRUD::LoadMoreDocumentsStore'); } module.exports.activate = activate; diff --git a/src/internal-packages/crud/lib/component/document-actions.jsx b/src/internal-packages/crud/lib/component/document-actions.jsx index c2887717850..711e7a9089c 100644 --- a/src/internal-packages/crud/lib/component/document-actions.jsx +++ b/src/internal-packages/crud/lib/component/document-actions.jsx @@ -1,7 +1,7 @@ 'use strict'; const React = require('react'); -const IconButton = require('./icon-button'); +const IconButton = require('hadron-app-registry').IconButton; /** * Component for actions on the document. diff --git a/src/internal-packages/crud/lib/component/document-footer.jsx b/src/internal-packages/crud/lib/component/document-footer.jsx index 3f82eeb0efe..b63cf939d28 100644 --- a/src/internal-packages/crud/lib/component/document-footer.jsx +++ b/src/internal-packages/crud/lib/component/document-footer.jsx @@ -3,7 +3,7 @@ const _ = require('lodash'); const React = require('react'); const Element = require('hadron-document').Element; -const TextButton = require('./text-button'); +const TextButton = require('hadron-app-registry').TextButton; /** * The progress mode. diff --git a/src/internal-packages/crud/lib/component/document-list.jsx b/src/internal-packages/crud/lib/component/document-list.jsx index cad49ae232b..dafa04f64b5 100644 --- a/src/internal-packages/crud/lib/component/document-list.jsx +++ b/src/internal-packages/crud/lib/component/document-list.jsx @@ -13,7 +13,6 @@ const LoadMoreDocumentsStore = require('../store/load-more-documents-store'); const RemoveDocumentStore = require('../store/remove-document-store'); const InsertDocumentStore = require('../store/insert-document-store'); const InsertDocumentDialog = require('./insert-document-dialog'); -const SamplingMessage = require('./sampling-message'); const Actions = require('../actions'); /** @@ -70,6 +69,7 @@ class DocumentList extends React.Component { constructor(props) { super(props); this.loading = false; + this.samplingMessage = app.appRegistry.getComponent('Component::Query::SamplingMessage'); this.state = { docs: [], nextSkip: 0, namespace: NamespaceStore.ns }; } @@ -182,7 +182,7 @@ class DocumentList extends React.Component { render() { return (
- +
    this._node = c}> diff --git a/src/internal-packages/crud/lib/component/icon-button.jsx b/src/internal-packages/crud/lib/component/icon-button.jsx deleted file mode 100644 index 3611a9ee6d7..00000000000 --- a/src/internal-packages/crud/lib/component/icon-button.jsx +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -const React = require('react'); - -/** - * The button constant. - */ -const BUTTON = 'button'; - -/** - * Component for a button with an icon. - */ -class IconButton extends React.Component { - - /** - * The component constructor. - * - * @param {Object} props - The properties. - */ - constructor(props) { - super(props); - } - - /** - * Render the button. - * - * @returns {Component} The button component. - */ - render() { - return ( - - ); - } - - /** - * By default should not need to to re-render itself. - * - * @returns {Boolean} Always false. - */ - shouldComponentUpdate() { - return false; - } -} - -IconButton.displayName = 'IconButton'; - -module.exports = IconButton; diff --git a/src/internal-packages/crud/lib/component/insert-document-dialog.jsx b/src/internal-packages/crud/lib/component/insert-document-dialog.jsx index a68312182d3..eeaeae0d5a9 100644 --- a/src/internal-packages/crud/lib/component/insert-document-dialog.jsx +++ b/src/internal-packages/crud/lib/component/insert-document-dialog.jsx @@ -4,7 +4,7 @@ const React = require('react'); const Modal = require('react-bootstrap').Modal; const OpenInsertDocumentDialogStore = require('../store/open-insert-document-dialog-store'); const InsertDocument = require('./insert-document'); -const TextButton = require('./text-button'); +const TextButton = require('hadron-app-registry').TextButton; const Actions = require('../actions'); /** diff --git a/src/internal-packages/crud/lib/component/remove-document-footer.jsx b/src/internal-packages/crud/lib/component/remove-document-footer.jsx index a76ddd5dbea..9e7611c0c9f 100644 --- a/src/internal-packages/crud/lib/component/remove-document-footer.jsx +++ b/src/internal-packages/crud/lib/component/remove-document-footer.jsx @@ -1,7 +1,7 @@ 'use strict'; const React = require('react'); -const TextButton = require('./text-button'); +const TextButton = require('hadron-app-registry').TextButton; /** * The progress mode. diff --git a/src/internal-packages/crud/lib/component/sampling-message.jsx b/src/internal-packages/crud/lib/component/sampling-message.jsx deleted file mode 100644 index c367a3a064d..00000000000 --- a/src/internal-packages/crud/lib/component/sampling-message.jsx +++ /dev/null @@ -1,74 +0,0 @@ -'use strict'; - -const React = require('react'); -const ResetDocumentListStore = require('../store/reset-document-list-store'); -const TextButton = require('./text-button'); - -/** - * Component for the sampling message. - */ -class SamplingMessage extends React.Component { - - /** - * Fetch the state when the component mounts. - */ - componentDidMount() { - this.unsubscribeReset = ResetDocumentListStore.listen(this.handleReset.bind(this)); - } - - /** - * Unsibscribe from the document list store when unmounting. - */ - componentWillUnmount() { - this.unsubscribeReset(); - } - - /** - * The component constructor. - * - * @param {Object} props - The properties. - */ - constructor(props) { - super(props); - this.state = { count: 0 }; - } - - /** - * Handle the reset of the document list. - * - * @param {Array} documents - The documents. - * @param {Integer} count - The count. - */ - handleReset(documents, count) { - this.setState({ count: count }); - } - - /** - * Render the sampling message. - * - * @returns {React.Component} The document list. - */ - render() { - return ( -
    - Query returned {this.state.count} documents. - - -
    - ); - } - - /** - * Only update when the count changes. - */ - shouldComponentUpdate(nextProps, nextState) { - return nextState.count !== this.state.count; - } -} - -SamplingMessage.displayName = 'SamplingMessage'; - -module.exports = SamplingMessage; diff --git a/src/internal-packages/crud/lib/component/text-button.jsx b/src/internal-packages/crud/lib/component/text-button.jsx deleted file mode 100644 index ea17014b28a..00000000000 --- a/src/internal-packages/crud/lib/component/text-button.jsx +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -const React = require('react'); - -/** - * The button constant. - */ -const BUTTON = 'button'; - -/** - * Component for a button with text. - */ -class TextButton extends React.Component { - - /** - * The component constructor. - * - * @param {Object} props - The properties. - */ - constructor(props) { - super(props); - } - - /** - * Render the button. - * - * @returns {Component} The button component. - */ - render() { - return ( - - ); - } - - /** - * By default should not need to to re-render itself. - * - * @returns {Boolean} Always false. - */ - shouldComponentUpdate() { - return false; - } -} - -TextButton.displayName = 'TextButton'; - -module.exports = TextButton; diff --git a/src/internal-packages/query/index.js b/src/internal-packages/query/index.js new file mode 100644 index 00000000000..12e59204eb7 --- /dev/null +++ b/src/internal-packages/query/index.js @@ -0,0 +1,28 @@ +const app = require('ampersand-app'); +const QueryBarComponent = require('./lib/component'); +const SamplingMessage = require('./lib/component/sampling-message'); +const QueryAction = require('./lib/action'); +const QueryStore = require('./lib/store'); + +/** + * Activate all the components in the Query Bar package. + */ +function activate() { + app.appRegistry.registerComponent('App:QueryBar', QueryBarComponent); + app.appRegistry.registerComponent('Component::Query::SamplingMessage', SamplingMessage); + app.appRegistry.registerAction('QueryAction', QueryAction); + app.appRegistry.registerStore('QueryStore', QueryStore); +} + +/** + * Deactivate all the components in the Query Bar package. + */ +function deactivate() { + app.appRegistry.deregisterComponent('App:QueryBar'); + app.appRegistry.deregisterComponent('Component::Query::SamplingMessage'); + app.appRegistry.deregisterAction('QueryAction'); + app.appRegistry.deregisterStore('QueryStore'); +} + +module.exports.activate = activate; +module.exports.deactivate = deactivate; diff --git a/src/internal-packages/query/lib/action/index.jsx b/src/internal-packages/query/lib/action/index.jsx new file mode 100644 index 00000000000..b0c2df79a05 --- /dev/null +++ b/src/internal-packages/query/lib/action/index.jsx @@ -0,0 +1,77 @@ +const Reflux = require('reflux'); + +const QueryAction = Reflux.createActions({ + /* Generic actions */ + + /** + * Sets the entire query, overwriting all fields. + */ + 'setQuery': {sync: true}, + /** + * Sets the entire query as string, overwriting all fields. + * Useful for text input. + */ + 'setQueryString': {sync: true}, + /** + * Sets the value for a specific field, overwriting all previous values. + */ + 'setValue': {sync: true}, + /** + * Clears the value of a specific field. + */ + 'clearValue': {sync: true}, + + /* Distinct actions: + * support single values (equality clause) and multiple values ($in clause). + */ + + /** + * Adds a value to a distinct set of values for a field. + */ + 'addDistinctValue': {sync: true}, + /** + * Removes a value from a distinct set of values for a field. + */ + 'removeDistinctValue': {sync: true}, + /** + * Toggles a value in a distinct set of values for a field. + */ + 'toggleDistinctValue': {sync: true}, + /** + * Sets all distinct values of a field at once. If a single value is specified + * and the value is already set, remove the value. + */ + 'setDistinctValues': {sync: true}, + + + /* Range actions: + * support single values (equality clause) and ranges ($gt(e) / $lt(e) clauses). + */ + + /** + * Sets a range of values, specifying min and max values and inclusive/exclusive + * upper and lower bounds. + */ + 'setRangeValues': {sync: true}, + + /* Geo actions */ + + /** + * sets a $geoWithin query with center and distance. + */ + 'setGeoWithinValue': {sync: true}, + + /* Execution */ + + /** + * apply the current query, only possible if the query is valid. Also stores + * the current query as `lastExecutedQuery`. + */ + 'apply': {sync: true}, + /** + * return to the last executed query, dismissing all changes. + */ + 'reset': {sync: true} +}); + +module.exports = QueryAction; diff --git a/src/internal-packages/query/lib/component/index.jsx b/src/internal-packages/query/lib/component/index.jsx new file mode 100644 index 00000000000..a69f2774e99 --- /dev/null +++ b/src/internal-packages/query/lib/component/index.jsx @@ -0,0 +1,35 @@ +const React = require('react'); +const QueryStore = require('../store'); +const QueryInputForm = require('./input-form'); +const StateMixin = require('reflux-state-mixin'); + +// const debug = require('debug')('mongodb-compass:query-bar'); + +const QueryBar = React.createClass({ + + /** + * automatically subscribe/unsubscribe to changes from the store. + */ + mixins: [ StateMixin.connect(QueryStore) ], + + /** + * Render Query Bar. + * + * @returns {React.Component} The Query Bar view. + */ + render() { + return ( +
    +
    +
    +
    + +
    +
    +
    +
    + ); + } +}); + +module.exports = QueryBar; diff --git a/src/internal-packages/query/lib/component/input-form.jsx b/src/internal-packages/query/lib/component/input-form.jsx new file mode 100644 index 00000000000..7db663abdb4 --- /dev/null +++ b/src/internal-packages/query/lib/component/input-form.jsx @@ -0,0 +1,83 @@ +const React = require('react'); +const QueryAction = require('../action'); +const EJSON = require('mongodb-extended-json'); + +// const debug = require('debug')('mongodb-compass:query-bar'); + +const DEFAULT_QUERY_STRING = '{}'; + +const QueryInputGroup = React.createClass({ + + propTypes: { + query: React.PropTypes.object.isRequired, + lastExecutedQuery: React.PropTypes.object, + valid: React.PropTypes.bool.isRequired, + queryString: React.PropTypes.string.isRequired + }, + + onChange(evt) { + QueryAction.setQueryString(evt.target.value); + }, + + onApplyButtonClicked(evt) { + evt.preventDefault(); + evt.stopPropagation(); + + if (this.props.valid) { + QueryAction.apply(); + } + }, + + onResetButtonClicked() { + QueryAction.reset(); + }, + + /** + * Render Query Bar input form (just the input field and buttons). + * + * @returns {React.Component} The Query Bar view. + */ + render() { + const query = this.props.queryString; + const inputGroupClass = this.props.valid ? + 'input-group' : 'input-group has-error'; + const notEmpty = this.props.queryString !== DEFAULT_QUERY_STRING && + this.props.queryString !== ''; + const resetButtonStyle = { + display: notEmpty ? 'inline-block' : 'none' + }; + + const hasChanges = this.props.queryString !== EJSON.stringify(this.props.lastExecutedQuery); + const applyDisabled = !(this.props.valid && hasChanges); + + return ( +
    +
    + + + + + +
    +
    + ); + } +}); + +module.exports = QueryInputGroup; diff --git a/src/internal-packages/query/lib/component/sampling-message.jsx b/src/internal-packages/query/lib/component/sampling-message.jsx new file mode 100644 index 00000000000..d45e0d01234 --- /dev/null +++ b/src/internal-packages/query/lib/component/sampling-message.jsx @@ -0,0 +1,157 @@ +'use strict'; + +const React = require('react'); +const app = require('ampersand-app'); +const TextButton = require('hadron-app-registry').TextButton; +const numeral = require('numeral'); +const pluralize = require('pluralize'); + +/** + * Component for the sampling message. + */ +class SamplingMessage extends React.Component { + + /** + * Fetch the state when the component mounts. + */ + componentDidMount() { + this.unsubscribeReset = this.resetDocumentListStore.listen(this.handleReset.bind(this)); + this.unsubscribeInsert = this.insertDocumentStore.listen(this.handleInsert.bind(this)); + this.unsubscribeRemove = this.documentRemovedAction.listen(this.handleRemove.bind(this)); + this.unsubscribeLoadMore = this.loadMoreDocumentsStore.listen(this.handleLoadMore.bind(this)); + } + + /** + * Unsibscribe from the document list store when unmounting. + */ + componentWillUnmount() { + this.unsubscribeReset(); + this.unsubscribeInsert(); + this.unsubscribeRemove(); + this.unsubscribeLoadMore(); + } + + /** + * The component constructor. + * + * @param {Object} props - The properties. + */ + constructor(props) { + super(props); + this.state = { count: 0, loaded: 20 }; + this.resetDocumentListStore = app.appRegistry.getStore('Store::CRUD::ResetDocumentListStore'); + this.insertDocumentStore = app.appRegistry.getStore('Store::CRUD::InsertDocumentStore'); + this.documentRemovedAction = app.appRegistry.getAction('Action::CRUD::DocumentRemoved'); + this.loadMoreDocumentsStore = app.appRegistry.getStore('Store::CRUD::LoadMoreDocumentsStore'); + } + + /** + * Handle updating the count on document insert. + */ + handleInsert() { + this.setState({ count: this.state.count + 1 }); + } + + /** + * Handle updating the count on document removal. + */ + handleRemove() { + this.setState({ count: this.state.count - 1 }); + } + + /** + * Handle the reset of the document list. + * + * @param {Array} documents - The documents. + * @param {Integer} count - The count. + */ + handleReset(documents, count) { + this.setState({ count: count, loaded: 20 }); + } + + /** + * Handle scrolling that loads more documents. + * + * @param {Array} documents - The loaded documents. + */ + handleLoadMore(documents) { + this.setState({ loaded: this.state.loaded + documents.length }); + } + + /** + * Render the sampling message. + * + * @returns {React.Component} The document list. + */ + render() { + if (this.props.insertHandler) { + return this.renderQueryMessage(); + } + return this.renderSamplingMessage(); + } + + /** + * If we are on the schema tab, the smapling message is rendered. + * + * @returns {React.Component} The sampling message. + */ + renderSamplingMessage() { + var noun = pluralize('document', this.state.count); + return ( +
    + Query returned  + {this.state.count} {noun}. + This report is based on a sample of  + {this.props.sampleSize} {noun} ({this._samplePercentage()}). + +
    + ); + } + + /** + * If we are on the documents tab, just display the count and insert button. + * + * @returns {React.Component} The count message. + */ + renderQueryMessage() { + var noun = pluralize('document', this.state.count); + return ( +
    + Query returned {this.state.count} {noun}.  + {this._loadedMessage()} + +
    + ); + } + + /** + * Only update when the count changes. + */ + shouldComponentUpdate(nextProps, nextState) { + return (nextState.count !== this.state.count) || + (nextState.loaded != this.state.loaded) || + (nextProps.sampleSize !== this.props.sampleSize); + } + + _loadedMessage() { + if (this.state.count > 20) { + return ( + + Displaying documents 1-{this.state.loaded}  + + ); + } + } + + _samplePercentage() { + var percent = (this.state.count === 0) ? 0 : this.props.sampleSize / this.state.count; + return numeral(percent).format('0.00%'); + } +} + +SamplingMessage.displayName = 'SamplingMessage'; + +module.exports = SamplingMessage; diff --git a/src/internal-packages/query/lib/store/index.jsx b/src/internal-packages/query/lib/store/index.jsx new file mode 100644 index 00000000000..eaa3b52c850 --- /dev/null +++ b/src/internal-packages/query/lib/store/index.jsx @@ -0,0 +1,367 @@ +const app = require('ampersand-app'); +const Reflux = require('reflux'); +const NamespaceStore = require('hadron-reflux-store').NamespaceStore; +const StateMixin = require('reflux-state-mixin'); +const QueryAction = require('../action'); +const EJSON = require('mongodb-extended-json'); +const Query = require('mongodb-language-model').Query; +const _ = require('lodash'); +const hasDistinctValue = require('../util').hasDistinctValue; +const filterChanged = require('hadron-action').filterChanged; +const bsonEqual = require('../util').bsonEqual; + +const debug = require('debug')('mongodb-compass:stores:query'); +// const metrics = require('mongodb-js-metrics')(); + +/** + * The reflux store for the schema. + */ +const QueryStore = Reflux.createStore({ + mixins: [StateMixin.store], + listenables: QueryAction, + + /** + * listen to Namespace store and reset if ns changes. + */ + init: function() { + NamespaceStore.listen(() => { + // reset the store + this.setState(this.getInitialState()); + }); + }, + + /** + * Initialize the document list store. + * + * @return {Object} the initial store state. + */ + getInitialState() { + return { + query: {}, + queryString: '{}', + valid: true, + lastExecutedQuery: null + }; + }, + + /** + * Sets `queryString` and `valid`, and if it is a valid query, also set `query`. + * If it is not a valid query, set `valid` to `false` and don't set the query. + * + * @param {Object} queryString the query string (i.e. manual user input) + */ + setQueryString(queryString) { + const query = this._validateQueryString(queryString); + const state = { + queryString: queryString, + valid: Boolean(query) + }; + if (query) { + state.query = query; + } + this.setState(state); + }, + + _cleanQueryString(queryString) { + let output = queryString; + // accept whitespace-only input as empty query + if (_.trim(output) === '') { + output = '{}'; + } + // wrap field names in double quotes. I appologize for the next line of code. + // @see http://stackoverflow.com/questions/6462578/alternative-to-regex-match-all-instances-not-inside-quotes + // @see https://regex101.com/r/xM7iH6/1 + output = output.replace(/([{,])\s*([^,{\s\'"]+)\s*:(?=([^"\\]*(\\.|"([^"\\]*\\.)*[^"\\]*"))*[^"]*$)/g, '$1"$2":'); + // replace multiple whitespace with single whitespace + output = output.replace(/\s+/g, ' '); + return output; + }, + + /** + * validates whether a string is a valid query. + * + * @param {Object} queryString a string to validate + * @return {Object|Boolean} false if invalid, otherwise the query + */ + _validateQueryString(queryString) { + let parsed; + try { + // is it valid eJSON? + const cleaned = this._cleanQueryString(queryString); + parsed = EJSON.parse(cleaned); + // is it a valid parsable Query according to the language? + /* eslint no-unused-vars: 0 */ + const query = new Query(parsed, { + parse: true + }); + } catch (e) { + return false; + } + return parsed; + }, + + /** + * sets the query and the query string, and computes `valid`. + * + * @param {Object} query a valid query. + */ + setQuery(query) { + const queryString = EJSON.stringify(query); + const valid = this._validateQueryString(queryString); + this.setState({ + query: query, + queryString: queryString, + valid: Boolean(valid) + }); + }, + + /** + * Sets the value for the given field. + * + * @param {Object} args arguments must include `field` and `value`, and + * can optionally include `unsetIfSet`: + * field the field of the query to set the value on. + * value the value to set. + * unsetIfSet (optional) boolean, unsets the value if an identical + * value is already set. This is useful for the toggle + * behavior we use on minichart bars. + */ + setValue(args) { + const query = _.clone(this.state.query); + if (args.unsetIfSet && _.isEqual(query[args.field], args.value, bsonEqual)) { + delete query[args.field]; + } else { + query[args.field] = args.value; + } + this.setQuery(query); + }, + + /** + * takes either a single value or an array of values, and sets the value + * correctly as equality or $in depending on the number of values. + * + * @param {Object} args arguments must include `field` and `value`: + * field the field of the query to set the value on. + * value the value(s) to set. Can be a single value or an + * array of values, in which case `$in` is used. + */ + setDistinctValues(args) { + const query = _.clone(this.state.query); + if (_.isArray(args.value)) { + if (args.value.length > 1) { + query[args.field] = {$in: args.value}; + } else if (args.value.length === 1) { + query[args.field] = args.value[0]; + } else { + this.clearValue(args); + } + this.setQuery(query); + return; + } + query[args.field] = args.value; + this.setQuery(query); + }, + + clearValue(args) { + const query = _.clone(this.state.query); + delete query[args.field]; + this.setQuery(query); + }, + + /** + * adds a discrete value to a field, converting primitive values to $in lists + * as required. + * + * @param {Object} args object with a `field` and `value` key. + */ + addDistinctValue(args) { + const query = _.clone(this.state.query); + const field = _.get(query, args.field, undefined); + + // field not present in query yet, add primitive value + if (field === undefined) { + query[args.field] = args.value; + this.setQuery(query); + return; + } + // field is object, could be a $in clause or a primitive value + if (_.isPlainObject(field)) { + if (_.has(field, '$in')) { + // add value to $in array if it is not present yet + const inArray = query[args.field].$in; + if (!_.contains(inArray, args.value)) { + query[args.field].$in.push(args.value); + this.setQuery(query); + } + return; + } + // it is not a $in operator, replace the value + query[args.field] = args.value; + this.setQuery(query); + return; + } + // in all other cases, we want to turn a primitive value into a $in list + query[args.field] = {$in: [field, args.value]}; + this.setQuery(query); + }, + + removeDistinctValue(args) { + const query = _.clone(this.state.query); + const field = _.get(query, args.field, undefined); + + if (field === undefined) { + return; + } + + if (_.isPlainObject(field)) { + if (_.has(field, '$in')) { + // add value to $in array if it is not present yet + const inArray = query[args.field].$in; + const newArray = _.pull(inArray, args.value); + // if $in array was reduced to single value, replace with primitive + if (newArray.length > 1) { + query[args.field].$in = newArray; + } else if (newArray.length === 1) { + query[args.field] = newArray[0]; + } else { + delete query[args.field]; + } + this.setQuery(query); + return; + } + } + // if value to remove is the same as the primitive value, unset field + if (_.isEqual(field, args.value, bsonEqual)) { + delete query[args.field]; + this.setQuery(query); + return; + } + // else do nothing + return; + }, + + /** + * adds distinct value (equality or $in) if not yet present, otherwise + * removes it. + * + * @param {Object} args object with a `field` and `value` key. + */ + toggleDistinctValue(args) { + const field = _.get(this.state.query, args.field, undefined); + const actionFn = hasDistinctValue(field, args.value) ? + this.removeDistinctValue : this.addDistinctValue; + actionFn(args); + }, + + /** + * Sets a range with minimum and/or maximum, and determines inclusive/exclusive + * upper and lower bounds. If neither `min` nor `max` are set, clears the field. + * + * @param {Object} args arguments must include `field`, and can optionally + * include `min`, `max`, `minInclusive`, `maxInclusive` + * and `unsetIfSet`: + * field the field of the query to set the value on. + * min (optional) the minimum value (lower bound) + * minInclusive (optional) boolean, true uses $gte, false uses $gt + * default is true. + * max (optional) the maximum value (upper bound) + * maxInclusive (optional) boolean, true uses $lte, false uses $lt + * default is false. + * unsetIfSet (optional) boolean, unsets the value if an identical + * value is already set. This is useful for the toggle + * behavior we use on minichart bars. + */ + setRangeValues(args) { + const query = _.clone(this.state.query); + const value = {}; + let op; + // without min and max, clear the field + const minValue = _.get(args, 'min', undefined); + const maxValue = _.get(args, 'max', undefined); + if (minValue === undefined && maxValue === undefined) { + this.clearValue({field: args.field}); + return; + } + + if (minValue !== undefined) { + op = _.get(args, 'minInclusive', true) ? '$gte' : '$gt'; + value[op] = minValue; + } + + if (maxValue !== undefined) { + op = _.get(args, 'maxInclusive', false) ? '$lte' : '$lt'; + value[op] = maxValue; + } + + // if `args.unsetIfSet` is true, then unset the value if it's already set + if (args.unsetIfSet && _.isEqual(query[args.field], value, bsonEqual)) { + delete query[args.field]; + } else { + query[args.field] = value; + } + this.setQuery(query); + }, + + /** + * takes a center coordinate [lng, lat] and a radius in miles and constructs + * a circular geoWithin query. + * + * @param {Object} args arguments must include `field` and `value`: + * field the field of the query to set the value on. + * center array of two numeric values: longitude and latitude + * radius radius in miles of the circle + * + * @see https://docs.mongodb.com/manual/tutorial/calculate-distances-using-spherical-geometry-with-2d-geospatial-indexes/ + */ + setGeoWithinValue(args) { + const query = _.clone(this.state.query); + const value = {}; + const radius = _.get(args, 'radius', 0); + const center = _.get(args, 'center', null); + + if (radius && center) { + value.$geoWithin = { + $centerSphere: [[center[0], center[1]], radius] + }; + query[args.field] = value; + this.setQuery(query); + return; + } + // else if center or radius are not set, or radius is 0, clear field + this.clearValue({field: args.field}); + }, + + /** + * apply the current (valid) query, and store it in `lastExecutedQuery`. + */ + apply() { + if (this.state.valid) { + this.setState({ + lastExecutedQuery: _.clone(this.state.query) + }); + // start queries for all tabs: schema, documents, explain, indexes + const SchemaAction = app.appRegistry.getAction('SchemaAction'); + SchemaAction.startSampling(); + filterChanged(this.state.query); + } + }, + + /** + * dismiss current changes to the query and restore `{}` as the query. + */ + reset() { + if (!_.isEqual(this.state.query, {})) { + this.setQuery({}); + if (!_.isEqual(this.state.lastExecutedQuery, {})) { + QueryAction.apply(); + } + } + }, + + storeDidUpdate(prevState) { + debug('query store changed from %j to %j', prevState, this.state); + } + +}); + +module.exports = QueryStore; diff --git a/src/internal-packages/query/lib/util/index.js b/src/internal-packages/query/lib/util/index.js new file mode 100644 index 00000000000..e9afc5bfa8f --- /dev/null +++ b/src/internal-packages/query/lib/util/index.js @@ -0,0 +1,162 @@ +const _ = require('lodash'); +// const debug = require('debug')('mongodb-compass:schema:test'); + +function bsonEqual(value, other) { + const bsontype = _.get(value, '_bsontype', undefined); + if (bsontype === 'ObjectID') { + return value.equals(other); + } + // for all others, use native comparisons + return undefined; +} + +/** + * determines if a field in the query has a distinct value (equality or $in). + * + * @param {Any} field the right-hand side of a document field + * @param {Any} value the value to check + * @return {Boolean} whether or not value is included in field + */ +function hasDistinctValue(field, value) { + // field not present, add primitive value + if (field === undefined) { + return false; + } + // field is object, could be a $in clause or a primitive value + if (_.isPlainObject(field)) { + if (_.has(field, '$in')) { + // check if $in array contains the value + const inArray = field.$in; + return (_.contains(inArray, value)); + } + } + // it is not a $in operator, check value directly + return (_.isEqual(field, value, bsonEqual)); +} + +/** + * returns an array of all distinct values in a field (equality or $in) + * + * @param {Any} field the right-hand side of a document field + * @return {Boolean} array of values for this field + */ +function getDistinctValues(field) { + // field not present, return empty array + if (field === undefined) { + return []; + } + // field is object, could be a $in clause or a primitive value + if (_.isPlainObject(field)) { + if (_.has(field, '$in')) { + return field.$in; + } + } + // it is not a $in operator, return single value as array + return [field]; +} + +/** + * returns whether a value is fully or partially covered by a range, + * specified with $gt(e)/$lt(e). Ranges can be open ended on either side, + * and can also be single equality queries, e.g. {"field": 16}. + * + * @examples + * inValueRange(15, {value: 15, dx: 0}) => 'yes' + * inValueRange({$gte: 15, $lt: 30}, {value: 20, dx: 5}) => 'yes' + * inValueRange({$gte: 15, $lt: 30}, {value: 15, dx: 5}) => 'yes' + * inValueRange(15, {value: 15, dx: 1}) => 'partial' + * inValueRange({$gt: 15, $lt: 30}, {value: 15, dx: 5}) => 'partial' + * inValueRange({$gte: 15, $lt: 30}, {value: 20, dx: 20}) => 'partial' + * inValueRange({$gte: 15, $lt: 30}, {value: 10, dx: 10}) => 'partial' + * inValueRange({$gte: 15, $lt: 30}, {value: 10, dx: 5}) => 'partial' + * inValueRange({$gt: 15, $lt: 30}, {value: 10, dx: 5}) => 'no' + * inValueRange({$gte: 15, $lt: 30}, {value: 10, dx: 4}) => 'no' + * + * @param {Object|number} field the field value (range or number) + * @param {Object} d object with a `value` and `dx` field if + * the value represents a binned range itself + * @return {String} 'yes', 'partial', 'no' + */ +function inValueRange(field, d) { + const compOperators = { + $gte: function(a, b) { + return a >= b; + }, + $gt: function(a, b) { + return a > b; + }, + $lte: function(a, b) { + return a <= b; + }, + $lt: function(a, b) { + return a < b; + } + }; + + const conditions = []; + const edgeCase = []; + + if (!_.isPlainObject(field)) { + // add an equality condition + conditions.push(function(a) { + return _.isEqual(a, field, bsonEqual); + }); + edgeCase.push(field); + } else { + _.forOwn(field, function(value, key) { + // add comparison condition(s), right-curried with the value of the query + conditions.push(_.curryRight(compOperators[key])(value)); + edgeCase.push(value); + }); + } + const dx = _.get(d, 'dx', null); + + // extract bound(s) + const bounds = dx === null ? [d.value] : _.uniq([d.value, d.value + dx]); + + /* + * Logic to determine if the query covers the value (or value range) + * + * if all bounds pass all conditions, the value is fully covered in the range (yes) + * if one of two bounds passes all conditions, the value is partially covered (partial) + * if none of the bounds pass all conditions, the value is not covered (no) + * + * Since the upper bound of a bar represents the exclusive bound + * (i.e. lower <= x < upper) we need to use a little hack to adjust for + * the math. This means that if someone adjusts the query bound manually by + * less than 1 millionth of the value, one of the bars may appear half + * selected instead of not/fully selected. The error is purely visual. + */ + const results = _.map(bounds, function(bound, i) { + // adjust the upper bound slightly as it represents an exclusive bound + // getting this right would require a lot more code to check for all 4 + // edge cases. + if (i > 0) { + bound *= 0.999999; + } + return _.every(_.map(conditions, function(cond) { + return cond(bound); + })); + }); + + if (_.every(results)) { + return 'yes'; + } + if (_.some(results)) { + return 'partial'; + } + // check for edge case where range wraps around query on both ends + if (_.every(edgeCase, function(val) { + return val > bounds[0] && val < bounds[bounds.length - 1]; + })) { + return 'partial'; + } + return 'no'; +} + +module.exports = { + hasDistinctValue: hasDistinctValue, + getDistinctValues: getDistinctValues, + inValueRange: inValueRange, + bsonEqual: bsonEqual +}; diff --git a/src/internal-packages/query/package.json b/src/internal-packages/query/package.json new file mode 100644 index 00000000000..10bf6ab4495 --- /dev/null +++ b/src/internal-packages/query/package.json @@ -0,0 +1,9 @@ +{ + "name": "query-bar", + "productName": "Compass Query Bar", + "description": "Compass Query Bar component with buttons.", + "version": "0.0.1", + "authors": "MongoDB Inc.", + "private": true, + "main": "./index.js" +} diff --git a/src/internal-packages/query/styles/index.less b/src/internal-packages/query/styles/index.less new file mode 100644 index 00000000000..07a945b6375 --- /dev/null +++ b/src/internal-packages/query/styles/index.less @@ -0,0 +1,27 @@ +.refine-view-container { + position: relative; + z-index: 1; + + .query-input-container { + padding: 12px 10px 12px; + background: @gray8; + border-bottom: 1px solid @gray7; + + input[type='text'] { + font-family: @font-family-monospace; + background: @pw; + height: 28px; + & + .input-group-btn { + padding-left: 10px; + + .btn { + border-radius: 3px; + } + + &:last-child > .btn { + margin-left: 2px; + } + } + } + } +} diff --git a/src/internal-packages/query/test/index.test.js b/src/internal-packages/query/test/index.test.js new file mode 100644 index 00000000000..8e581382891 --- /dev/null +++ b/src/internal-packages/query/test/index.test.js @@ -0,0 +1,9 @@ +/* eslint no-var: 0 */ +var QueryBar = require('../lib/component'); +var assert = require('assert'); + +describe('QueryBar', function() { + it('should work', function() { + assert.ok(QueryBar); + }); +}); diff --git a/src/internal-packages/query/test/ranges.test.js b/src/internal-packages/query/test/ranges.test.js new file mode 100644 index 00000000000..d1b222c63a9 --- /dev/null +++ b/src/internal-packages/query/test/ranges.test.js @@ -0,0 +1,197 @@ +/* eslint no-var: 0 */ +var inValueRange = require('../lib/util').inValueRange; +var assert = require('assert'); +var bson = require('bson'); + +describe('inValueRange', function() { + describe('equality queries', function() { + var query; + beforeEach(function() { + query = 15; + }); + it('should detect a match', function() { + assert.equal(inValueRange(query, {value: 15, dx: 0}), 'yes'); + }); + it('should detect a partial match', function() { + assert.equal(inValueRange(query, {value: 14, dx: 2}), 'partial'); + }); + it('should detect a miss', function() { + assert.equal(inValueRange(query, {value: 14.99, dx: 0}), 'no'); + assert.equal(inValueRange(query, {value: 15.01, dx: 0}), 'no'); + }); + }); + describe('closed ranges with $gte and $lt', function() { + var query; + beforeEach(function() { + query = {$gte: 15, $lt: 30}; + }); + it('should detect a match', function() { + assert.equal(inValueRange(query, {value: 20, dx: 5}), 'yes'); + }); + it('should detect a match at the lower bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 5}), 'yes'); + }); + it('should detect a partial match across the upper bound', function() { + assert.equal(inValueRange(query, {value: 20, dx: 20}), 'partial'); + }); + it('should detect a partial match across the lower bound', function() { + assert.equal(inValueRange(query, {value: 10, dx: 10}), 'partial'); + }); + it('should detect a miss exactly at the lower bound', function() { + assert.equal(inValueRange(query, {value: 10, dx: 5}), 'no'); + }); + it('should detect a miss exactly at the upper bound', function() { + assert.equal(inValueRange(query, {value: 30, dx: 5}), 'no'); + }); + it('should detect a miss just below the lower bound', function() { + assert.equal(inValueRange(query, {value: 10, dx: 4.99}), 'no'); + }); + it('should detect edge case where range wraps around both bounds', function() { + assert.equal(inValueRange(query, {value: 0, dx: 100}), 'partial'); + }); + }); + + describe('open ranges with $gte', function() { + var query; + beforeEach(function() { + query = {$gte: 15}; + }); + it('should detect a match for a range', function() { + assert.equal(inValueRange(query, {value: 20, dx: 5}), 'yes'); + }); + it('should detect a match for single value', function() { + assert.equal(inValueRange(query, {value: 20, dx: 0}), 'yes'); + }); + it('should detect a match for a range, starting at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 5}), 'yes'); + }); + it('should detect a miss for a range, ending at the bound', function() { + assert.equal(inValueRange(query, {value: 10, dx: 5}), 'no'); + }); + it('should detect a match for single value at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 0}), 'yes'); + }); + it('should detect a partial match for a range across the bound', function() { + assert.equal(inValueRange(query, {value: 12, dx: 5}), 'partial'); + }); + it('should detect a miss for a range below the bound', function() { + assert.equal(inValueRange(query, {value: -20, dx: 5}), 'no'); + }); + it('should detect a miss for a single value below the bound', function() { + assert.equal(inValueRange(query, {value: -20, dx: 0}), 'no'); + }); + }); + describe('open ranges with $gt', function() { + var query; + beforeEach(function() { + query = {$gt: 15}; + }); + it('should detect a match for a range', function() { + assert.equal(inValueRange(query, {value: 20, dx: 5}), 'yes'); + }); + it('should detect a match for single value', function() { + assert.equal(inValueRange(query, {value: 20, dx: 0}), 'yes'); + }); + it('should detect a partial match for a range, starting at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 5}), 'partial'); + }); + it('should detect a miss for single value at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 0}), 'no'); + }); + it('should detect a partial match for a range across the bound', function() { + assert.equal(inValueRange(query, {value: 12, dx: 5}), 'partial'); + }); + it('should detect a miss for a range ending at the bound', function() { + assert.equal(inValueRange(query, {value: 10, dx: 5}), 'no'); + }); + it('should detect a miss for a range below the bound', function() { + assert.equal(inValueRange(query, {value: -20, dx: 5}), 'no'); + }); + it('should detect a miss for a single value below the bound', function() { + assert.equal(inValueRange(query, {value: -20, dx: 0}), 'no'); + }); + }); + describe('open ranges with $lte', function() { + var query; + beforeEach(function() { + query = {$lte: 15}; + }); + it('should detect a match for a range', function() { + assert.equal(inValueRange(query, {value: 5, dx: 5}), 'yes'); + }); + it('should detect a match for single value', function() { + assert.equal(inValueRange(query, {value: 10, dx: 0}), 'yes'); + }); + it('should detect a match for single value at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 0}), 'yes'); + }); + it('should detect a partial match for a range across the bound', function() { + assert.equal(inValueRange(query, {value: 12, dx: 5}), 'partial'); + }); + it('should detect a match for a range ending at the bound', function() { + assert.equal(inValueRange(query, {value: 10, dx: 5}), 'yes'); + }); + it('should detect a partial match for a range starting at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 5}), 'partial'); + }); + it('should detect a miss for a range above the bound', function() { + assert.equal(inValueRange(query, {value: 20, dx: 5}), 'no'); + }); + it('should detect a miss for a single value above the bound', function() { + assert.equal(inValueRange(query, {value: 20, dx: 0}), 'no'); + }); + }); + describe('open ranges with $lt', function() { + var query; + beforeEach(function() { + query = {$lt: 15}; + }); + it('should detect a match for a range', function() { + assert.equal(inValueRange(query, {value: 5, dx: 5}), 'yes'); + }); + it('should detect a match for single value', function() { + assert.equal(inValueRange(query, {value: 10, dx: 0}), 'yes'); + }); + it('should detect a miss for a range starting at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 5}), 'no'); + }); + it('should detect a miss for single value at the bound', function() { + assert.equal(inValueRange(query, {value: 15, dx: 0}), 'no'); + }); + it('should detect a partial match for a range across the bound', function() { + assert.equal(inValueRange(query, {value: 12, dx: 5}), 'partial'); + }); + it('should detect a match for a range ending at the bound', function() { + assert.equal(inValueRange(query, {value: 10, dx: 5}), 'yes'); + }); + it('should detect a miss for a range above the bound', function() { + assert.equal(inValueRange(query, {value: 20, dx: 5}), 'no'); + }); + it('should detect a miss for a single value above the bound', function() { + assert.equal(inValueRange(query, {value: 20, dx: 0}), 'no'); + }); + }); + + describe('non-numeric types', function() { + it('should work for dates', function() { + var query = {$gte: new Date('2011-01-01'), $lte: new Date('2013-01-01')}; + assert.equal(inValueRange(query, {value: new Date('2012-01-01')}), 'yes'); + assert.equal(inValueRange(query, {value: new Date('2015-01-01')}), 'no'); + }); + it('should work for objectids', function() { + var query = { + $gte: new bson.ObjectId('578cfb38d5021e616087f53f'), + $lte: new bson.ObjectId('578cfb42d5021e616087f541') + }; + assert.equal(inValueRange(query, {value: new bson.ObjectId('578cfb3ad5021e616087f540')}), 'yes'); + assert.equal(inValueRange(query, {value: new bson.ObjectId('578cfb6fd5021e616087f542')}), 'no'); + }); + }); + + describe('special edge cases', function() { + it('should detect a miss exactly at the lower bound for very large numbers', function() { + var query = {$gte: 10000000000, $lt: 10100000000}; + assert.equal(inValueRange(query, {value: 9900000000, dx: 100000000}), 'no'); + }); + }); +}); diff --git a/src/internal-packages/schema/index.js b/src/internal-packages/schema/index.js new file mode 100644 index 00000000000..010935e4532 --- /dev/null +++ b/src/internal-packages/schema/index.js @@ -0,0 +1,27 @@ +'use strict'; + +const app = require('ampersand-app'); +const SchemaComponent = require('./lib/component'); +const SchemaAction = require('./lib/action'); +const SchemaStore = require('./lib/store'); + +/** + * Activate all the components in the CRUD package. + */ +function activate() { + app.appRegistry.registerComponent('Collection:Schema', SchemaComponent); + app.appRegistry.registerAction('SchemaAction', SchemaAction); + app.appRegistry.registerStore('SchemaStore', SchemaStore); +} + +/** + * Deactivate all the components in the CRUD package. + */ +function deactivate() { + app.appRegistry.deregisterComponent('Collection:Schema'); + app.appRegistry.deregisterAction('SchemaAction'); + app.appRegistry.deregisterStore('SchemaStore'); +} + +module.exports.activate = activate; +module.exports.deactivate = deactivate; diff --git a/src/internal-packages/schema/lib/action/index.jsx b/src/internal-packages/schema/lib/action/index.jsx new file mode 100644 index 00000000000..8094f4d68f6 --- /dev/null +++ b/src/internal-packages/schema/lib/action/index.jsx @@ -0,0 +1,26 @@ +const Reflux = require('reflux'); + +const SchemaAction = Reflux.createActions({ + /** + * starts schema sampling with the current query + */ + startSampling: {sync: true}, + /** + * stops schema sampling + */ + stopSampling: {sync: true}, + /** + * set new maxTimeMS value + */ + setMaxTimeMS: {sync: true}, + /** + * reset maxTimeMS value to default + */ + resetMaxTimeMS: {sync: true}, + /** + * Resize the minicharts. + */ + resizeMiniCharts: {sync: true} +}); + +module.exports = SchemaAction; diff --git a/src/internal-packages/schema/lib/component/array.jsx b/src/internal-packages/schema/lib/component/array.jsx new file mode 100644 index 00000000000..4b4fd70420b --- /dev/null +++ b/src/internal-packages/schema/lib/component/array.jsx @@ -0,0 +1,46 @@ +const React = require('react'); +const _ = require('lodash'); +const pluralize = require('pluralize'); +const numeral = require('numeral'); + +// const debug = require('debug')('mongodb-compass:schema:array'); + +const ArrayMinichart = React.createClass({ + + propTypes: { + type: React.PropTypes.object.isRequired, + nestedDocType: React.PropTypes.object + }, + + render() { + let arrayOfFieldsMessage = ''; + if (this.props.nestedDocType) { + const numFields = _.get(this.props.nestedDocType.fields, 'length', 0); + const nestedFields = pluralize('nested field', numFields, true); + arrayOfFieldsMessage = `Array of documents with ${nestedFields}.`; + } + + const minLength = _.min(this.props.type.lengths); + const averageLength = numeral(this.props.type.average_length).format('0.0[0]'); + const maxLength = _.max(this.props.type.lengths); + + return ( +
    +
    +
    {arrayOfFieldsMessage}
    +
    +
    Array lengths
    +
    +
      +
    • min: {minLength}
    • +
    • average: {averageLength}
    • +
    • max: {maxLength}
    • +
    +
    +
    +
    + ); + } +}); + +module.exports = ArrayMinichart; diff --git a/src/internal-packages/schema/lib/component/d3component.jsx b/src/internal-packages/schema/lib/component/d3component.jsx new file mode 100644 index 00000000000..820c8de7672 --- /dev/null +++ b/src/internal-packages/schema/lib/component/d3component.jsx @@ -0,0 +1,97 @@ +const React = require('react'); +const ReactDOM = require('react-dom'); +const d3 = require('d3'); +const _ = require('lodash'); + +// const debug = require('debug')('mongodb-compass:schema:d3component'); + +const D3Component = React.createClass({ + + propTypes: { + fieldName: React.PropTypes.string.isRequired, + type: React.PropTypes.object.isRequired, + renderMode: React.PropTypes.oneOf(['svg', 'div']), + width: React.PropTypes.number, + height: React.PropTypes.number, + fn: React.PropTypes.func.isRequired, + query: React.PropTypes.any + }, + + getInitialState() { + return { + chart: null + }; + }, + + componentWillMount() { + this.setState({ + chart: this.props.fn() + }); + }, + + componentDidMount: function() { + this._redraw(); + }, + + componentDidUpdate() { + this._redraw(); + }, + + _getContainer() { + let options = { + className: 'minichart', + ref: 'container' + }; + const sizeOptions = { + width: this.props.width, + height: this.props.height + }; + if (this.props.renderMode === 'svg') { + options = _.assign(options, sizeOptions); + return ( + + + + + + + + + + + ); + } + options = _.assign(options, { + style: sizeOptions + }); + return
    ; + }, + + _redraw() { + const el = ReactDOM.findDOMNode(this.refs.container); + this.state.chart + .width(this.props.width) + .height(this.props.height); + + this.state.chart.options({ + fieldName: this.props.fieldName, + unique: this.props.type.unique, + query: this.props.query + }); + + d3.select(el) + .datum(this.props.type.values) + .call(this.state.chart); + }, + + render() { + const container = this._getContainer(); + return ( +
    + {container} +
    + ); + } +}); + +module.exports = D3Component; diff --git a/src/internal-packages/schema/lib/component/document.jsx b/src/internal-packages/schema/lib/component/document.jsx new file mode 100644 index 00000000000..049128b95ee --- /dev/null +++ b/src/internal-packages/schema/lib/component/document.jsx @@ -0,0 +1,32 @@ +const React = require('react'); +const pluralize = require('pluralize'); +const _ = require('lodash'); + +const debug = require('debug')('mongodb-compass:schema:array'); + +const DocumentMinichart = React.createClass({ + + propTypes: { + nestedDocType: React.PropTypes.object + }, + + render() { + let docFieldsMessage = ''; + if (this.props.nestedDocType) { + const numFields = _.get(this.props.nestedDocType.fields, 'length', 0); + const nestedFields = pluralize('nested field', numFields, true); + docFieldsMessage = `Document with ${nestedFields}.`; + } + + return ( +
    +
    +
    {docFieldsMessage}
    +
    +
    +
    + ); + } +}); + +module.exports = DocumentMinichart; diff --git a/src/internal-packages/schema/lib/component/field.jsx b/src/internal-packages/schema/lib/component/field.jsx new file mode 100644 index 00000000000..13f55a4b5f9 --- /dev/null +++ b/src/internal-packages/schema/lib/component/field.jsx @@ -0,0 +1,193 @@ +const React = require('react'); +const Type = require('./type'); +const Minichart = require('./minichart'); +const detectCoordinates = require('detect-coordinates'); +const _ = require('lodash'); + +// const debug = require('debug')('mongodb-compass:schema:field'); + +/** + * The full schema component class. + */ +const FIELD_CLASS = 'schema-field'; + +/** + * Component for the entire document list. + */ +const Field = React.createClass({ + propTypes: { + // non-dotted name of the field, e.g. `street` + name: React.PropTypes.string, + // full dotted name of the field, e.g. `address.street` + path: React.PropTypes.string, + // array of type objects present in this field + types: React.PropTypes.array, + // array of subfields in a nested documents + fields: React.PropTypes.array + }, + + getInitialState() { + return { + // whether the nested fields are collapsed (true) or expanded (false) + collapsed: true, + // a reference to the active type object (only null initially) + activeType: null + }; + }, + + componentWillMount() { + // sets the active type to the first type in the props.types array + this.setState({ + activeType: this.props.types.length > 0 ? this.props.types[0] : null + }); + }, + + /** + * returns the field list (an array of components) for nested + * subdocuments. + * + * @return {component} Field list or empty div + */ + getChildren() { + const fields = _.get(this.getNestedDocType(), 'fields', []); + let fieldList; + + if (this.state.collapsed) { + // return empty div if field is collapsed + fieldList = []; + } else { + fieldList = fields.map((field) => { + return ; + }); + } + return ( +
    + {fieldList} +
    + ); + }, + + /** + * returns Document type object of a nested document, either directly nested + * or sub-documents inside an array. + * + * @return {Object} object representation of `Document` type. + * + * @example + * {foo: {bar: 1}} ==> {bar: 1} is a direct descendant + * {foo: [{baz: 2}]} ==> {baz: 2} is a nested document inside an array + * + * @see mongodb-js/mongodb-schema + */ + getNestedDocType() { + // check for directly nested document first + const docType = _.find(this.props.types, 'name', 'Document'); + if (docType) { + return docType; + } + // otherwise check for nested documents inside an array + const arrType = _.find(this.props.types, 'name', 'Array'); + if (arrType) { + return _.find(arrType.types, 'name', 'Document'); + } + return null; + }, + + /** + * tests type for semantic interpretations, like geo coordinates, and + * replaces type information like name and values if there's a match. + * + * @param {Object} type The original type + * @return {Object} The possibly modified type + */ + getSemanticType(type) { + // check if the type represents geo coordinates + const coords = detectCoordinates(type); + if (coords) { + type.name = 'Coordinates'; + type.values = coords; + } + return type; + }, + + /** + * onclick handler to toggle collapsed/expanded state. This will hide/show + * the nested fields and turn the disclosure triangle sideways. + */ + titleClicked() { + this.setState({collapsed: !this.state.collapsed}); + }, + + /** + * callback passed down to each type to be called when the type is + * clicked. Will change the state of the Field component to track the + * active type. + * + * @param {Object} type object of the clicked type + */ + renderType(type) { + this.setState({activeType: type}); + }, + + /** + * Render a single field; + * + * @returns {React.Component} A react component for a single field + */ + render() { + // top-level class of this component + const cls = FIELD_CLASS + ' ' + (this.state.collapsed ? 'collapsed' : 'expanded'); + + // types represented as horizontal bars with labels + const types = _.sortBy(this.props.types, (type) => { + if (type.name === 'Undefined') { + return -Infinity; + } + return type.probability; + }).reverse(); + const typeList = types.map((type) => { + // allow for semantic types and convert the type, e.g. geo coordinates + type = this.getSemanticType(type); + return ( + + ); + }); + + const activeType = this.state.activeType; + const nestedDocType = this.getNestedDocType(); + + // children fields in case of nested array / document + return ( +
    +
    +
    +
    + + {this.props.name} +
    +
    + {typeList} +
    +
    +
    + +
    +
    + {this.getChildren()} +
    + ); + } +}); + +module.exports = Field; diff --git a/src/internal-packages/schema/lib/component/index.jsx b/src/internal-packages/schema/lib/component/index.jsx new file mode 100644 index 00000000000..a3facd6e5c2 --- /dev/null +++ b/src/internal-packages/schema/lib/component/index.jsx @@ -0,0 +1,100 @@ +const app = require('ampersand-app'); +const React = require('react'); +const SchemaStore = require('../store'); +const StateMixin = require('reflux-state-mixin'); +const Field = require('./field'); +const StatusSubview = require('../component/status-subview'); +const _ = require('lodash'); + +// const debug = require('debug')('mongodb-compass:schema'); + +/** + * Component for the entire schema view component. + */ +const Schema = React.createClass({ + + mixins: [ + StateMixin.connect(SchemaStore) + ], + + componentWillMount() { + this.samplingMessage = app.appRegistry.getComponent('Component::Query::SamplingMessage'); + this.StatusAction = app.appRegistry.getAction('StatusAction'); + }, + + shouldComponentUpdate() { + // @todo optimize this + return true; + }, + + /** + * updates the progress bar according to progress of schema sampling. + * The count is indeterminate (trickling), and sampling/analyzing is + * increased in 5% steps. + */ + _updateProgressBar() { + if (this.state.samplingState === 'error') { + this.StatusAction.configure({ + progressbar: false, + animation: false + }); + return; + } + const progress = this.state.samplingProgress; + // initial schema phase, cannot measure progress, enable trickling + if (this.state.samplingProgress === -1) { + this.trickleStop = null; + this.StatusAction.configure({ + visible: true, + progressbar: true, + progress: 0, + animation: true, + trickle: true, + subview: StatusSubview + }); + } else if (progress >= 0 && progress < 100 && progress % 5 === 1) { + if (this.trickleStop === null) { + // remember where trickling stopped to calculate remaining progress + const StatusStore = app.appRegistry.getStore('StatusStore'); + this.trickleStop = StatusStore.state.progress; + } + const newProgress = Math.ceil(this.trickleStop + (100 - this.trickleStop) / 100 * progress); + this.StatusAction.configure({ + visible: true, + trickle: false, + animation: true, + progressbar: true, + subview: StatusSubview, + progress: newProgress + }); + } else if (progress === 100) { + this.StatusAction.done(); + } + }, + + /** + * Render the schema + * + * @returns {React.Component} The schema view. + */ + render() { + this._updateProgressBar(); + const fieldList = _.get(this.state.schema, 'fields', []).map((field) => { + return ; + }); + return ( +
    + +
    +
    +
    + {fieldList} +
    +
    +
    +
    + ); + } +}); + +module.exports = Schema; diff --git a/src/internal-packages/schema/lib/component/minichart.jsx b/src/internal-packages/schema/lib/component/minichart.jsx new file mode 100644 index 00000000000..0618794592d --- /dev/null +++ b/src/internal-packages/schema/lib/component/minichart.jsx @@ -0,0 +1,142 @@ +const app = require('ampersand-app'); +const React = require('react'); +const UniqueMinichart = require('./unique'); +const _ = require('lodash'); +const DocumentMinichart = require('./document'); +const ArrayMinichart = require('./array'); +const D3Component = require('./d3component'); +const vizFns = require('../d3'); +const Actions = require('../action'); + +// const debug = require('debug')('mongodb-compass:schema:minichart'); + +const Minichart = React.createClass({ + + propTypes: { + fieldName: React.PropTypes.string.isRequired, + type: React.PropTypes.object.isRequired, + nestedDocType: React.PropTypes.object + }, + + getInitialState() { + return { + containerWidth: null, + query: {} + }; + }, + + componentDidMount() { + const rect = this.refs.minichart.getBoundingClientRect(); + + /* eslint react/no-did-mount-set-state: 0 */ + + // yes, this is not ideal, we are rendering the empty container first to + // measure the size, then render the component with content a second time, + // but it is not noticable to the user. + this.setState({ + containerWidth: rect.width + }); + window.addEventListener('resize', this.handleResize); + + const QueryStore = app.appRegistry.getStore('QueryStore'); + this.unsubscribeQueryStore = QueryStore.listen((store) => { + this.setState({ + query: store.query + }); + }); + + this.unsubscribeMiniChartResize = Actions.resizeMiniCharts.listen(this.handleResize); + }, + + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize); + this.unsubscribeQueryStore(); + this.unsubscribeMiniChartResize(); + }, + + handleResize() { + const rect = this.refs.minichart.getBoundingClientRect(); + this.setState({ + containerWidth: rect.width + }); + }, + + minichartFactory() { + /* eslint camelcase: 0 */ + const typeName = this.props.type.name; + const fieldName = this.props.fieldName; + const queryClause = this.state.query[fieldName]; + const has_duplicates = this.props.type.has_duplicates; + const fn = vizFns[typeName.toLowerCase()]; + const width = this.state.containerWidth; + + if (_.includes(['String', 'Number'], typeName) && !has_duplicates) { + return ( + + ); + } + if (typeName === 'Coordinates') { + const height = width / 1.618; // = golden ratio + return ( + + ); + } + if (typeName === 'Document') { + return ( + + ); + } + if (typeName === 'Array') { + return ( + + ); + } + if (typeName === 'Undefined') { + return
    Undefined
    ; + } + if (!fn) { + return null; + } + return ( + + ); + }, + + render() { + const minichart = this.state.containerWidth ? this.minichartFactory() : null; + return ( +
    + {minichart} +
    + ); + } + +}); + +module.exports = Minichart; diff --git a/src/internal-packages/schema/lib/component/status-subview/buttons-error.jsx b/src/internal-packages/schema/lib/component/status-subview/buttons-error.jsx new file mode 100644 index 00000000000..6bacfceafcf --- /dev/null +++ b/src/internal-packages/schema/lib/component/status-subview/buttons-error.jsx @@ -0,0 +1,81 @@ +const app = require('ampersand-app'); +const React = require('react'); +const ms = require('ms'); + +// const debug = require('debug')('mongodb-compass:schema:status-subview:buttons-error'); + +const RETRY_INC_MAXTIMEMS_VALUE = 60000; +/** + * Component for the entire document list. + */ +const ButtonsError = React.createClass({ + propTypes: { + maxTimeMS: React.PropTypes.number.isRequired, + samplingState: React.PropTypes.string.isRequired + }, + + componentWillMount() { + this.StatusAction = app.appRegistry.getAction('StatusAction'); + this.SchemaAction = app.appRegistry.getAction('SchemaAction'); + }, + + onTryAgainButtonClick() { + // increase maxTimeMS and sample again + this.SchemaAction.setMaxTimeMS(RETRY_INC_MAXTIMEMS_VALUE); + this.SchemaAction.startSampling(); + }, + + onNewQueryButtonClick() { + // dismiss status view + this.StatusAction.hide(); + }, + + /** + * only show the retry button if the maxTimeMS value hasn't been increased + * yet (first time). + * + * @return {React.Component|null} Retry button or null. + */ + _getTryAgainButton() { + if (this.props.maxTimeMS < RETRY_INC_MAXTIMEMS_VALUE) { + return ( +
    + Try for 1 minute +
    + ); + } + return null; + }, + + render() { + // if sampling state is not `error`, don't show this component + if (this.props.samplingState !== 'error') { + return null; + } + + const sampleTime = ms(this.props.maxTimeMS, {long: true}); + const tryAgainButton = this._getTryAgainButton(); + + return ( +
    +
    +
    + The query took longer than {sampleTime} on the database. + As a safety measure, Compass aborts long-running queries.   + + Learn More + + +
    +
    + {tryAgainButton} +
    + Create New Query +
    +
    +
    + ); + } +}); + +module.exports = ButtonsError; diff --git a/src/internal-packages/schema/lib/component/status-subview/buttons-waiting.jsx b/src/internal-packages/schema/lib/component/status-subview/buttons-waiting.jsx new file mode 100644 index 00000000000..79c9fe205b0 --- /dev/null +++ b/src/internal-packages/schema/lib/component/status-subview/buttons-waiting.jsx @@ -0,0 +1,53 @@ +const React = require('react'); +const SchemaAction = require('../../action'); + +// const debug = require('debug')('mongodb-compass:schema:status-subview:buttons-waiting'); + +const SHOW_WAITING_BUTTONS_TIME_MS = 15000; + +/** + * Component for the entire document list. + */ +const ButtonsWaiting = React.createClass({ + propTypes: { + samplingTimeMS: React.PropTypes.number.isRequired, + samplingState: React.PropTypes.string.isRequired + }, + + onStopPartialButton() { + SchemaAction.stopSampling(); + }, + + render() { + // if in error state, don't show this component + if (this.props.samplingState === 'error') { + return null; + } + + // if below 15 second threshold, hide this component + const buttonStyle = { + visibility: (this.props.samplingTimeMS < SHOW_WAITING_BUTTONS_TIME_MS) ? + 'hidden' : 'visible' + }; + + return ( +
    +
    +
    + Document analysis is taking longer than expected.   + + Learn More + + +
    +
    +
    + Stop and show partial results +
    +
    +
    + ); + } +}); + +module.exports = ButtonsWaiting; diff --git a/src/internal-packages/schema/lib/component/status-subview/index.jsx b/src/internal-packages/schema/lib/component/status-subview/index.jsx new file mode 100644 index 00000000000..b29831939d4 --- /dev/null +++ b/src/internal-packages/schema/lib/component/status-subview/index.jsx @@ -0,0 +1,42 @@ +const React = require('react'); +const ButtonsWaiting = require('./buttons-waiting'); + +const StateMixin = require('reflux-state-mixin'); +const ButtonsError = require('./buttons-error'); + +const SchemaStore = require('../../store'); +const SchemaSteps = require('./steps'); + +// const debug = require('debug')('mongodb-compass:schema:status-subview'); + +/** + * Component for the entire document list. + */ +const SchemaStatusSubview = React.createClass({ + + mixins: [ + StateMixin.connect(SchemaStore) + ], + + render() { + return ( +
    + + + +
    + ); + } + +}); + +module.exports = SchemaStatusSubview; diff --git a/src/internal-packages/schema/lib/component/status-subview/steps.jsx b/src/internal-packages/schema/lib/component/status-subview/steps.jsx new file mode 100644 index 00000000000..cc5b8d9b40c --- /dev/null +++ b/src/internal-packages/schema/lib/component/status-subview/steps.jsx @@ -0,0 +1,91 @@ +const React = require('react'); +const _ = require('lodash'); + +// const debug = require('debug')('mongodb-compass:schema:status-subview:steps'); + +const SHOW_STEPS_TIME_MS = 3000; + +/** + * Component for the entire document list. + */ +const SchemaSteps = React.createClass({ + + propTypes: { + samplingTimeMS: React.PropTypes.number.isRequired, + samplingState: React.PropTypes.string.isRequired + }, + + getInitialState() { + return { + errorState: null + }; + }, + + /** + * remember the last known non-error state internally. + * + * @param {Object} nextProps next props of this component + */ + componentWillReceiveProps(nextProps) { + if (this.props.samplingState !== 'error' && nextProps.samplingState === 'error') { + this.setState({ + errorState: this.props.samplingState + }); + } + }, + + _getSamplingIndicator() { + if (_.contains(['counting', 'sampling'], this.props.samplingState)) { + return 'fa fa-fw fa-spin fa-circle-o-notch'; + } + if (this.props.samplingState === 'analyzing' || + (this.props.samplingState === 'error' && this.state.errorState === 'analyzing')) { + return 'mms-icon-check'; + } + if (this.props.samplingState === 'error' && this.state.errorState === 'sampling') { + return 'fa fa-fw fa-warning'; + } + return 'fa fa-fw'; + }, + + _getAnalyzingIndicator() { + if (this.props.samplingState === 'analyzing') { + return 'fa fa-fw fa-spin fa-circle-o-notch'; + } + if (this.props.samplingState === 'complete') { + return 'mms-icon-check'; + } + if (this.props.samplingState === 'error' && this.state.errorState === 'analyzing') { + return 'fa fa-fw fa-warning'; + } + return 'fa fa-fw'; + }, + + render() { + // if below 3 second threshold, don't show this component + const style = { + visibility: (this.props.samplingTimeMS < SHOW_STEPS_TIME_MS) ? + 'hidden' : 'visible' + }; + + const samplingIndicator = this._getSamplingIndicator(); + const analyzingIndicator = this._getAnalyzingIndicator(); + + return ( +
    +
      +
    • + + Sampling Collection +
    • +
    • + + Analyzing Documents +
    • +
    +
    + ); + } +}); + +module.exports = SchemaSteps; diff --git a/src/internal-packages/schema/lib/component/type.jsx b/src/internal-packages/schema/lib/component/type.jsx new file mode 100644 index 00000000000..9c952058a83 --- /dev/null +++ b/src/internal-packages/schema/lib/component/type.jsx @@ -0,0 +1,141 @@ +const React = require('react'); +const _ = require('lodash'); +const ReactTooltip = require('react-tooltip'); +const numeral = require('numeral'); + +// const debug = require('debug')('mongodb-compass:schema:type'); + +/** + * The full schema component class. + */ +const TYPE_CLASS = 'schema-field-wrapper'; + +/** + * Component for the entire document list. + */ +const Type = React.createClass({ + propTypes: { + name: React.PropTypes.string.isRequired, // type name, e.g. `Number` + types: React.PropTypes.array, // array of types (for subtypes) + activeType: React.PropTypes.any, // currently active type overall + self: React.PropTypes.object, // a reference to this type + probability: React.PropTypes.number.isRequired, // length of bar + renderType: React.PropTypes.func.isRequired, // callback function + showSubTypes: React.PropTypes.bool.isRequired // should subtypes be rendered? + }, + + /** + * The type bar corresponding to this Type was clicked. Execute the + * callback passed in from the parent (either or component + * in case of subtypes). + * + * @param {Object} e click event (need to stop propagation) + */ + typeClicked(e) { + e.stopPropagation(); + this.props.renderType(this.props.self); + }, + + /** + * A subtype was clicked (in case of an Array type). Pass up to the Field + * so the entire type bar can be re-rendered. + * + * @param {Object} subtype The subtype object + */ + subTypeClicked(subtype) { + this.props.renderType(subtype); + }, + + /** + * returns a list of subtype components for Array types. + * + * @return {ReactFragment} array of components for subtype bar + */ + _getArraySubTypes() { + // only worry about subtypes if the type is Array + if (this.props.name !== 'Array') { + return null; + } + // only show one level of subtypes, further Arrays inside Arrays don't + // render their subtypes. + if (!this.props.showSubTypes) { + return null; + } + // sort the subtypes same as types (by probability, undefined last) + const subtypes = _.sortBy(this.props.types, (type) => { + if (type.name === 'Undefined') { + return -Infinity; + } + return type.probability; + }).reverse(); + // is one of the subtypes active? + const activeSubType = _.find(subtypes, this.props.activeType); + // generate the react fragment of subtypes, pass in showSubTypes=false + // to stop the recursion after one step. + const typeList = subtypes.map((subtype) => { + return ( + + ); + }); + return ( +
    +
    + {typeList} +
    +
    + ); + }, + + /** + * Render a single type + * + * @returns {React.Component} A react component for a single type, + * possibly with subtypes included for Array type. + */ + render() { + const type = this.props.name.toLowerCase(); + let cls = `${TYPE_CLASS} schema-field-type-${type}`; + if (this.props.activeType === this.props.self) { + cls += ' active'; + } + const handleClick = type === 'undefined' ? null : this.typeClicked; + const percentage = (this.props.probability * 100) + '%'; + const style = { + width: percentage + }; + const subtypes = this._getArraySubTypes(); + const label = {this.props.name}; + const tooltipText = `${this.props.name} (${numeral(this.props.probability).format('0%')})`; + const tooltipOptions = { + 'data-tip': tooltipText, + 'data-effect': 'solid', + 'data-border': true, + 'data-place': this.props.showSubTypes ? 'top' : 'bottom' + }; + tooltipOptions['data-offset'] = this.props.showSubTypes ? + '{"top": -15, "left": 0}' : '{"top": 10, "left": 0}'; + return ( +
    + + {this.props.showSubTypes ? label : null} +
    + {subtypes} + {this.props.showSubTypes ? null : label} +
    + ); + } +}); + +module.exports = Type; diff --git a/src/internal-packages/schema/lib/component/unique.jsx b/src/internal-packages/schema/lib/component/unique.jsx new file mode 100644 index 00000000000..af7be550a25 --- /dev/null +++ b/src/internal-packages/schema/lib/component/unique.jsx @@ -0,0 +1,111 @@ +const app = require('ampersand-app'); +const React = require('react'); +const _ = require('lodash'); +const NativeListener = require('react-native-listener'); +const hasDistinctValue = require('../../../query/lib/util').hasDistinctValue; + +// const debug = require('debug')('mongodb-compass:minichart:unique'); + +const ValueBubble = React.createClass({ + propTypes: { + fieldName: React.PropTypes.string.isRequired, + value: React.PropTypes.any.isRequired, + query: React.PropTypes.any + }, + + onBubbleClicked(e) { + const QueryAction = app.appRegistry.getAction('QueryAction'); + const action = e.shiftKey ? + QueryAction.toggleDistinctValue : QueryAction.setValue; + action({ + field: this.props.fieldName, + value: this.props.value, + unsetIfSet: true + }); + }, + + render() { + const value = this.props.value; + const selectedClass = hasDistinctValue(this.props.query, value) ? + 'selected' : 'unselected'; + return ( +
  1. + + {value.toString()} + +
  2. + ); + } +}); + +/* eslint react/no-multi-comp: 0 */ +const UniqueMinichart = React.createClass({ + propTypes: { + fieldName: React.PropTypes.string.isRequired, + type: React.PropTypes.object.isRequired, + width: React.PropTypes.number, + query: React.PropTypes.any + }, + + getInitialState() { + return { + sample: _.sample(this.props.type.values, 20) + }; + }, + + onRefresh(e) { + e.stopPropagation(); + e.preventDefault(); + this.setState({ + sample: _.sample(this.props.type.values, 20) + }); + }, + + /** + * Render a single field; + * + * @returns {React.Component} A react component for a single field + */ + render() { + if (!this.props.type.values) { + return
    ; + } + const sample = this.state.sample || []; + const fieldName = this.props.fieldName.toLowerCase(); + const typeName = this.props.type.name.toLowerCase(); + const randomValueList = sample.map((value) => { + return ( + + ); + }); + const style = { + width: this.props.width + }; + + return ( +
    +
    +
    + + + + + +
    +
    +
      + {randomValueList} +
    +
    +
    +
    + ); + } +}); + +module.exports = UniqueMinichart; diff --git a/src/internal-packages/schema/lib/d3/boolean.js b/src/internal-packages/schema/lib/d3/boolean.js new file mode 100644 index 00000000000..18cc218d427 --- /dev/null +++ b/src/internal-packages/schema/lib/d3/boolean.js @@ -0,0 +1,88 @@ +/* eslint camelcase: 0 */ +const d3 = require('d3'); +const _ = require('lodash'); +const few = require('./few'); +const shared = require('./shared'); +// const debug = require('debug')('mongodb-compass:minicharts:boolean'); + + +const minicharts_d3fns_boolean = function() { + // --- beginning chart setup --- + let width = 400; + let height = 100; + const options = { + view: null + }; + const fewChart = few(); + const margin = shared.margin; + // --- end chart setup --- + + function chart(selection) { + selection.each(function(data) { + const el = d3.select(this); + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // group by true/false + const grouped = _(data) + .groupBy(function(d) { + return d; + }) + .defaults({ + false: [], + true: [] + }) + .map(function(v, k) { + return { + label: k, + value: k === 'true', + count: v.length + }; + }) + .sortByOrder('label', [false]) // order: false, true + .value(); + + fewChart + .width(innerWidth) + .height(innerHeight) + .options(options); + + const g = el.selectAll('g').data([grouped]); + + // append g element if it doesn't exist yet + g.enter() + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + g.call(fewChart); + }); + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + return chart; +}; + +module.exports = minicharts_d3fns_boolean; diff --git a/src/internal-packages/schema/lib/d3/coordinates.js b/src/internal-packages/schema/lib/d3/coordinates.js new file mode 100644 index 00000000000..4d99fdba0ca --- /dev/null +++ b/src/internal-packages/schema/lib/d3/coordinates.js @@ -0,0 +1,489 @@ +/* eslint camelcase: 0 */ +const d3 = require('d3'); +const _ = require('lodash'); +const shared = require('./shared'); +const app = require('ampersand-app'); +const turfDistance = require('turf-distance'); +const turfPoint = require('turf-point'); +const turfDestination = require('turf-destination'); + +// const metrics = require('mongodb-js-metrics')(); +// const debug = require('debug')('mongodb-compass:minicharts:geo'); + +const QueryAction = app.appRegistry.getAction('QueryAction'); + +const SELECTED_COLOR = '#F68A1E'; +const UNSELECTED_COLOR = '#43B1E5'; +const CONTROL_COLOR = '#ed271c'; +const TOKEN = 'pk.eyJ1IjoibW9uZ29kYi1jb21wYXNzIiwiYSI6ImNpbWUxZjNudjAwZTZ0emtrczByanZ4MzIifQ.6Mha4zoflraopcZKOLSpYQ'; + +const minicharts_d3fns_geo = function() { + // --- beginning chart setup --- + let width = 400; + let height = 100; + let map = null; + let circleControl; + let mapboxgl; + + const options = { + view: null + }; + + let circleCenter; + let circleOuter; // control points + let mileDistance; + let circleSelected = false; // have we completed the circle? + let svg; + let render; + let dots; + + const margin = shared.margin; + + function CircleSelector(container) { + let dragging = false; // track whether we are dragging + + // we expose events on our component + const dispatch = d3.dispatch('update', 'clear'); + + // this will likely be overriden by leaflet projection + let project; + let unproject; + + // const project = d3.geo.mercator(); + // const unproject = d3.geo.mercator().invert; + + let update; + + function querybuilder() { + if (circleCenter && circleOuter) { + mileDistance = turfDistance( + turfPoint([circleCenter.lng, circleCenter.lat]), + turfPoint([circleOuter.lng, circleOuter.lat]), + 'miles' + ); + QueryAction.setGeoWithinValue({ + field: options.fieldName, + center: [circleCenter.lng, circleCenter.lat], + radius: mileDistance / 3963.2 + }); + } else { + QueryAction.clearValue({ + field: options.fieldName + }); + } + } + + function distance(ll0, ll1) { + const p0 = project(ll0); + const p1 = project(ll1); + const dist = Math.sqrt((p1.x - p0.x) * (p1.x - p0.x) + (p1.y - p0.y) * (p1.y - p0.y)); + return dist; + } + + const drag = d3.behavior.drag() + .on('drag', function(d, i) { + if (circleSelected) { + dragging = true; + const p = d3.mouse(container.node()); + const ll = unproject([p[0], p[1]]); + if (i) { + circleOuter = ll; + } else { + const dlat = circleCenter.lat - ll.lat; + const dlng = circleCenter.lng - ll.lng; + circleCenter = ll; + circleOuter.lat -= dlat; + circleOuter.lng -= dlng; + } + update(); + querybuilder(); + } else { + return; + } + }) + .on('dragend', function() { + // kind of a dirty hack... + setTimeout(function() { + dragging = false; + querybuilder(); + }, 100); + }); + + function clear(dontUpdate) { + circleCenter = null; + circleOuter = null; + circleSelected = false; + container.selectAll('circle.lasso').remove(); + container.selectAll('circle.control').remove(); + container.selectAll('line.lasso').remove(); + dispatch.clear(); + if (!dontUpdate) { + querybuilder(); + } + return; + } + + this.clear = clear; + + update = function(g) { + if (g) { + container = g; + } + if (!circleCenter || !circleOuter) return; + const dist = distance(circleCenter, circleOuter); + const circleLasso = container.selectAll('circle.lasso').data([dist]); + circleLasso.enter().append('circle') + .classed('lasso', true) + .style({ + stroke: SELECTED_COLOR, + 'stroke-width': 2, + fill: SELECTED_COLOR, + 'fill-opacity': 0.1 + }); + + circleLasso + .attr({ + cx: project(circleCenter).x, + cy: project(circleCenter).y, + r: dist + }); + + const line = container.selectAll('line.lasso').data([circleOuter]); + line.enter().append('line') + .classed('lasso', true) + .style({ + stroke: CONTROL_COLOR, + 'stroke-dasharray': '2 2' + }); + + line.attr({ + x1: project(circleCenter).x, + y1: project(circleCenter).y, + x2: project(circleOuter).x, + y2: project(circleOuter).y + }); + + const controls = container.selectAll('circle.control') + .data([circleCenter, circleOuter]); + controls.enter().append('circle') + .classed('control', true) + .style({ + 'cursor': 'move' + }); + + controls.attr({ + cx: function(d) { return project(d).x; }, + cy: function(d) { return project(d).y; }, + r: 5, + stroke: CONTROL_COLOR, + fill: CONTROL_COLOR, + 'fill-opacity': 0.7 + }) + .call(drag) + .on('mousedown', function() { + map.dragPan.disable(); + }) + .on('mouseup', function() { + map.dragPan.enable(); + }); + + dispatch.update(); + }; // end update() + this.update = update; + + function setCircle(centerLL, radiusMiles) { + const pCenter = turfPoint([centerLL[0], centerLL[1]]); + const pOuter = turfDestination(pCenter, radiusMiles, 45, 'miles'); + circleCenter = mapboxgl.LngLat.convert(pCenter.geometry.coordinates); + circleOuter = mapboxgl.LngLat.convert(pOuter.geometry.coordinates); + circleSelected = true; + update(); + } + this.setCircle = setCircle; + + container.on('mousedown.circle', function() { + if (!d3.event.shiftKey) return; + if (dragging && circleSelected) return; + if (!dragging && circleSelected) { + // reset and remove circle + clear(); + return; + } + + map.dragPan.disable(); + const p = d3.mouse(this); + const ll = unproject([p[0], p[1]]); + + if (!circleCenter) { + // We set the center to the initial click + circleCenter = ll; + circleOuter = ll; + } + update(); + }); + + container.on('mousemove.circle', function() { + if (circleSelected || !circleCenter) return; + // we draw a guideline for where the next point would go. + const p = d3.mouse(this); + const ll = unproject([p[0], p[1]]); + circleOuter = ll; + update(); + querybuilder(); + }); + + container.on('mouseup.circle', function() { + if (dragging && circleSelected) return; + + map.dragPan.enable(); + + const p = d3.mouse(this); + const ll = unproject([p[0], p[1]]); + + if (circleCenter) { + if (!circleSelected) { + circleOuter = ll; + circleSelected = true; + querybuilder(); + } + } + }); + + this.projection = function(val) { + if (!val) return project; + project = val; + return this; + }; + + this.inverseProjection = function(val) { + if (!val) return unproject; + unproject = val; + return this; + }; + + this.distance = function(ll) { + if (!ll) ll = circleOuter; + return distance(circleCenter, ll); + }; + + d3.rebind(this, dispatch, 'on'); + return this; + } + + function disableMapsFeature() { + // disable in preferences and persist + app.preferences.save('googleMaps', false); + delete window.google; + // options.view.parent.render(); + } + + function loadMapBoxScript(done) { + const script = document.createElement('script'); + script.setAttribute('type', 'text/javascript'); + script.src = 'https://api.tiles.mapbox.com/mapbox-gl-js/v0.15.0/mapbox-gl.js'; + script.onerror = function() { + done('Error ocurred while loading MapBox script.'); + }; + script.onload = function() { + done(null, window.mapboxgl); + }; + document.getElementsByTagName('head')[0].appendChild(script); + } + + + function selectFromQuery() { + if (options.query === undefined) { + circleControl.clear(true); + } else { + const center = options.query.$geoWithin.$centerSphere[0]; + const radius = options.query.$geoWithin.$centerSphere[1] * 3963.2; + // only redraw if the center/radius is different to the existing circle + if (radius !== mileDistance || !_.isEqual(center, [circleCenter.lng, circleCenter.lat])) { + circleControl.setCircle(center, radius); + } + } + } + // --- end chart setup --- + + function chart(selection) { + // load mapbox script + if (!window.mapboxgl) { + loadMapBoxScript(function(err) { + if (err) { + disableMapsFeature(); + } else { + chart.call(this, selection); + } + }); + return; + } + mapboxgl = window.mapboxgl; + + selection.each(function(data) { + function getLL(d) { + if (d instanceof mapboxgl.LngLat) return d; + return new mapboxgl.LngLat(+d[0], +d[1]); + } + function project(d) { + return map.project(getLL(d)); + } + + const el = d3.select(this); + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // append inner div once + const innerDiv = el.selectAll('div.map').data([null]); + innerDiv.enter().append('div') + .attr('class', 'map'); + + innerDiv + .style({ + width: innerWidth + 'px', + height: innerHeight + 'px', + padding: margin.top + 'px ' + margin.right + 'px ' + margin.bottom + + 'px ' + margin.left + 'px;' + }); + + // append info sprinkle + el.selectAll('i.help').data([null]).enter().append('i') + .classed('help', true) + .attr('data-hook', 'schema-geo-query-builder'); + + // compute bounds from data + const bounds = new mapboxgl.LngLatBounds(); + _.each(data, function(d) { + bounds.extend(getLL(d)); + }); + + // create the map once + if (!map) { + mapboxgl.accessToken = TOKEN; + map = new mapboxgl.Map({ + container: innerDiv[0][0], + // not allowed to whitelabel the map without enterprise license + // attributionControl: false, + style: 'mapbox://styles/mapbox/light-v8', + center: bounds.getCenter() + }); + map.dragPan.enable(); + map.scrollZoom.enable(); + map.boxZoom.disable(); + + // Add zoom and rotation controls to the map + map.addControl(new mapboxgl.Navigation({position: 'top-left'})); + + // Setup our svg layer that we can manipulate with d3 + const container = map.getCanvasContainer(); + svg = d3.select(container).append('svg'); + + circleControl = new CircleSelector(svg) + .projection(project) + .inverseProjection(function(a) { + return map.unproject({x: a[0], y: a[1]}); + }); + + // when lasso changes, update point selections + circleControl.on('update', function() { + svg.selectAll('circle.dot').style({ + fill: function(d) { + const thisDist = circleControl.distance(d); + const circleDist = circleControl.distance(); + if (thisDist < circleDist) { + return SELECTED_COLOR; + } + return UNSELECTED_COLOR; + } + }); + }); + circleControl.on('clear', function() { + svg.selectAll('circle.dot').style('fill', UNSELECTED_COLOR); + }); + + /* eslint no-inner-declarations: 0 */ + render = function() { + // update points + dots.attr({ + cx: function(d) { + const x = project(d).x; + return x; + }, + cy: function(d) { + const y = project(d).y; + return y; + } + }); + // update circle + circleControl.update(svg); + }; + + // re-render our visualization whenever the view changes + map.on('viewreset', function() { + render(); + }); + map.on('move', function() { + render(); + }); + + _.defer(function() { + map.resize(); + map.fitBounds(bounds, { + linear: true, + padding: 20 + }); + }); + } // end if (!map) ... + + // draw data points + dots = svg.selectAll('circle.dot') + .data(data); + dots.enter().append('circle').classed('dot', true) + .attr('r', 4) + .style({ + fill: UNSELECTED_COLOR, + stroke: 'white', + 'stroke-opacity': 0.6, + 'stroke-width': 1 + }); + + selectFromQuery(); + render(); + }); // end selection.each() + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + chart.geoSelection = function(value) { + if (!value) { + circleControl.clear(); + return; + } + circleControl.setCircle(value[0], value[1]); + }; + + return chart; +}; + +module.exports = minicharts_d3fns_geo; diff --git a/src/internal-packages/schema/lib/d3/d3-tip.js b/src/internal-packages/schema/lib/d3/d3-tip.js new file mode 100644 index 00000000000..d84c2514f9d --- /dev/null +++ b/src/internal-packages/schema/lib/d3/d3-tip.js @@ -0,0 +1,349 @@ +/* eslint no-use-before-define: 0, one-var: 0, no-else-return: 0, no-unused-vars: 0, eqeqeq: 0, no-shadow: 0, yoda: 0, consistent-return: 0, one-var: 0, camelcase: 0 */ +// d3.tip +// Copyright (c) 2013 Justin Palmer +// +// Tooltips for d3.js SVG visualizations + +(function(root, factory) { + if (typeof module === 'object' && module.exports) { + // CommonJS + module.exports = function(d3) { + d3.tip = factory(d3); + return d3.tip; + }; + } else { + // Browser global. + root.d3.tip = factory(root.d3); + } +}(this, function(d3) { + // Public - contructs a new tooltip + // + // Returns a tip + return function() { + var direction = d3_tip_direction; + var offset = d3_tip_offset; + var html = d3_tip_html; + var node = initNode(); + var svg = null; + var point = null; + var target = null; + + function tip(vis) { + svg = getSVGNode(vis); + if (!svg) { + return; + } + point = svg.createSVGPoint(); + document.body.appendChild(node); + } + + // Public - show the tooltip on the screen + // + // Returns a tip + tip.show = function() { + var args = Array.prototype.slice.call(arguments); + if (args[args.length - 1] instanceof SVGElement) { + target = args.pop(); + } + + var content = html.apply(this, args), + poffset = offset.apply(this, args), + dir = direction.apply(this, args), + nodel = getNodeEl(), + i = directions.length, + coords, + scrollTop = document.documentElement.scrollTop || document.body.scrollTop, + scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft; + + nodel.html(content) + .style({ + opacity: 1, + 'pointer-events': 'all' + }); + + while (i--) { + nodel.classed(directions[i], false); + } + coords = direction_callbacks.get(dir).apply(this); + nodel.classed(dir, true).style({ + top: (coords.top + poffset[0] + scrollTop).toString() + 'px', + left: (coords.left + poffset[1] + scrollLeft).toString() + 'px' + }); + + return tip; + }; + + // Public - hide the tooltip + // + // Returns a tip + tip.hide = function() { + var nodel = getNodeEl(); + nodel.style({ + opacity: 0, + 'pointer-events': 'none' + }); + return tip; + }; + + // Public: Proxy attr calls to the d3 tip container. Sets or gets attribute value. + // + // n - name of the attribute + // v - value of the attribute + // + // Returns tip or attribute value + tip.attr = function(n, v) { + if (arguments.length < 2 && typeof n === 'string') { + return getNodeEl().attr(n); + } else { + var args = Array.prototype.slice.call(arguments); + d3.selection.prototype.attr.apply(getNodeEl(), args); + } + + return tip; + }; + + // Public: Proxy style calls to the d3 tip container. Sets or gets a style value. + // + // n - name of the property + // v - value of the property + // + // Returns tip or style property value + tip.style = function(n, v) { + if (arguments.length < 2 && typeof n === 'string') { + return getNodeEl().style(n); + } else { + var args = Array.prototype.slice.call(arguments); + d3.selection.prototype.style.apply(getNodeEl(), args); + } + + return tip; + }; + + // Public: Set or get the direction of the tooltip + // + // v - One of n(north), s(south), e(east), or w(west), nw(northwest), + // sw(southwest), ne(northeast) or se(southeast) + // + // Returns tip or direction + tip.direction = function(v) { + if (!arguments.length) { + return direction; + } + direction = v == null ? v : d3.functor(v); + + return tip; + }; + + // Public: Sets or gets the offset of the tip + // + // v - Array of [x, y] offset + // + // Returns offset or + tip.offset = function(v) { + if (!arguments.length) { + return offset; + } + offset = v == null ? v : d3.functor(v); + + return tip; + }; + + // Public: sets or gets the html value of the tooltip + // + // v - String value of the tip + // + // Returns html value or tip + tip.html = function(v) { + if (!arguments.length) { + return html; + } + html = v == null ? v : d3.functor(v); + + return tip; + }; + + // Public: destroys the tooltip and removes it from the DOM + // + // Returns a tip + tip.destroy = function() { + if (node) { + getNodeEl().remove(); + node = null; + } + return tip; + }; + + function d3_tip_direction() { + return 'n'; + } + function d3_tip_offset() { + return [0, 0]; + } + function d3_tip_html() { + return ' '; + } + + var direction_callbacks = d3.map({ + n: direction_n, + s: direction_s, + e: direction_e, + w: direction_w, + nw: direction_nw, + ne: direction_ne, + sw: direction_sw, + se: direction_se + }), + + directions = direction_callbacks.keys(); + + function direction_n() { + var bbox = getScreenBBox(); + return { + top: bbox.n.y - node.offsetHeight, + left: bbox.n.x - node.offsetWidth / 2 + }; + } + + function direction_s() { + var bbox = getScreenBBox(); + return { + top: bbox.s.y, + left: bbox.s.x - node.offsetWidth / 2 + }; + } + + function direction_e() { + var bbox = getScreenBBox(); + return { + top: bbox.e.y - node.offsetHeight / 2, + left: bbox.e.x + }; + } + + function direction_w() { + var bbox = getScreenBBox(); + return { + top: bbox.w.y - node.offsetHeight / 2, + left: bbox.w.x - node.offsetWidth + }; + } + + function direction_nw() { + var bbox = getScreenBBox(); + return { + top: bbox.nw.y - node.offsetHeight, + left: bbox.nw.x - node.offsetWidth + }; + } + + function direction_ne() { + var bbox = getScreenBBox(); + return { + top: bbox.ne.y - node.offsetHeight, + left: bbox.ne.x + }; + } + + function direction_sw() { + var bbox = getScreenBBox(); + return { + top: bbox.sw.y, + left: bbox.sw.x - node.offsetWidth + }; + } + + function direction_se() { + var bbox = getScreenBBox(); + return { + top: bbox.se.y, + left: bbox.e.x + }; + } + + function initNode() { + var node = d3.select(document.createElement('div')); + node.style({ + position: 'absolute', + top: 0, + opacity: 0, + 'pointer-events': 'none', + 'box-sizing': 'border-box' + }); + + return node.node(); + } + + function getSVGNode(el) { + el = el.node(); + if (!el) { + return; + } + if (el.tagName.toLowerCase() === 'svg') { + return el; + } + + return el.ownerSVGElement; + } + + function getNodeEl() { + if (node === null) { + node = initNode(); + // re-add node to DOM + document.body.appendChild(node); + } + return d3.select(node); + } + + // Private - gets the screen coordinates of a shape + // + // Given a shape on the screen, will return an SVGPoint for the directions + // n(north), s(south), e(east), w(west), ne(northeast), se(southeast), nw(northwest), + // sw(southwest). + // + // +-+-+ + // | | + // + + + // | | + // +-+-+ + // + // Returns an Object {n, s, e, w, nw, sw, ne, se} + function getScreenBBox() { + var targetel = target || d3.event.target; + + while ('undefined' === typeof targetel.getScreenCTM && 'undefined' === targetel.parentNode) { + targetel = targetel.parentNode; + } + + var bbox = {}, + matrix = targetel.getScreenCTM(), + tbbox = targetel.getBBox(), + width = tbbox.width, + height = tbbox.height, + x = tbbox.x, + y = tbbox.y; + + point.x = x; + point.y = y; + bbox.nw = point.matrixTransform(matrix); + point.x += width; + bbox.ne = point.matrixTransform(matrix); + point.y += height; + bbox.se = point.matrixTransform(matrix); + point.x -= width; + bbox.sw = point.matrixTransform(matrix); + point.y -= height / 2; + bbox.w = point.matrixTransform(matrix); + point.x += width; + bbox.e = point.matrixTransform(matrix); + point.x -= width / 2; + point.y -= height / 2; + bbox.n = point.matrixTransform(matrix); + point.y += height; + bbox.s = point.matrixTransform(matrix); + + return bbox; + } + + return tip; + }; +})); diff --git a/src/internal-packages/schema/lib/d3/date.js b/src/internal-packages/schema/lib/d3/date.js new file mode 100644 index 00000000000..b3816885f34 --- /dev/null +++ b/src/internal-packages/schema/lib/d3/date.js @@ -0,0 +1,429 @@ +/* eslint no-use-before-define: 0, camelcase:0 */ +const app = require('ampersand-app'); +const d3 = require('d3'); +const _ = require('lodash'); +const $ = require('jquery'); +const moment = require('moment'); +const shared = require('./shared'); +const many = require('./many'); +const inValueRange = require('../../../query/lib/util').inValueRange; + +// const debug = require('debug')('mongodb-compass:minicharts:date'); + +require('./d3-tip')(d3); + +const QueryAction = app.appRegistry.getAction('QueryAction'); + +function generateDefaults(n) { + const doc = {}; + _.each(_.range(n), function(d) { + doc[d] = []; + }); + return doc; +} + +function extractTimestamp(d) { + return d._bsontype === 'ObjectID' ? d.getTimestamp() : d; +} + +const minicharts_d3fns_date = function() { + // --- beginning chart setup --- + let width = 400; + let height = 100; + let el; + let lastNonShiftRangeValue = null; + + const upperRatio = 2.5; + const upperMargin = 20; + const options = {}; + + const weekdayLabels = moment.weekdays(); + + // A formatter for dates + const format = d3.time.format('%Y-%m-%d %H:%M:%S'); + + const margin = shared.margin; + const barcodeX = d3.time.scale(); + + // set up tooltips + const tip = d3.tip() + .attr('class', 'd3-tip') + .html(function(d) { + return d.label; + }) + .direction('n') + .offset([-9, 0]); + + const brush = d3.svg.brush() + .x(barcodeX) + // .on('brushstart', brushstart) + .on('brush', brushed) + .on('brushend', brushend); + + // function brushstart(clickedLine) { + // // remove selections and half selections + // const lines = d3.selectAll(options.view.queryAll('.selectable')); + // lines.classed('selected', function() { + // return this === clickedLine; + // }); + // lines.classed('unselected', function() { + // return this !== clickedLine; + // }); + // } + + function handleDrag() { + const lines = el.selectAll('line.selectable'); + const numSelected = el.selectAll('line.selectable.selected').length; + const s = brush.extent(); + + // add `unselected` class to all elements + lines.classed('unselected', true); + lines.classed('selected', false); + + // get elements within the brush + const selected = lines.filter(function(d) { + return s[0] <= d.ts && d.ts <= s[1]; + }); + + // add `selected` class and remove `unselected` class + selected.classed('selected', true); + selected.classed('unselected', false); + + if (numSelected !== selected[0].length) { + // number of selected items has changed, trigger querybuilder event + if (selected[0].length === 0) { + // clear value + QueryAction.clearValue({ + field: options.fieldName + }); + return; + } + } + + const minValue = _.min(selected.data(), function(d) { + return d.ts; + }); + const maxValue = _.max(selected.data(), function(d) { + return d.ts; + }); + + if (_.isEqual(minValue.ts, maxValue.ts)) { + // if values are the same, single equality query + QueryAction.setValue({ + field: options.fieldName, + value: minValue.value + }); + return; + } + // binned values, build range query with $gte and $lte + QueryAction.setRangeValues({ + field: options.fieldName, + min: minValue.value, + max: maxValue.value, + maxInclusive: true + }); + } + + function brushed() { + handleDrag(); + } + + function brushend() { + d3.select(this).call(brush.clear()); + } + + + function handleMouseDown(d) { + if (d3.event.shiftKey && lastNonShiftRangeValue) { + const minVal = d.ts < lastNonShiftRangeValue.ts ? d.value : lastNonShiftRangeValue.value; + const maxVal = d.ts > lastNonShiftRangeValue.ts ? d.value : lastNonShiftRangeValue.value; + QueryAction.setRangeValues({ + field: options.fieldName, + min: minVal, + max: maxVal, + maxInclusive: true + }); + } else { + // remember non-shift value so that range can be extended with shift + lastNonShiftRangeValue = d; + QueryAction.setValue({ + field: options.fieldName, + value: d.value, + unsetIfSet: true + }); + } + + const parent = $(this).closest('.minichart'); + const background = parent.find('g.brush > rect.background')[0]; + const brushNode = parent.find('g.brush')[0]; + const start = barcodeX.invert(d3.mouse(background)[0]); + + const w = d3.select(window) + .on('mousemove', mousemove) + .on('mouseup', mouseup); + + d3.event.preventDefault(); // disable text dragging + + function mousemove() { + const extent = [start, barcodeX.invert(d3.mouse(background)[0])]; + d3.select(brushNode).call(brush.extent(_.sortBy(extent))); + brushed.call(brushNode); + } + + function mouseup() { + // bar.classed('selected', true); + w.on('mousemove', null).on('mouseup', null); + brushend.call(brushNode); + } + } + + function selectFromQuery(lines) { + if (options.query === undefined) { + lines.classed('unselected', false); + lines.classed('selected', false); + lines.classed('half', false); + return; + } + lines.each(function(d) { + d.inRange = inValueRange(options.query, d); + }); + + lines.classed('selected', function(d) { + return d.inRange === 'yes'; + }); + lines.classed('unselected', function(d) { + return d.inRange === 'no'; + }); + } + + + function chart(selection) { + selection.each(function(data) { + const values = data.map(function(d) { + const ts = extractTimestamp(d); + return { + label: format(ts), + ts: ts, + value: d, + count: 1 + }; + }); + + // without `-1` the tooltip won't always trigger on the rightmost value + const innerWidth = width - margin.left - margin.right - 1; + const innerHeight = height - margin.top - margin.bottom; + el = d3.select(this); + + const barcodeTop = Math.floor(innerHeight / 2 + 15); + const barcodeBottom = Math.floor(innerHeight - 10); + + const upperBarBottom = innerHeight / 2 - 20; + + barcodeX + .domain(d3.extent(values, function(d) { + return d.ts; + })) + .range([0, innerWidth]); + + // group by weekdays + const weekdays = _(values) + .groupBy(function(d) { + return moment(d.ts).weekday(); + }) + .defaults(generateDefaults(7)) + .map(function(d, i) { + return { + label: weekdayLabels[i], + count: d.length + }; + }) + .value(); + + // group by hours + const hourLabels = d3.range(24); + const hours = _(values) + .groupBy(function(d) { + return d.ts.getHours(); + }) + .defaults(generateDefaults(24)) + .map(function(d, i) { + return { + label: hourLabels[i] + ':00', + count: d.length + }; + }) + .value(); + el.call(tip); + + const g = el.selectAll('g').data([data]); + + // append g element if it doesn't exist yet + const gEnter = g.enter() + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + gEnter.append('g') + .attr('class', 'weekday') + .append('text') + .attr('class', 'date-icon fa-fw') + .attr('x', 0) + .attr('dx', '-0.6em') + .attr('y', 0) + .attr('dy', '1em') + .attr('text-anchor', 'end') + .attr('font-family', 'FontAwesome') + .text('\uf133'); + + gEnter.append('g') + .attr('class', 'hour') + .append('text') + .attr('class', 'date-icon fa-fw') + .attr('x', 0) + .attr('dx', '-0.6em') + .attr('y', 0) + .attr('dy', '1em') + .attr('text-anchor', 'end') + .attr('font-family', 'FontAwesome') + .text('\uf017'); + + el.select('.hour') + .attr('transform', 'translate(' + (innerWidth / (upperRatio + 1) + upperMargin) + ', 0)'); + + const gBrush = g.selectAll('.brush').data([0]); + gBrush.enter().append('g') + .attr('class', 'brush') + .call(brush) + .selectAll('rect') + .attr('y', barcodeTop) + .attr('height', barcodeBottom - barcodeTop); + + gEnter.append('g') + .attr('class', 'line-container'); + + const lines = g.select('.line-container').selectAll('.selectable').data(values, function(d) { + return d.ts; + }); + + lines.enter().append('line') + .attr('class', 'line selectable') + .style('opacity', function() { + return lines.size() > 200 ? 0.3 : 1.0; + }) + .on('mouseover', tip.show) + .on('mouseout', tip.hide) + .on('mousedown', handleMouseDown); + + // disabling direct onClick handler in favor of click-drag + // .on('click', handleClick); + + lines + .attr('y1', barcodeTop) + .attr('y2', barcodeBottom) + .attr('x2', function(d) { + return barcodeX(d.ts); + }) + .attr('x1', function(d) { + return barcodeX(d.ts); + }); + + lines.exit().remove(); + + // unset the non-shift clicked bar marker if the query is empty + if (options.query === undefined) { + lastNonShiftRangeValue = null; + } + + // paint remaining lines in correct color + el.selectAll('line.selectable').call(selectFromQuery); + + const text = g.selectAll('.text') + .data(barcodeX.domain()); + + text.enter().append('text') + .attr('class', 'text') + .attr('dy', '0.75em') + .attr('y', barcodeBottom + 5); + + text + .attr('x', function(d, i) { + return i * innerWidth; + }) + .attr('text-anchor', function(d, i) { + return i ? 'end' : 'start'; + }) + .text(function(d, i) { + if (format(barcodeX.domain()[0]) === format(barcodeX.domain()[1])) { + if (i === 0) { + return 'inserted: ' + format(d); + } + } else { + return (i ? 'last: ' : 'first: ') + format(d); + } + }); + + text.exit().remove(); + + let chartWidth = innerWidth / (upperRatio + 1) - upperMargin; + const weekdayContainer = g.select('g.weekday').data([weekdays]); + const manyDayChart = many() + .width(chartWidth) + .height(upperBarBottom) + .options({ + selectable: false, + bgbars: true, + labels: { + 'text-anchor': 'middle', + text: function(d) { + return d.label[0]; + } + } + }); + weekdayContainer.call(manyDayChart); + + chartWidth = innerWidth / (upperRatio + 1) * upperRatio - upperMargin; + const hourContainer = g.select('g.hour').data([hours]); + const manyHourChart = many() + .width(chartWidth) + .height(upperBarBottom) + .options({ + selectable: false, + bgbars: true, + labels: { + text: function(d, i) { + return i % 6 === 0 || i === 23 ? d.label : ''; + } + } + }); + hourContainer.call(manyHourChart); + }); + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + return chart; +}; + + +module.exports = minicharts_d3fns_date; diff --git a/src/internal-packages/schema/lib/d3/few.js b/src/internal-packages/schema/lib/d3/few.js new file mode 100644 index 00000000000..75f3a492ee0 --- /dev/null +++ b/src/internal-packages/schema/lib/d3/few.js @@ -0,0 +1,255 @@ +/* eslint no-use-before-define: 0, camelcase: 0 */ +const app = require('ampersand-app'); +const d3 = require('d3'); +const $ = require('jquery'); +const _ = require('lodash'); +const shared = require('./shared'); +const tooltipTemplate = require('./tooltip.jade'); +const hasDistinctValue = require('../../../query/lib/util').hasDistinctValue; + +// const debug = require('debug')('mongodb-compass:minicharts:few'); + +require('./d3-tip')(d3); + +const QueryAction = app.appRegistry.getAction('QueryAction'); + +const minicharts_d3fns_few = function() { + // --- beginning chart setup --- + let width = 400; // default width + let height = 100; // default height + let el; + + const barHeight = 25; + const brushHeight = 80; + const options = {}; + + const xScale = d3.scale.linear(); + + // set up tooltips + const tip = d3.tip() + .attr('class', 'd3-tip') + .direction('n') + .offset([-9, 0]); + const brush = d3.svg.brush() + .x(xScale) + .on('brush', brushed) + .on('brushend', brushend); + // --- end chart setup --- + + function handleDrag() { + // ignore this event when shift is pressed, only works for single clicks + if (d3.event.shiftKey) { + return; + } + const bars = el.selectAll('rect.selectable'); + const numSelected = el.selectAll('rect.selectable.selected')[0].length; + const s = brush.extent(); + // add `unselected` class to all elements + bars.classed('unselected', true); + // get elements within the brush + const selected = bars.filter(function(d) { + const left = d.xpos; + const right = left + d.count; + return s[0] <= right && left <= s[1]; + }); + // add `selected` class and remove `unselected` class + selected.classed('selected', true); + selected.classed('unselected', false); + + // if selection has changed, trigger query builder event + if (numSelected !== selected[0].length) { + const values = _.map(selected.data(), 'value'); + QueryAction.setDistinctValues({ + field: options.fieldName, + value: values + }); + } + } + + function brushed() { + handleDrag(); + } + + function brushend() { + d3.select(this).call(brush.clear()); + } + + function handleMouseDown(d) { + const parent = $(this).closest('.minichart'); + const background = parent.find('g.brush > rect.background')[0]; + const brushNode = parent.find('g.brush')[0]; + const start = xScale.invert(d3.mouse(background)[0]); + + + const qbAction = d3.event.shiftKey ? + QueryAction.toggleDistinctValue : QueryAction.setValue; + qbAction({ + field: options.fieldName, + value: d.value, + unsetIfSet: true + }); + + const w = d3.select(window) + .on('mousemove', mousemove) + .on('mouseup', mouseup); + + d3.event.preventDefault(); // disable text dragging + + function mousemove() { + const extent = [start, xScale.invert(d3.mouse(background)[0])]; + d3.select(brushNode).call(brush.extent(_.sortBy(extent))); + brushed.call(brushNode); + } + + function mouseup() { + w.on('mousemove', null).on('mouseup', null); + brushend.call(brushNode); + } + } + + function selectFromQuery(bars) { + // handle distinct selections + if (options.query === undefined) { + bars.classed('unselected', false); + bars.classed('selected', false); + bars.classed('half', false); + return; + } + bars.classed('selected', function(d) { + return hasDistinctValue(options.query, d.value); + }); + bars.classed('unselected', function(d) { + return !hasDistinctValue(options.query, d.value); + }); + } + + function chart(selection) { + selection.each(function(data) { + _.each(data, (d, i) => { + data[i].xpos = _.sum(_(data) + .slice(0, i) + .map('count') + .value() + ); + }); + const values = _.map(data, 'count'); + const sumValues = d3.sum(values); + const maxValue = d3.max(values); + const percentFormat = shared.friendlyPercentFormat(maxValue / sumValues * 100); + el = d3.select(this); + + xScale + .domain([0, sumValues]) + .range([0, width]); + + // setup tool tips + tip.html(function(d, i) { + if (typeof d.tooltip === 'function') { + return d.tooltip(d, i); + } + return d.tooltip || tooltipTemplate({ + label: shared.truncateTooltip(d.label), + count: percentFormat(d.count / sumValues * 100, false) + }); + }); + el.call(tip); + + const gBrush = el.selectAll('.brush').data([0]); + gBrush.enter().append('g') + .attr('class', 'brush') + .call(brush) + .selectAll('rect') + .attr('y', (height - brushHeight) / 2) + .attr('height', brushHeight); + + // select all g.bar elements + const bar = el.selectAll('g.bar') + .data(data, function(d) { + return d.label; // identify data by its label + }); + + bar + .attr('transform', function(d) { + return 'translate(' + xScale(d.xpos) + ', ' + (height - barHeight) / 2 + ')'; + }); + + const barEnter = bar.enter().append('g') + .attr('class', 'bar few') + .attr('transform', function(d) { // repeat transform attr here but without transition + return 'translate(' + xScale(d.xpos) + ', ' + (height - barHeight) / 2 + ')'; + }) + .on('mousedown', handleMouseDown); + + barEnter.append('rect') + .attr('class', function(d, i) { + return 'selectable fg fg-' + i; + }) + .attr('y', 0) + .attr('x', 0) + .attr('height', barHeight); + + barEnter.append('text') + .attr('y', barHeight / 2) + .attr('dy', '0.3em') + .attr('dx', 10) + .attr('text-anchor', 'start') + .attr('fill', 'white'); + + barEnter.append('rect') + .attr('class', 'glass') + .attr('y', 0) + .attr('x', 0) + .attr('height', barHeight) + .on('mouseover', tip.show) + .on('mouseout', tip.hide); + + bar.select('rect.selectable') + .attr('width', function(d) { + return xScale(d.count); + }); + + bar.select('rect.glass') + .attr('width', function(d) { + return xScale(d.count); + }); + + bar.select('text') + .text(function(d) { + return d.label; + }); + + bar.exit().remove(); + + // paint remaining bars in correct color + el.selectAll('rect.selectable').call(selectFromQuery); + }); + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + return chart; +}; + +module.exports = minicharts_d3fns_few; diff --git a/src/internal-packages/schema/lib/d3/index.js b/src/internal-packages/schema/lib/d3/index.js new file mode 100644 index 00000000000..9666b377e4b --- /dev/null +++ b/src/internal-packages/schema/lib/d3/index.js @@ -0,0 +1,8 @@ +module.exports = { + number: require('./number'), + boolean: require('./boolean'), + date: require('./date'), + string: require('./string'), + objectid: require('./date'), + coordinates: require('./coordinates') +}; diff --git a/src/internal-packages/schema/lib/d3/many.js b/src/internal-packages/schema/lib/d3/many.js new file mode 100644 index 00000000000..1af667c169c --- /dev/null +++ b/src/internal-packages/schema/lib/d3/many.js @@ -0,0 +1,484 @@ +/* eslint no-use-before-define: 0, camelcase: 0 */ +const app = require('ampersand-app'); +const d3 = require('d3'); +const $ = require('jquery'); +const _ = require('lodash'); +const shared = require('./shared'); +const tooltipTemplate = require('./tooltip.jade'); +const hasDistinctValue = require('../../../query/lib/util').hasDistinctValue; +const inValueRange = require('../../../query/lib/util').inValueRange; + +// const debug = require('debug')('mongodb-compass:minicharts:many'); + +require('./d3-tip')(d3); + +const QueryAction = app.appRegistry.getAction('QueryAction'); + +const minicharts_d3fns_many = function() { + // --- beginning chart setup --- + let width = 400; // default width + let height = 100; // default height + let el; + let lastNonShiftRangeValue = null; + + const options = { + bgbars: false, + scale: false, + labels: false, // label defaults will be set further below + selectable: true, // setting to false disables query builder for this chart + selectionType: 'distinct' // can be `distinct` or `range` + }; + + const xScale = d3.scale.ordinal(); + const yScale = d3.scale.linear(); + const labelScale = d3.scale.ordinal(); + + // set up tooltips + const tip = d3.tip() + .attr('class', 'd3-tip') + .direction('n') + .offset([-9, 0]); + const brush = d3.svg.brush() + .on('brush', brushed) + .on('brushend', brushend); + // --- end chart setup --- + + function handleDrag() { + // ignore this event when shift is pressed for distinct selections. + // multiple unconnected $in ranges are not supported yet + if (d3.event.shiftKey) { + return; + } + const bars = el.selectAll('rect.selectable'); + const numSelected = el.selectAll('rect.selectable.selected')[0].length; + const s = brush.extent(); + // add `unselected` class to all elements + bars.classed('unselected', true); + // get elements within the brush + const selected = bars.filter(function(d) { + const left = xScale(d.label); + const right = left + xScale.rangeBand(); + return s[0] <= right && left <= s[1]; + }); + // add `selected` class and remove `unselected` class + selected.classed('selected', true); + selected.classed('unselected', false); + + // if selection has changed, trigger query builder event + if (numSelected !== selected[0].length) { + if (selected[0].length === 0) { + // clear value + QueryAction.clearValue({ + field: options.fieldName + }); + return; + } + // distinct values (strings) + if (options.selectionType === 'distinct') { + const values = _.map(selected.data(), 'value'); + QueryAction.setDistinctValues({ + field: options.fieldName, + value: values + }); + return; + } + // numeric types + const minValue = _.min(selected.data(), function(d) { + return d.value; + }); + const maxValue = _.max(selected.data(), function(d) { + return d.value; + }); + + if (minValue.value === maxValue.value + maxValue.dx) { + // if not binned and values are the same, single equality query + QueryAction.setValue({ + field: options.fieldName, + value: minValue.value + }); + return; + } + // binned values, build range query with $gte and $lt (if binned) + // or $gte and $lte (if not binned) + QueryAction.setRangeValues({ + field: options.fieldName, + min: minValue.value, + max: maxValue.value + maxValue.dx, + maxInclusive: maxValue.dx === 0 + }); + } + } + + function brushed() { + handleDrag(); + } + + function brushend() { + d3.select(this).call(brush.clear()); + } + + + /** + * Handles event of single mousedown (either as click, or beginning of a + * brush drag event). + * + * For distinct (non-numeric values), the behavior is this: + * - If shift is pressed: toggle the value (selected if it was unselected, + * and vice versa) + * - If shift is not pressed: set the value to selected one, unless already + * selected, in which case unselect all values. + * + * For ranges (numeric values), the behavior is this: + * - If the bar represents a single value (not binned), create a single value + * equality query, e.g. {"field": 16}. + * - If the bar represents a range (binned), create a $gte / $lt range query, + * e.g. {"field": {"$gte": 20, "$lt": 25}} for a bin size of 5. + * + * @param {Document} d the data associated with the clicked bar + */ + function handleMouseDown(d) { + if (!options.selectable) { + return; + } + + if (options.selectionType === 'distinct') { + // distinct values, behavior dependent on shift key + const qbAction = d3.event.shiftKey ? + QueryAction.toggleDistinctValue : QueryAction.setValue; + qbAction({ + field: options.fieldName, + value: d.value, + unsetIfSet: true + }); + } else if (d3.event.shiftKey && lastNonShiftRangeValue) { + QueryAction.setRangeValues({ + field: options.fieldName, + min: Math.min(d.value, lastNonShiftRangeValue.value), + max: Math.max(d.value + d.dx, lastNonShiftRangeValue.value + lastNonShiftRangeValue.dx), + maxInclusive: d.dx === 0 + }); + } else { + // remember non-shift value so that range can be extended with shift + lastNonShiftRangeValue = d; + if (d.dx > 0) { + // binned bars, turn single value into range + QueryAction.setRangeValues({ + field: options.fieldName, + min: d.value, + max: d.value + d.dx, + unsetIfSet: true + }); + } else { + // bars don't represent bins, build single value query + QueryAction.setValue({ + field: options.fieldName, + value: d.value, + unsetIfSet: true + }); + } + } + + const parent = $(this).closest('.minichart'); + const background = parent.find('g.brush > rect.background')[0]; + const brushNode = parent.find('g.brush')[0]; + const start = d3.mouse(background)[0]; + + const w = d3.select(window) + .on('mousemove', mousemove) + .on('mouseup', mouseup); + + d3.event.preventDefault(); // disable text dragging + + function mousemove() { + const extent = [start, d3.mouse(background)[0]]; + d3.select(brushNode).call(brush.extent(_.sortBy(extent))); + brushed.call(brushNode); + } + + function mouseup() { + w.on('mousemove', null).on('mouseup', null); + brushend.call(brushNode); + } + } + + function selectFromQuery(bars) { + if (options.query === undefined) { + bars.classed('unselected', false); + bars.classed('selected', false); + bars.classed('half', false); + return; + } + // handle distinct selections + if (options.selectionType === 'distinct') { + bars.each(function(d) { + d.hasDistinct = hasDistinctValue(options.query, d.value); + }); + bars.classed('selected', function(d) { + return d.hasDistinct; + }); + bars.classed('unselected', function(d) { + return !d.hasDistinct; + }); + } else if (options.selectionType === 'range') { + bars.each(function(d) { + d.inRange = inValueRange(options.query, d); + }); + bars.classed('selected', function(d) { + return d.inRange === 'yes'; + }); + bars.classed('half-selected', function(d) { + return d.inRange === 'partial'; + }); + bars.classed('unselected', function(d) { + return d.inRange === 'no'; + }); + } + } + + function chart(selection) { + /* eslint complexity: 0 */ + selection.each(function(data) { + const values = _.pluck(data, 'count'); + const maxValue = d3.max(values); + const sumValues = d3.sum(values); + const percentFormat = shared.friendlyPercentFormat(maxValue / sumValues * 100); + const labels = options.labels; + el = d3.select(this); + + xScale + .domain(_.pluck(data, 'label')) + .rangeRoundBands([0, width], 0.3, 0.0); + + brush.x(xScale); + brush.extent(brush.extent()); + + yScale + .domain([0, maxValue]) + .range([height, 0]); + + // set label defaults + if (options.labels) { + _.defaults(labels, { + 'text-anchor': function(d, i) { + if (i === 0) { + return 'start'; + } + if (i === data.length - 1) { + return 'end'; + } + return 'middle'; + }, + x: labels['text-anchor'] === 'middle' ? xScale.rangeBand() / 2 : function(d, i) { + if (i === 0) { + return 0; + } + if (i === data.length - 1) { + return xScale.rangeBand(); + } + return xScale.rangeBand() / 2; + }, + y: height + 5, + dy: '0.75em', + text: function(d) { + return d.count; + } + }); + } + + // setup tool tips + tip.html(function(d, i) { + if (typeof d.tooltip === 'function') { + return d.tooltip(d, i); + } + return d.tooltip || tooltipTemplate({ + label: shared.truncateTooltip(d.label), + count: percentFormat(d.count / sumValues * 100, false) + }); + }); + el.call(tip); + + // draw scale labels and lines if requested + if (options.scale) { + const triples = function(v) { + return [v, v / 2, 0]; + }; + + const scaleLabels = _.map(triples(maxValue / sumValues * 100), function(x) { + return percentFormat(x, true); + }); + + labelScale + .domain(scaleLabels) + .rangePoints([0, height]); + + const legend = el.selectAll('g.legend') + .data(scaleLabels); + + // create new legend elements + const legendEnter = legend.enter().append('g') + .attr('class', 'legend'); + + legendEnter + .append('text') + .attr('x', 0) + .attr('dx', '-1em') + .attr('dy', '0.3em') + .attr('text-anchor', 'end'); + + legendEnter + .append('line') + .attr('class', 'bg') + .attr('x1', -5) + .attr('y1', 0) + .attr('y2', 0); + + // update legend elements + legend + .attr('transform', function(d) { + return 'translate(0, ' + labelScale(d) + ')'; + }); + + legend.select('text') + .text(function(d) { + return d; + }); + + legend.select('line') + .attr('x2', width); + + legend.exit().remove(); + } + + if (options.selectable) { + const gBrush = el.selectAll('.brush').data([0]); + gBrush.enter().append('g') + .attr('class', 'brush') + .call(brush) + .selectAll('rect') + .attr('y', 0) + .attr('height', height); + } + + // select all g.bar elements + const bar = el.selectAll('.bar') + .data(data, function(d) { + return d.label; // identify data by its label + }); + + // create new bar elements as needed + const barEnter = bar.enter().append('g') + .attr('class', 'bar'); + + bar + .attr('transform', function(d) { + return 'translate(' + xScale(d.label) + ', 0)'; + }); + + // if background bars are used, fill whole area with background bar color first + if (options.bgbars) { + barEnter.append('rect') + .attr('class', 'bg'); + // .attr('width', xScale.rangeBand()) + // .attr('height', height); + } + + // now attach the foreground bars + barEnter + .append('rect') + .attr('class', options.selectable ? 'fg selectable' : 'fg') + .attr('x', 0); + // .attr('width', xScale.rangeBand()); + + // create mouseover and click handlers + if (options.bgbars) { + // ... on a separate front "glass" pane if we use background bars + barEnter.append('rect') + .attr('class', 'glass') + .attr('width', xScale.rangeBand()) + .attr('height', height) + .on('mouseover', tip.show) + .on('mouseout', tip.hide); + } else { + // ... or attach tooltips directly to foreground bars if we don't use background bars + barEnter.selectAll('.fg') + .on('mouseover', tip.show) + .on('mouseout', tip.hide); + + if (options.selectable) { + barEnter.selectAll('.selectable').on('mousedown', handleMouseDown); + } + } + + if (options.labels) { + barEnter.append('text') + .attr('x', labels.x) + .attr('dx', labels.dx) + .attr('y', labels.y) + .attr('dy', labels.dy) + .attr('text-anchor', labels['text-anchor']); + } + + + // now update _all_ bar elements (old and new) based on changes + // in data and width/height + bar.selectAll('.bg') + .attr('width', xScale.rangeBand()) + .attr('height', height); + + bar.selectAll('.fg') + // .transition() + .attr('y', function(d) { + return yScale(d.count); + }) + .attr('width', xScale.rangeBand()) + .attr('height', function(d) { + return height - yScale(d.count); + }); + + if (options.labels) { + bar.select('text').text(labels.text); + } else { + bar.select('text').remove(); + } + + // finally remove obsolete bar elements + bar.exit().remove(); + + // unset the non-shift clicked bar marker if the query is empty + if (options.query === undefined) { + lastNonShiftRangeValue = null; + } + + // paint remaining bars in correct color + el.selectAll('rect.selectable').call(selectFromQuery); + }); + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + return chart; +}; + +module.exports = minicharts_d3fns_many; diff --git a/src/internal-packages/schema/lib/d3/mapstyle.js b/src/internal-packages/schema/lib/d3/mapstyle.js new file mode 100644 index 00000000000..ec925d33e52 --- /dev/null +++ b/src/internal-packages/schema/lib/d3/mapstyle.js @@ -0,0 +1,89 @@ +module.exports = [ + { + featureType: 'administrative', + elementType: 'all', + stylers: [ + { + visibility: 'simplified' + } + ] + }, + { + featureType: 'landscape', + elementType: 'geometry', + stylers: [ + { + visibility: 'simplified' + }, + { + color: '#fcfcfc' + } + ] + }, + { + featureType: 'poi', + elementType: 'all', + stylers: [ + { + visibility: 'off' + } + ] + }, + { + featureType: 'transit.station', + elementType: 'all', + stylers: [ + { + visibility: 'off' + } + ] + }, + { + featureType: 'road.highway', + elementType: 'geometry', + stylers: [ + { + visibility: 'simplified' + }, + { + color: '#dddddd' + } + ] + }, + { + featureType: 'road.arterial', + elementType: 'geometry', + stylers: [ + { + visibility: 'simplified' + }, + { + color: '#dddddd' + } + ] + }, + { + featureType: 'road.local', + elementType: 'geometry', + stylers: [ + { + visibility: 'simplified' + }, + { + color: '#eeeeee' + } + ] + }, + { + featureType: 'water', + elementType: 'geometry', + stylers: [ + { + visibility: 'simplified' + }, + { + color: '#dddddd' + } + ] + } +]; diff --git a/src/internal-packages/schema/lib/d3/number.js b/src/internal-packages/schema/lib/d3/number.js new file mode 100644 index 00000000000..254660b4faf --- /dev/null +++ b/src/internal-packages/schema/lib/d3/number.js @@ -0,0 +1,136 @@ +/* eslint camelcase: 0 */ +const d3 = require('d3'); +const _ = require('lodash'); +const many = require('./many'); +const shared = require('./shared'); +// const debug = require('debug')('mongodb-compass:minicharts:number'); + +const minicharts_d3fns_number = function() { + let width = 400; + let height = 100; + const options = { + view: null + }; + const margin = shared.margin; + const xBinning = d3.scale.linear(); + const manyChart = many(); + + function chart(selection) { + selection.each(function(data) { + let grouped; + const el = d3.select(this); + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // transform data + if (options.unique < 20) { + grouped = _(data) + .groupBy(function(d) { + return d; + }) + .map(function(v, k) { + v.label = k; + v.x = parseFloat(k, 10); + v.value = v.x; + v.dx = 0; + v.count = v.length; + return v; + }) + .sortBy(function(v) { + return v.value; + }) + .value(); + } else { + // use the linear scale just to get nice binning values + xBinning + .domain(d3.extent(data)) + .range([0, innerWidth]); + + // Generate a histogram using approx. twenty uniformly-spaced bins + const ticks = xBinning.ticks(20); + const hist = d3.layout.histogram() + .bins(ticks); + + grouped = hist(data); + + _.each(grouped, function(d, i) { + let label; + if (i === 0) { + label = '< ' + (d.x + d.dx); + } else if (i === data.length - 1) { + label = '≥ ' + d.x; + } else { + label = d.x + '-' + (d.x + d.dx); + } + // remapping keys to conform with all other types + d.count = d.y; + d.value = d.x; + d.label = label; + }); + } + + const g = el.selectAll('g').data([grouped]); + + // append g element if it doesn't exist yet + g.enter() + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + let labels; + if (options.unique < 20) { + labels = true; + } else { + labels = { + text: function(d, i) { + if (i === 0) { + return 'min: ' + d3.min(data); + } + if (i === grouped.length - 1) { + return 'max: ' + d3.max(data); + } + return ''; + } + }; + } + + options.labels = labels; + options.scale = true; + options.selectionType = 'range'; + + manyChart + .width(innerWidth) + .height(innerHeight - 10) + .options(options); + + g.call(manyChart); + }); + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + return chart; +}; + +module.exports = minicharts_d3fns_number; diff --git a/src/internal-packages/schema/lib/d3/shared.js b/src/internal-packages/schema/lib/d3/shared.js new file mode 100644 index 00000000000..1d039bad895 --- /dev/null +++ b/src/internal-packages/schema/lib/d3/shared.js @@ -0,0 +1,46 @@ +/* eslint camelcase: 0 */ +var d3 = require('d3'); + +// source: http://bit.ly/1Tc9Tp5 +function decimalPlaces(number) { + return ((+number).toFixed(20)).replace(/^-?\d*\.?|0+$/g, '').length; +} + +var minicharts_d3fns_shared = { + + margin: { + top: 10, + right: 0, + bottom: 10, + left: 40 + }, + + friendlyPercentFormat: function(vmax) { + var prec1Format = d3.format('.1r'); + var intFormat = d3.format('.0f'); + var format = vmax > 1 ? intFormat : prec1Format; + var maxFormatted = format(vmax); + var maxDecimals = decimalPlaces(maxFormatted); + + return function(v, incPrec) { + if (v === vmax) { + return maxFormatted + '%'; + } + if (v > 1 && !incPrec) { // v > vmax || maxFormatted % 2 === 0 + return d3.round(v, maxDecimals) + '%'; + } + // adjust for corrections, if increased precision required + return d3.round(v / vmax * maxFormatted, maxDecimals + 1) + '%'; + }; + }, + + truncateTooltip: function(text, maxLength) { + maxLength = maxLength || 500; + if (text.length > maxLength) { + text = text.substring(0, maxLength - 1) + '…'; + } + return text; + } + +}; +module.exports = minicharts_d3fns_shared; diff --git a/src/internal-packages/schema/lib/d3/string.js b/src/internal-packages/schema/lib/d3/string.js new file mode 100644 index 00000000000..432c744fea4 --- /dev/null +++ b/src/internal-packages/schema/lib/d3/string.js @@ -0,0 +1,91 @@ +/* eslint camelcase: 0 */ +const d3 = require('d3'); +const _ = require('lodash'); +const few = require('./few'); +const many = require('./many'); +const shared = require('./shared'); + +const minicharts_d3fns_string = function() { + // --- beginning chart setup --- + let width = 400; + let height = 100; + const options = { + query: {} + }; + + const manyChart = many(); + const fewChart = few(); + const margin = shared.margin; + // --- end chart setup --- + + function chart(selection) { + selection.each(function(data) { + const el = d3.select(this); + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // group into labels and values per bucket, sort descending + const grouped = _(data) + .groupBy(function(d) { + return d; + }) + .map(function(v, k) { + return { + label: k, + value: k, + count: v.length + }; + }) + .sortByOrder('count', [false]) // descending on value + .value(); + + const g = el.selectAll('g').data([grouped]); + + // append g element if it doesn't exist yet + g.enter() + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') + .attr('width', innerWidth) + .attr('height', innerHeight); + + const chartFn = grouped.length <= 5 ? fewChart : manyChart; + options.scale = true; + options.selectionType = 'distinct'; + + chartFn + .width(innerWidth) + .height(innerHeight) + .options(options); + + g.call(chartFn); + }); + } + + chart.width = function(value) { + if (!arguments.length) { + return width; + } + width = value; + return chart; + }; + + chart.height = function(value) { + if (!arguments.length) { + return height; + } + height = value; + return chart; + }; + + chart.options = function(value) { + if (!arguments.length) { + return options; + } + _.assign(options, value); + return chart; + }; + + return chart; +}; + +module.exports = minicharts_d3fns_string; diff --git a/src/internal-packages/schema/lib/d3/tooltip.jade b/src/internal-packages/schema/lib/d3/tooltip.jade new file mode 100644 index 00000000000..e50c5321618 --- /dev/null +++ b/src/internal-packages/schema/lib/d3/tooltip.jade @@ -0,0 +1,3 @@ +.tooltip-wrapper + .tooltip-label!= label + .tooltip-value #{count} diff --git a/src/internal-packages/schema/lib/store/index.jsx b/src/internal-packages/schema/lib/store/index.jsx new file mode 100644 index 00000000000..2a4d6bbb491 --- /dev/null +++ b/src/internal-packages/schema/lib/store/index.jsx @@ -0,0 +1,200 @@ +const app = require('ampersand-app'); +const Reflux = require('reflux'); +const StateMixin = require('reflux-state-mixin'); +const Schema = require('mongodb-schema').Schema; +const _ = require('lodash'); + +// stores +const NamespaceStore = require('hadron-reflux-store').NamespaceStore; + +// actions +const SchemaAction = require('../action'); + +const debug = require('debug')('mongodb-compass:stores:schema'); +// const metrics = require('mongodb-js-metrics')(); + +const DEFAULT_MAX_TIME_MS = 10000; +const DEFAULT_NUM_DOCUMENTS = 1000; + +/** + * The reflux store for the schema. + */ +const SchemaStore = Reflux.createStore({ + + mixins: [StateMixin.store], + listenables: SchemaAction, + + /** + * Initialize the document list store. + */ + init: function() { + NamespaceStore.listen(() => { + this._reset(); + SchemaAction.startSampling(); + }); + + this.samplingStream = null; + this.analyzingStream = null; + this.samplingTimer = null; + this.trickleStop = null; + }, + + /** + * Initialize the schema store. + * + * @return {Object} initial schema state. + */ + getInitialState() { + return { + samplingState: 'initial', + samplingProgress: 0, + samplingTimeMS: 0, + maxTimeMS: DEFAULT_MAX_TIME_MS, + schema: null + }; + }, + + + _reset: function() { + this.setState(this.getInitialState()); + }, + + setMaxTimeMS(maxTimeMS) { + this.setState({ + maxTimeMS: maxTimeMS + }); + }, + + resetMaxTimeMS() { + this.setState({ + maxTimeMS: DEFAULT_MAX_TIME_MS + }); + }, + + stopSampling() { + if (this.samplingTimer) { + clearInterval(this.samplingTimer); + this.samplingTimer = null; + } + if (this.samplingStream) { + this.samplingStream.destroy(); + this.samplingStream = null; + } + if (this.analyzingStream) { + this.analyzingStream.destroy(); + this.analyzingStream = null; + } + }, + + /** + * This function is called when the collection filter changes. + */ + startSampling() { + const QueryStore = app.appRegistry.getStore('QueryStore'); + const query = QueryStore.state.query; + + if (_.includes(['counting', 'sampling', 'analyzing'], this.state.samplingState)) { + return; + } + + const ns = NamespaceStore.ns; + if (!ns) { + return; + } + + this.setState({ + samplingState: 'counting', + samplingProgress: -1, + samplingTimeMS: 0, + schema: null + }); + + const options = { + maxTimeMS: this.state.maxTimeMS, + query: query, + size: DEFAULT_NUM_DOCUMENTS, + fields: null + }; + + const samplingStart = new Date(); + this.samplingTimer = setInterval(() => { + this.setState({ + samplingTimeMS: new Date() - samplingStart + }); + }, 1000); + + this.samplingStream = app.dataService.sample(ns, options); + const schema = new Schema(); + this.analyzingStream = schema.stream(true); + + const onError = () => { + this.setState({ + samplingState: 'error' + }); + this.stopSampling(); + }; + + const onSuccess = (_schema) => { + this.setState({ + samplingState: 'complete', + samplingTimeMS: new Date() - samplingStart, + samplingProgress: 100, + schema: _schema + }); + this.stopSampling(); + }; + + app.dataService.count(ns, query, {maxTimeMS: this.state.maxTimeMS}, (err, count) => { + if (err) { + return onError(err); + } + + this.setState({ + samplingState: 'sampling', + samplingProgress: 0, + samplingTimeMS: new Date() - samplingStart + }); + const numSamples = Math.min(count, DEFAULT_NUM_DOCUMENTS); + let sampleCount = 0; + + this.samplingStream + .on('error', (sampleErr) => { + return onError(sampleErr); + }) + .pipe(this.analyzingStream) + .once('progress', () => { + this.setState({ + samplingState: 'analyzing', + samplingTimeMS: new Date() - samplingStart + }); + }) + .on('progress', () => { + sampleCount ++; + const newProgress = Math.ceil(sampleCount / numSamples * 100); + if (newProgress > this.state.samplingProgress) { + this.setState({ + samplingProgress: Math.ceil(sampleCount / numSamples * 100), + samplingTimeMS: new Date() - samplingStart + }); + } + }) + .on('error', (analysisErr) => { + onError(analysisErr); + }) + .on('end', () => { + if ((numSamples === 0 || sampleCount > 0) && this.state.samplingState !== 'error') { + onSuccess(schema.serialize()); + } else { + return onError(); + } + }); + }); + }, + + storeDidUpdate(prevState) { + debug('schema store changed from %j to %j', prevState, this.state); + } + +}); + +module.exports = SchemaStore; diff --git a/src/internal-packages/schema/package.json b/src/internal-packages/schema/package.json new file mode 100644 index 00000000000..b0e2e4cfb50 --- /dev/null +++ b/src/internal-packages/schema/package.json @@ -0,0 +1,9 @@ +{ + "name": "schema", + "productName": "Compass Schema", + "description": "Schema for Compass as an internal package.", + "version": "0.0.1", + "authors": "MongoDB Inc.", + "private": true, + "main": "./index.js" +} diff --git a/src/internal-packages/schema/styles/index.less b/src/internal-packages/schema/styles/index.less new file mode 100644 index 00000000000..9e201684a46 --- /dev/null +++ b/src/internal-packages/schema/styles/index.less @@ -0,0 +1,336 @@ +// minicharts +@mc-blue0: #43B1E5; +@mc-blue1: lighten(@mc-blue0, 7.5%); +@mc-blue2: lighten(@mc-blue0, 15%); +@mc-blue3: lighten(@mc-blue0, 22.5%); +@mc-blue4: lighten(@mc-blue0, 30%); +@mc-blue5: lighten(@mc-blue0, 37.5%); + +@mc-bg: @gray8; +@mc-fg: @mc-blue0; +@mc-fg-selected: @chart0; +@mc-fg-unselected: @gray6; + +div.minichart.unique { + font-size: 12px; + + dl.dl-horizontal { + margin-left: -32px; + padding-top: 12px; + max-height: 112px; + overflow: hidden; + + dt { + color: @gray3; + width: 20px; + + a { + color: @gray5; + display: inline-block; + + &:hover { + text-decoration: none; + color: @gray1; + } + } + i.mms-icon-continuous { // remove after wrapping this in an again + color: @gray5; + cursor: pointer; + + &:hover { + color: @gray1; + } + } + } + dd { + margin-left: 30px; + overflow: hidden; + + ul li { + margin-bottom: 6px; + + code { + cursor: pointer; + background-color: @gray7; + border: 1px solid transparent; + color: @gray1; + font-size: 12px; + line-height: 20px; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + } + + code.selected { + background-color: @mc-fg-selected; + color: @pw; + // border: 1px dotted @gray2; + } + } + } + } +} + +.minichart-wrapper { + svg.minichart { + margin-left: -40px; + } +} + +.layer, .layer svg { + position: absolute; +} + +.layer svg.marker { + width: 20px; + height: 20px; + + circle { + fill: @mc-fg; + stroke: @pw; + stroke-width: 1.5px; + + &.selected { + fill: @mc-fg-selected; + } + } +} + +.layer svg.selection { + visibility: hidden; + + circle { + fill: @mc-fg-selected; + fill-opacity:0.2; + stroke: @mc-fg-selected; + stroke-width: 2px; + } +} + +svg.minichart { + font-size: 10px; + + text { + fill: @gray4; + font-weight: bold; + } + + .glass { + opacity: 0; + } + + g.brush rect.extent { + fill: @mc-fg-selected; + fill-opacity:0.2; + } + + .hour, .weekday { + .bar { + cursor: default !important; + } + } + + .bar { + shape-rendering: crispEdges; + cursor: crosshair; + + rect.bg { + fill: @mc-bg; + } + + rect.fg { + fill: @mc-fg; + + &.selected { + fill: @mc-fg-selected; + } + + &.half-selected { + fill: @mc-fg-selected; + mask: url(#mask-stripe); + } + + &.unselected { + fill: @mc-fg-unselected; + } + } + + &.few { + + rect { + stroke: white; + stroke-width: 2px; + } + + rect.fg-0 { + fill: @mc-blue0; + } + + rect.fg-1 { + fill: @mc-blue1; + } + + rect.fg-2 { + fill: @mc-blue2; + } + + rect.fg-3 { + fill: @mc-blue3; + } + + rect.fg-4 { + fill: @mc-blue4; + } + + rect.fg-5 { + fill: @mc-blue5; + } + + rect.fg.selected { + fill: @mc-fg-selected; + } + rect.fg.unselected { + fill: @mc-fg-unselected; + } + + text { + fill: white; + font-size: 12px; + } + } + } + + .line { + stroke: @mc-fg; + + &.selected { + stroke: @mc-fg-selected; + } + } + + .legend { + text { + fill: @gray5; + } + + line { + stroke: @gray7; + } + shape-rendering: crispEdges; + } + + .axis path, .axis line { + fill: none; + stroke: @gray7; + shape-rendering: crispEdges; + } + + .circle { + fill: @mc-fg; + stroke: @pw; + stroke-width: 1.5px; + + &.selected { + fill: @mc-fg-selected; + } + } +} + +.tooltip-wrapper { + line-height: 120%; + max-width: 400px; +} + +.map { + position:absolute; + top:0; + bottom:0; + width:100%; + float: left; + svg { + position: absolute; + width: 100%; + height: 100%; + } + nav { + position: absolute; + top: 40px; + left: 20px; + z-index: 1; + } + #circle { + background-color: rgba(20, 20, 20, 0.1); + font-family: Helvetica, sans-serif; + color: #3b83bd; + padding: 5px 8px; + border-radius: 3px; + cursor: pointer; + border: 1px solid #111; + } + #circle.active { + background-color: rgba(250, 250, 250, 0.9); + } + i.help { + display: inline-block; + float: left; + } +} + +// -- d3-tip styling +.d3-tip { + z-index: 2; + line-height: 1; + padding: 8px; + background: #000; + color: #fff; + border-radius: 5px; + pointer-events: none; + font-size: 12px; +} + +/* Creates a small triangle extender for the tooltip */ +.d3-tip:after { + box-sizing: border-box; + display: inline; + font-size: 14px; + width: 100%; + line-height: 1; + color: #000; + position: absolute; + pointer-events: none; +} + +/* Northward tooltips */ +.d3-tip.n:after { + content: "\25BC"; + margin: -4px 0 0 0; + top: 100%; + left: 0; + text-align: center; +} + +/* Eastward tooltips */ +.d3-tip.e:after { + content: "\25C0"; + margin: -4px 0 0 0; + top: 50%; + left: -8px; +} + +/* Southward tooltips */ +.d3-tip.s:after { + content: "\25B2"; + margin: 0 0 1px 0; + top: -8px; + left: 0; + text-align: center; +} + +/* Westward tooltips */ +.d3-tip.w:after { + content: "\25B6"; + margin: -4px 0 0 -1px; + top: 50%; + left: 100%; +} diff --git a/src/internal-packages/schema/util b/src/internal-packages/schema/util new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/internal-packages/status/index.js b/src/internal-packages/status/index.js new file mode 100644 index 00000000000..5e2773a2db3 --- /dev/null +++ b/src/internal-packages/status/index.js @@ -0,0 +1,26 @@ +'use strict'; + +const app = require('ampersand-app'); +const StatusComponent = require('./lib/component'); +const StatusAction = require('./lib/action'); +const StatusStore = require('./lib/store'); +/** + * Activate all the components in the CRUD package. + */ +function activate() { + app.appRegistry.registerComponent('App:Status', StatusComponent); + app.appRegistry.registerAction('StatusAction', StatusAction); + app.appRegistry.registerStore('StatusStore', StatusStore); +} + +/** + * Deactivate all the components in the CRUD package. + */ +function deactivate() { + app.appRegistry.deregisterComponent('App:Status'); + app.appRegistry.deregisterAction('StatusAction'); + app.appRegistry.deregisterStore('StatusStore'); +} + +module.exports.activate = activate; +module.exports.deactivate = deactivate; diff --git a/src/internal-packages/status/lib/action/index.js b/src/internal-packages/status/lib/action/index.js new file mode 100644 index 00000000000..a5f332a0c33 --- /dev/null +++ b/src/internal-packages/status/lib/action/index.js @@ -0,0 +1,112 @@ +const Reflux = require('reflux'); + +const StatusAction = Reflux.createActions([ + /** + * shows the progress bar. + */ + 'showProgressBar', + /** + * shows an indeterminate progress bar at 100 percent. + */ + 'showIndeterminateProgressBar', + /** + * hides the progress bar. + */ + 'hideProgressBar', + /** + * sets the value of the progres bar. + * + * @param {Number} value the value, must be between 0 and 100. + */ + 'setProgressValue', + /** + * increases the value of the progres bar. + * + * @param {Number} value increase by value. + */ + 'incProgressValue', + /** + * enable trickle: progress bar randomly advances a few percentage points + * every second to indicate progress. + */ + 'enableProgressTrickle', + /** + * disable trickle. + */ + 'disableProgressTrickle', + /** + * sets a message that is shown on the screen above the loading animation. + * + * @param {String} message the message to show + */ + 'setMessage', + /** + * clears and removes the message. + */ + 'clearMessage', + /** + * shows loading animation in the center of the screen. + */ + 'showAnimation', + /** + * hides loading animation. + */ + 'hideAnimation', + /** + * shows a static gray sidebar in the background. This is useful when + * no other content is on the screen yet (e.g. when connecting to a mongod) + * so that the message/loading animation look centered. + */ + 'showStaticSidebar', + /** + * hide static gray sidebar. + */ + 'hideStaticSidebar', + /** + * set a custom subview that is shown below the loading animation. For example, + * the schema view sets a subview to indicate longer than usual parsing. + * + * @param {View} subview the subview to render. + */ + 'setSubview', + /** + * clears the custom subview. + */ + 'clearSubview', + /** + * when enabled, overlays the screen with a transparent div, so that no other + * interaction can take place. + */ + 'enableModal', + /** + * disables the modal transparent div. + */ + 'disableModal', + /** + * custom configuration to set all the options above in a single call. + * + * @param {Object} options options to configure, see below: + * + * @param {Boolean} options.visible show/hide entire status view + * @param {Boolean} options.progressbar show/hide progress bar + * @param {Number} options.width progress bar width 0-100 + * @param {Boolean} options.modal activate/deactivate modal + * @param {Boolean} options.animation show/hide animation + * @param {String} options.message message to show, '' disables message + * @param {View} options.subview subview to show, or `null` + * @param {Boolean} options.sidebar show/hide static sidebar + */ + 'configure', + /** + * hide all status components (progress bar, message, animation, sidebar). + * Use when loading was interrupted. + */ + 'hide', + /** + * like `hide()` but animates the progress bar to 100% before hiding, so that + * the user gets feedback of success. Use when loading is complete. + */ + 'done' +]); + +module.exports = StatusAction; diff --git a/src/internal-packages/status/lib/component/index.jsx b/src/internal-packages/status/lib/component/index.jsx new file mode 100644 index 00000000000..5b2c76c82a0 --- /dev/null +++ b/src/internal-packages/status/lib/component/index.jsx @@ -0,0 +1,82 @@ +const React = require('react'); +const StatusStore = require('../store'); +const StateMixin = require('reflux-state-mixin'); + +// const debug = require('debug')('mongodb-compass:status'); + +const STATUS_ID = 'statusbar'; + +/** + * Component for the entire document list. + */ +const Status = React.createClass({ + + mixins: [ StateMixin.connect(StatusStore) ], + + /** + * Render the status elements: progress bar, message container, sidebar, + * animation container, subview container, ... + * + * @returns {React.Component} The status view. + */ + render() { + // derive styles from status state + const visible = this.state.visible ? '' : 'hidden'; + const progressBarWidth = this.state.progress; + const progressBarHeight = 4; + const outerBarStyle = { + display: this.state.progressbar ? 'block' : 'none', + height: progressBarHeight + }; + const innerBarStyle = { + width: `${progressBarWidth}%` + }; + const messageStyle = { + visibility: this.state.message !== '' ? 'visible' : 'hidden' + }; + const animationStyle = { + visibility: this.state.animation ? 'visible' : 'hidden' + }; + const sidebarStyle = { + display: this.state.sidebar ? 'block' : 'none' + }; + + // create subview component if state.subview is set + let statusSubview = null; + if (this.state.subview) { + const SubView = this.state.subview; + statusSubview = ; + } + + return ( +
    +
    +
    +
    +
    +
    +
    +
      +
    • +

      + {this.state.message} +

      +
      +
      +
      +
      +
      +
      +
      +
      + {statusSubview} +
      +
    • +
    +
    + ); + } +}); + + +module.exports = Status; diff --git a/src/internal-packages/status/lib/store/index.jsx b/src/internal-packages/status/lib/store/index.jsx new file mode 100644 index 00000000000..2740cb2b0fa --- /dev/null +++ b/src/internal-packages/status/lib/store/index.jsx @@ -0,0 +1,203 @@ +const Reflux = require('reflux'); +const StatusAction = require('../action'); +const StateMixin = require('reflux-state-mixin'); +const _ = require('lodash'); + +const debug = require('debug')('mongodb-compass:stores:status'); + +/** + * Status store. The store object consists of the following options: + * + * @param {Boolean} visible show/hide entire status view + * @param {Boolean} progressbar show/hide progress bar + * @param {Number} progress progress bar width in percent 0-100 + * @param {Boolean} modal activate/deactivate modal + * @param {Boolean} animation show/hide animation + * @param {String} message message to show, '' disables message + * @param {View} subview subview to show, or `null` + * @param {Boolean} sidebar show/hide static sidebar + */ + +const StatusStore = Reflux.createStore({ + // adds a state to the store, similar to React.Component's state + mixins: [StateMixin.store], + listenables: StatusAction, + + init() { + this._trickleTimer = null; + }, + + /** + * Initialize the status store. + * + * @return {Object} initial store state. + */ + getInitialState() { + return { + visible: false, + progressbar: false, + progress: 0, + modal: false, + animation: false, + message: '', + subview: null, + sidebar: false, + trickle: false + }; + }, + + showProgressBar() { + this.setState({ + visible: true, + progressbar: true + }); + }, + + showIndeterminateProgressBar() { + this.setState({ + visible: true, + progressbar: true, + progress: 100, + trickle: false + }); + }, + + hideProgressBar() { + this.setState({ + progressbar: false + }); + }, + + configure(options) { + // `trickle` is the only option with a "side-effect", all other + // state variables are handled by the status component. + if (options.trickle) { + this.enableProgressTrickle(); + } else { + this.disableProgressTrickle(); + } + this.setState(options); + }, + + setProgressValue(value) { + this.setState({ + visible: true, + progress: value + }); + }, + + incProgressValue(value) { + this.setState({ + visible: true, + progress: this.state.value + value + }); + }, + + enableProgressTrickle() { + this._trickleTimer = setInterval(() => { + const newValue = Math.min(98, this.state.progress + 1); + this.setState.call(this, { + progress: newValue + }); + }, 600); + this.setState({ + trickle: true + }); + }, + + disableProgressTrickle() { + if (this._trickleTimer !== null) { + clearInterval(this._trickleTimer); + this._trickleTimer = null; + } + this.setState({ + trickle: false + }); + }, + + setMessage(msg) { + this.setState({ + visible: true, + message: msg + }); + }, + + clearMessage() { + this.setState({ + message: '' + }); + }, + + showAnimation() { + this.setState({ + visible: true, + animation: true + }); + }, + + hideAnimation() { + this.setState({ + animation: false + }); + }, + + showStaticSidebar() { + this.setState({ + visible: true, + sidebar: true + }); + }, + + hideStaticSidebar() { + this.setState({ + sidebar: false + }); + }, + + setSubview(view) { + this.setState({ + subview: view + }); + }, + + onClearSubview() { + this.setState({ + subview: null + }); + }, + + enableModal() { + this.setState({ + modal: true + }); + }, + + disableModal() { + this.setState({ + modal: false + }); + }, + + hide() { + this.setState(this.getInitialState()); + }, + + done() { + this.disableProgressTrickle(); + this.setState({ + progress: 100, + animation: false, + message: '', + subview: null + }); + _.delay(() => { + this.hide(); + }, 700); + }, + + storeDidUpdate(prevState) { + debug('status store changed from %j to %j', prevState, this.state); + } +}); + +module.exports = StatusStore; diff --git a/src/internal-packages/status/package.json b/src/internal-packages/status/package.json new file mode 100644 index 00000000000..a37eaee7329 --- /dev/null +++ b/src/internal-packages/status/package.json @@ -0,0 +1,9 @@ +{ + "name": "status", + "productName": "Compass Status View", + "description": "progress bar, loading animations, status messages, etc.", + "version": "0.0.1", + "authors": "MongoDB Inc.", + "private": true, + "main": "./index.js" +} diff --git a/src/app/statusbar/index.less b/src/internal-packages/status/styles/index.less similarity index 100% rename from src/app/statusbar/index.less rename to src/internal-packages/status/styles/index.less