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 (
-
- {this.props.text}
-
- );
- }
-
- /**
- * 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 (
+
+ );
+ }
+});
+
+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 (
+
+ );
+ }
+});
+
+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 (
+
+ );
+ }
+});
+
+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 (
+
+ );
+ },
+
+ /**
+ * 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 (
+
+
+ {value.toString()}
+
+
+ );
+ }
+});
+
+/* 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+});
+
+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