diff --git a/app/components/output-data-input.js b/app/components/output-data-input.js index d4b021b9..af136cea 100644 --- a/app/components/output-data-input.js +++ b/app/components/output-data-input.js @@ -41,26 +41,26 @@ export default Ember.Component.extend({ }), // Helper equalities for template - isRawAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('RAW')), - isGroupAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('GROUP')), - isCountDistinctAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('COUNT_DISTINCT')), - isDistributionAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('DISTRIBUTION')), - isTopKAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('TOP_K')), + isRawAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('RAW')).readOnly(), + isGroupAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('GROUP')).readOnly(), + isCountDistinctAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('COUNT_DISTINCT')).readOnly(), + isDistributionAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('DISTRIBUTION')).readOnly(), + isTopKAggregation: Ember.computed.equal('outputDataType', AGGREGATIONS.get('TOP_K')).readOnly(), - isSelectType: Ember.computed.equal('rawType', RAWS.get('SELECT')), - showRawSelections: Ember.computed.and('isRawAggregation', 'isSelectType'), + isSelectType: Ember.computed.equal('rawType', RAWS.get('SELECT')).readOnly(), + showRawSelections: Ember.computed.and('isRawAggregation', 'isSelectType').readOnly(), - isNumberOfPoints: Ember.computed.equal('pointType', DISTRIBUTION_POINTS.get('NUMBER')), - isPoints: Ember.computed.equal('pointType', DISTRIBUTION_POINTS.get('POINTS')), - isGeneratedPoints: Ember.computed.equal('pointType', DISTRIBUTION_POINTS.get('GENERATED')), + isNumberOfPoints: Ember.computed.equal('pointType', DISTRIBUTION_POINTS.get('NUMBER')).readOnly(), + isPoints: Ember.computed.equal('pointType', DISTRIBUTION_POINTS.get('POINTS')).readOnly(), + isGeneratedPoints: Ember.computed.equal('pointType', DISTRIBUTION_POINTS.get('GENERATED')).readOnly(), canDeleteProjections: Ember.computed('query.projections.[]', function() { return this.get('query.projections.length') > 1; - }), + }).readOnly(), canDeleteField: Ember.computed('query.aggregation.groups.[]', function() { return this.get('query.aggregation.groups.length') > 1; - }), + }).readOnly(), findOrDefault(valuePath, defaultValue) { let value = this.get(valuePath); diff --git a/app/components/pivot-table.js b/app/components/pivot-table.js new file mode 100644 index 00000000..6392e452 --- /dev/null +++ b/app/components/pivot-table.js @@ -0,0 +1,40 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + rows: null, + columns: null, + initialOptions: null, + defaultOptions: { + unusedAttrsVertical: true, + menuLimit: 200, + renderers: Ember.$.extend( + Ember.$.pivotUtilities.renderers, + Ember.$.pivotUtilities.c3_renderers + ) + }, + + options: Ember.computed('initialOptions', 'defaultOptions', function() { + let deserialized = this.get('initialOptions'); + let options = this.get('defaultOptions'); + // Attach refresh handler + return Ember.$.extend({ onRefresh: this.refreshHandler(this) }, deserialized, options); + }), + + didInsertElement() { + this._super(...arguments); + let { rows, options } = this.getProperties('rows', 'options'); + this.$('.pivot-table-container').pivotUI(rows, options); + }, + + refreshHandler(context) { + return (configuration) => { + let copy = JSON.parse(JSON.stringify(configuration)); + // Deletes functions and defaults: http://nicolas.kruchten.com/pivottable/examples/onrefresh.html + delete copy.aggregators; + delete copy.renderers; + delete copy.rendererOptions; + delete copy.localeStrings; + context.sendAction('onRefresh', copy); + }; + } +}); diff --git a/app/components/query-blurb.js b/app/components/query-blurb.js index d0839ccb..63e8d8b1 100644 --- a/app/components/query-blurb.js +++ b/app/components/query-blurb.js @@ -7,5 +7,5 @@ import Ember from 'ember'; export default Ember.Component.extend({ classNames: ['query-blurb'], - query: null + summary: null }); diff --git a/app/components/records-charter.js b/app/components/records-charter.js new file mode 100644 index 00000000..40c44d7b --- /dev/null +++ b/app/components/records-charter.js @@ -0,0 +1,165 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import Ember from 'ember'; + +export default Ember.Component.extend({ + classNames: ['records-charter'], + model: null, + columns: null, + rows: null, + chartType: 'bar', + simpleMode: true, + + cannotModeSwitch: Ember.computed.alias('model.isRaw').readOnly(), + canModeSwitch: Ember.computed.not('cannotModeSwitch').readOnly(), + notSimpleMode: Ember.computed.not('simpleMode').readOnly(), + pivotMode: Ember.computed.or('notSimpleMode', 'cannotModeSwitch').readOnly(), + pivotOptions: Ember.computed('model.pivotOptions', function() { + return JSON.parse(this.get('model.pivotOptions')); + }).readOnly(), + + sampleRow: Ember.computed('rows', 'columns', function() { + let typicalRow = { }; + let rows = this.get('rows'); + this.get('columns').forEach(column => { + for (let row of rows) { + let value = row[column]; + if (!Ember.isEmpty(value)) { + typicalRow[column] = value; + break; + } + } + }); + return typicalRow; + }), + + independentColumns: Ember.computed('model', 'sampleRow', 'columns', function() { + let { columns, sampleRow } = this.getProperties('columns', 'sampleRow'); + let isDistribution = this.get('model.isDistribution'); + if (isDistribution) { + return Ember.A(columns.filter(c => this.isAny(c, 'Quantile', 'Range'))); + } + // Pick all string columns + return Ember.A(columns.filter(c => this.isType(sampleRow, c, 'string'))); + }), + + dependentColumns: Ember.computed('model', 'sampleRow', 'columns', function() { + let { columns, sampleRow } = this.getProperties('columns', 'sampleRow'); + let isDistribution = this.get('model.isDistribution'); + if (isDistribution) { + return Ember.A(columns.filter(c => this.isAny(c, 'Count', 'Value', 'Probability'))); + } + // Pick all number columns + return Ember.A(columns.filter(c => this.isType(sampleRow, c, 'number'))); + }), + + options: Ember.computed('dependentColumns', function() { + let numberOfColumns = this.get('dependentColumns.length'); + if (numberOfColumns === 1) { + return { }; + } + // Everything else, 2 axes + return { + scales: { + yAxes: [{ + position: 'left', + id: '0' + }, { + position: 'right', + id: '1' + }] + } + }; + }), + + labels: Ember.computed('independentColumns', 'rows', function() { + // Only one independent column for now + let rows = this.get('rows'); + // [ [field1 values...], [field2 values...], ...] + let valuesList = this.get('independentColumns').map(field => this.getFieldValues(field, rows)); + // valuesList won't be empty because all non-Raw aggregations will have at least one string field + return this.zip(valuesList); + }), + + datasets: Ember.computed('dependentColumns', 'rows', function() { + let dependentColumns = this.get('dependentColumns'); + let rows = this.get('rows'); + return dependentColumns.map((c, i) => this.dataset(c, rows, i)); + }), + + data: Ember.computed('labels', 'datasets', function() { + return { + labels: this.get('labels'), + datasets: this.get('datasets') + }; + }), + + dataset(column, rows, index) { + let values = this.getFieldValues(column, rows); + let dataset = { + label: column, + data: values, + backgroundColor: this.randomColors(values.length) + }; + // Add yAxisID only if we have more than one dataset. More than 2 => Add the first y-axis + if (index === 1) { + dataset.yAxisID = '1'; + } else if (index > 1) { + dataset.yAxisID = '0'; + } + return dataset; + }, + + randomUpto(size) { + return Math.floor(Math.random() * size); + }, + + randomColor() { + let red = this.randomUpto(255); + let green = this.randomUpto(255); + let blue = this.randomUpto(255); + return `rgb(${red},${green},${blue})`; + }, + + randomColors(size) { + let color = this.randomColor(); + return new Array(size).fill(color); + }, + + isType(row, field, type) { + return Ember.isEqual(Ember.typeOf(row[field]), type); + }, + + isAny(field, ...values) { + for (let value of values) { + if (Ember.isEqual(field, value)) { + return true; + } + } + return false; + }, + + zip(arrayOfArrays, delimiter = '/') { + let zipped = arrayOfArrays[0].map((_, i) => arrayOfArrays.map(a => a[i])); + return zipped.map(a => a.reduce((p, c) => `${p}${delimiter}${c}`), ''); + }, + + getFieldValues(field, rows) { + return rows.map(row => row[field]); + }, + + actions: { + toggleMode() { + this.toggleProperty('simpleMode'); + }, + + saveOptions(options) { + let model = this.get('model'); + model.set('pivotOptions', JSON.stringify(options)); + model.save(); + } + } +}); diff --git a/app/components/records-viewer.js b/app/components/records-viewer.js index 29eff229..3fe6f89f 100644 --- a/app/components/records-viewer.js +++ b/app/components/records-viewer.js @@ -8,10 +8,17 @@ import Ember from 'ember'; export default Ember.Component.extend({ fileSaver: Ember.inject.service(), classNames: ['records-viewer'], + showRawData: false, showTable: false, + showChart: false, + // Copy of the model + model: null, + metadata: null, records: null, fileName: 'results', + enableCharting: Ember.computed.not('model.isSingleRow').readOnly(), + columns: Ember.computed('records', function() { return Ember.A(this.extractUniqueColumns(this.get('records'))); }).readOnly(), @@ -98,13 +105,24 @@ export default Ember.Component.extend({ return flattened; }, + flipTo(field) { + this.set('showRawData', false); + this.set('showTable', false); + this.set('showChart', false); + this.set(field, true); + }, + actions: { - showRawData() { - this.set('showTable', false); + rawDataMode() { + this.flipTo('showRawData'); + }, + + tableMode() { + this.flipTo('showTable'); }, - showTable() { - this.set('showTable', true); + chartMode() { + this.flipTo('showChart'); }, downloadAsJSON() { diff --git a/app/components/results-table.js b/app/components/results-table.js index c30fa673..8cee9dd4 100644 --- a/app/components/results-table.js +++ b/app/components/results-table.js @@ -36,12 +36,5 @@ export default Ember.Component.extend(PaginatedTable, { this._super(...arguments); this.set('table', new Table(this.get('columns'))); this.addPages(1); - }, - - actions: { - resultClick(result) { - this.sendAction('resultClick', result); - } } }); - diff --git a/app/initializers/startup.js b/app/initializers/startup.js index 105ac43e..478d6ca2 100644 --- a/app/initializers/startup.js +++ b/app/initializers/startup.js @@ -16,9 +16,7 @@ export default { decodedSettings = JSON.parse(decodeURIComponent(metaSettings)); } // Merge into default settings, overriding them - let settings = { }; - Ember.merge(settings, ENV.APP.SETTINGS); - Ember.merge(settings, decodedSettings); + let settings = this.deepMergeSettings(decodedSettings); application.register('settings:main', Ember.Object.create(settings), { instantiate: false }); application.inject('service', 'settings', 'settings:main'); @@ -27,24 +25,17 @@ export default { application.inject('model', 'settings', 'settings:main'); application.inject('controller', 'settings', 'settings:main'); application.inject('component', 'settings', 'settings:main'); - - let version = settings.modelVersion; - this.applyMigrations(version); - localStorage.modelVersion = version; }, - /** - * Applies any forced migrations for local storage. Currently, only wipes localStorage - * if version is greater than the stored version or if stored version is not present. - * @param {Number} version A numeric version to compare the current stored version against. - * @return {Boolean} Denoting whether local storage was modified. - */ - applyMigrations(version) { - let currentVersion = localStorage.modelVersion; - if (!currentVersion || version > currentVersion) { - localStorage.clear(); - return true; - } - return false; + deepMergeSettings(overrides) { + let settings = JSON.parse(JSON.stringify(ENV.APP.SETTINGS)); + Ember.$.extend(true, settings, overrides); + + // Handle arrays manually + let helpLinks = []; + Ember.$.merge(helpLinks, ENV.APP.SETTINGS.helpLinks || []); + Ember.$.merge(helpLinks, overrides.helpLinks || []); + settings.helpLinks = helpLinks; + return settings; } }; diff --git a/app/instance-initializers/migrations.js b/app/instance-initializers/migrations.js new file mode 100644 index 00000000..b03ed26c --- /dev/null +++ b/app/instance-initializers/migrations.js @@ -0,0 +1,41 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import Ember from 'ember'; + +export function initialize(application) { + let settings = application.lookup('settings:main'); + let migrations = settings.get('migrations'); + let version = settings.get('modelVersion'); + + let currentVersion = window.localStorage.modelVersion; + if (!currentVersion || version > currentVersion) { + let manager = application.lookup('service:queryManager'); + applyMigrations(manager, migrations); + } + window.localStorage.modelVersion = version; +} + +/** + * Applies any forced migrations for local storage. + * @param {Object} manager The query manager. + * @param {Object} migrations An object containing migrations to apply. + */ +export function applyMigrations(manager, migrations) { + let deletions = migrations.deletions; + // Only support clearing everything or results at the moment + if (Ember.isEqual(deletions, 'result')) { + Ember.Logger.log('Deleting all results'); + manager.deleteAllResults(); + } else if (Ember.isEqual(deletions, 'query')) { + Ember.Logger.log('Deleting all queries'); + window.localStorage.clear(); + } +} + +export default { + name: 'migrations', + initialize +}; diff --git a/app/models/query.js b/app/models/query.js index 6d5f3125..bc189882 100644 --- a/app/models/query.js +++ b/app/models/query.js @@ -126,10 +126,6 @@ export default DS.Model.extend(Validations, { return max; }).readOnly(), - isRawWithoutFields: Ember.computed('projections.[]', 'aggregation.type', function() { - return this.get('aggregation.type') === AGGREGATIONS.get('RAW') && Ember.isEmpty(this.get('projections')); - }), - summarizeFieldLike(fieldLike) { return Ember.isEmpty(fieldLike) ? '' : fieldLike.getEach('name').reject((n) => Ember.isEmpty(n)).join(', '); } diff --git a/app/models/result.js b/app/models/result.js index 754c08a0..034c666e 100644 --- a/app/models/result.js +++ b/app/models/result.js @@ -3,8 +3,9 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ -import DS from 'ember-data'; import Ember from 'ember'; +import DS from 'ember-data'; +import { AGGREGATIONS } from 'bullet-ui/models/aggregation'; export default DS.Model.extend({ metadata: DS.attr({ @@ -22,5 +23,27 @@ export default DS.Model.extend({ return new Date(Date.now()); } }), - query: DS.belongsTo('query', { autoSave: true }) + query: DS.belongsTo('query', { autoSave: true }), + querySnapshot: DS.attr(), + pivotOptions: DS.attr('string'), + + isRaw: Ember.computed.equal('querySnapshot.type', AGGREGATIONS.get('RAW')), + isCountDistinct: Ember.computed.equal('querySnapshot.type', AGGREGATIONS.get('COUNT_DISTINCT')), + isGroup: Ember.computed.equal('querySnapshot.type', AGGREGATIONS.get('GROUP')), + isDistribution: Ember.computed.equal('querySnapshot.type', AGGREGATIONS.get('DISTRIBUTION')), + isTopK: Ember.computed.equal('querySnapshot.type', AGGREGATIONS.get('TOP_K')), + + isReallyRaw: Ember.computed('isRaw', 'querySnapshot.projectionsSize', function() { + return this.get('isRaw') && this.get('querySnapshot.projectionsSize') === 0; + }), + + isGroupAll: Ember.computed('isGroup', 'querySnapshot.groupsSize', function() { + return this.get('isGroup') && this.get('querySnapshot.groupsSize') === 0; + }), + + isGroupBy: Ember.computed('isGroup', 'querySnapshot.metricsSize', 'querySnapshot.groupsSize', function() { + return this.get('isGroup') && this.get('querySnapshot.metricsSize') >= 1 && this.get('querySnapshot.groupsSize') >= 1; + }), + + isSingleRow: Ember.computed.or('isCountDistinct', 'isGroupAll') }); diff --git a/app/routes/query.js b/app/routes/query.js index 3e0efde1..363e24bd 100644 --- a/app/routes/query.js +++ b/app/routes/query.js @@ -7,18 +7,11 @@ import Ember from 'ember'; export default Ember.Route.extend({ querier: Ember.inject.service(), + queryManager: Ember.inject.service(), resultHandler(data, context) { context.set('pendingRequest', null); - context.store.findRecord('query', context.paramsFor('query').query_id).then((query) => { - let result = context.store.createRecord('result', { - metadata: data.meta, - records: data.records, - query: query - }); - result.save(); - query.set('lastRun', result.get('created')); - query.save(); + context.get('queryManager').addResult(context.paramsFor('query').query_id, data).then(result => { context.transitionTo('result', result.get('id')); }); }, diff --git a/app/services/query-manager.js b/app/services/query-manager.js index 75dd2ffb..46519436 100644 --- a/app/services/query-manager.js +++ b/app/services/query-manager.js @@ -69,6 +69,28 @@ export default Ember.Service.extend({ return childModel.save(); }, + addResult(id, data) { + return this.get('store').findRecord('query', id).then((query) => { + let result = this.get('store').createRecord('result', { + metadata: data.meta, + records: data.records, + querySnapshot: { + type: query.get('aggregation.type'), + groupsSize: query.get('aggregation.groups.length'), + metricsSize: query.get('aggregation.metrics.length'), + projectionsSize: query.get('projections.length'), + fieldsSummary: query.get('fieldsSummary'), + filterSummary: query.get('filterSummary') + }, + query: query + }); + result.save(); + query.set('lastRun', result.get('created')); + query.save(); + return result; + }); + }, + setAggregationAttributes(query, fields) { return query.get('aggregation').then(aggregation => { fields.forEach(field => { @@ -190,17 +212,21 @@ export default Ember.Service.extend({ deleteMultiple(name, model, inverseName) { return model.get(name).then(items => { - items.toArray().forEach(item => { + let promises = items.toArray().map(item => { items.removeObject(item); item.set(inverseName, null); item.destroyRecord(); }); + return Ember.RSVP.all(promises); }); }, deleteProjections(query) { return query.get('projections').then((p) => { - p.forEach(i => i.destroyRecord()); + let promises = p.toArray().map(item => { + item.destroyRecord(); + }); + return Ember.RSVP.all(promises); }); }, @@ -214,15 +240,25 @@ export default Ember.Service.extend({ }, deleteResults(query) { - this.deleteMultiple('results', query, 'query'); - query.save(); + this.deleteMultiple('results', query, 'query').then(() => { + query.save(); + }); }, deleteQuery(query) { - this.deleteSingle('filter', query, 'query'); - this.deleteMultiple('projections', query, 'query'); - this.deleteMultiple('results', query, 'query'); - this.deleteAggregation(query); - query.destroyRecord(); + return Ember.RSVP.all([ + this.deleteSingle('filter', query, 'query'), + this.deleteMultiple('projections', query, 'query'), + this.deleteMultiple('results', query, 'query'), + this.deleteAggregation(query) + ]).then(() => { + query.destroyRecord(); + }); + }, + + deleteAllResults() { + this.get('store').findAll('query').then(queries => { + queries.forEach(q => this.deleteResults(q)); + }); } }); diff --git a/app/styles/components/output-data-input.scss b/app/styles/components/output-data-input.scss index 6fc30cc8..265327df 100644 --- a/app/styles/components/output-data-input.scss +++ b/app/styles/components/output-data-input.scss @@ -1,11 +1,14 @@ .output-data-input { - $box-rectangle-color: #F6F6F6; $border-rectangle-color: #DCDEE2; + $subsection-color: #8C8C8C; @import "info-popover"; - padding-top: 5px; - padding-left: 10px; + padding: 15px; + margin-top: 5px; + margin-bottom: 15px; max-width: 800px; + background-color: $box-rectangle-color; + position: relative; .ember-radio-button { display: block; @@ -14,6 +17,9 @@ font-size: $font-size-regular-small; font-family: $font-family-regular; font-weight: $font-weight-regular; + &.checked { + font-weight: $font-weight-strong; + } input { margin-right: 10px; } @@ -29,18 +35,15 @@ .output-options { display: flex; justify-content: space-between; - .ember-radio-button { - font-size: $font-size-regular; - } } .output-container { @import "validated-input"; - margin-left: $output-option-indentation; - margin-bottom: 20px; .help { - float: right; + position: absolute; + bottom: 10px; + right: 10px; font-size: $font-size-regular-small; font-family: $font-family-regular; font-weight: $font-weight-regular; @@ -70,15 +73,17 @@ } - .subsection-header { - margin-top: 5px; - margin-bottom: 10px; - text-transform: uppercase; - font-weight: $font-weight-medium; - } - .narrow-row { margin-top: 5px; } } + + .subsection-header { + margin: 5px 0; + text-transform: uppercase; + font-size: $font-size-small; + font-weight: $font-weight-medium; + color: $subsection-color; + opacity: 0.9; + } } diff --git a/app/styles/components/pivot-table.scss b/app/styles/components/pivot-table.scss new file mode 100644 index 00000000..54c57b97 --- /dev/null +++ b/app/styles/components/pivot-table.scss @@ -0,0 +1,15 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.pivot-table-container { + width: 100%; + overflow: auto; + font-size: $font-size-regular-small; + font-family: $font-family-regular; + + .pvtUi { + margin: auto; + } +} diff --git a/app/styles/components/query-builder.scss b/app/styles/components/query-builder.scss index 6e5291e6..37eff04d 100644 --- a/app/styles/components/query-builder.scss +++ b/app/styles/components/query-builder.scss @@ -6,9 +6,9 @@ // Overrides to the QueryBuilder styling .builder { - $logical-button-active-color: #6E54A2; - $logical-button-inactive-hover-color: #E9E9E9; - $logical-button-inactive-color: #D3D3D3; + $logical-button-active-color: $special-button-active-color; + $logical-button-inactive-hover-color: $special-button-inactive-hover-color; + $logical-button-inactive-color: $special-button-inactive-color; $rule-background-color: $background-box-color; $rule-background-hover-color: $background-box-hover-color; $rule-background-error-color: $background-box-error-color; @@ -197,7 +197,6 @@ select { appearance: none; -ms-appearance: none; - -o-appearance: none; -moz-appearance: none; -webkit-appearance: none; &::-ms-expand { diff --git a/app/styles/components/query-input.scss b/app/styles/components/query-input.scss index b5527e15..2afd5174 100644 --- a/app/styles/components/query-input.scss +++ b/app/styles/components/query-input.scss @@ -8,6 +8,7 @@ $fill-icon-color: $inactive-grey; $error-icon-color: $error-red; $error-icon-hover-color: #FF9693; + $box-rectangle-color: #F6F6F6; $background-box-color: $background-grey; $background-box-error-color: #FFE1E0; $background-box-hover-color: $background-grey-darker; @@ -173,7 +174,7 @@ @import "validated-input"; margin-top: 10px; - margin-left: 0; + margin-left: 5px; input { height: 45px; } diff --git a/app/styles/components/records-charter.scss b/app/styles/components/records-charter.scss new file mode 100644 index 00000000..fe42448e --- /dev/null +++ b/app/styles/components/records-charter.scss @@ -0,0 +1,50 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +.records-charter { + $mode-active-color: $special-button-active-color; + $mode-inactive-hover-color: $special-button-inactive-hover-color; + $mode-inactive-color: $special-button-inactive-color; + + margin: auto; + + .mode-toggle { + display: flex; + justify-content: center; + margin-top: 20px; + margin-bottom: 40px; + + .mode { + width: 70px; + padding: 0; + background-color: $mode-inactive-color; + color: $white; + height: 28px; + font-weight: $font-weight-regular; + font-family: $font-family-regular; + font-size: $font-size-regular-small; + &.selected { + background-color: $mode-active-color; + } + &:hover:not(.selected) { + background-color: $mode-inactive-hover-color + } + &:focus { + outline: 0; + } + // Two buttons, first is curved to left, second to the right + &.simple-view { + border-radius: $button-radius-large 0 0 $button-radius-large; + } + &.advanced-view { + border-radius: 0 $button-radius-large $button-radius-large 0; + } + } + } + + .visual-container { + @import 'pivot-table'; + } +} diff --git a/app/styles/components/records-viewer.scss b/app/styles/components/records-viewer.scss index 1876388d..7b564d7f 100644 --- a/app/styles/components/records-viewer.scss +++ b/app/styles/components/records-viewer.scss @@ -5,9 +5,15 @@ */ .records-viewer { $records-header-color: #00C877; - margin-right: $results-right-space; + // Make unselectable + -moz-user-select: -moz-none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + + .records-title { @import "section-heading-with-help"; @@ -29,6 +35,9 @@ } .view-controls { + display: flex; + justify-content: flex-end; + width: 180px; .view-control { margin-left: 15px; border: 2px solid $secondary-button-color; @@ -40,26 +49,46 @@ vertical-align: -1px; } background-color: $white; - width: 30px; - height: 30px; - &:hover { + width: 32px; + height: 32px; + + &:not(.active):hover { border-color: $secondary-button-hover-color; - i { + i, span { color: $secondary-button-hover-color; } } &:focus { outline: none; } + &.active { + // From bootstrap-sass + @include box-shadow(inset 0 3px 5px rgba(0,0,0,.25)); + } } - .raw-view, .table-view { - padding-left: 7px; + .table-view > i { + padding-left: 2px; } + .raw-view { + display: flex; + justify-content: center; + align-items: center; + font-size: $font-size-small; + vertical-align: 0px; + i:first-of-type { + padding-left: 1px; + } + .slash { + align-self: flex-start; + font-size: $font-size-regular; + color: $secondary-button-color; + } + } + .download { padding-right: 6px; } - .download-options { left: -50px; top: 40px; @@ -90,19 +119,19 @@ color: white; border: 9px solid transparent; border-bottom-color: $primary-button-color; - top: -18px; + top: -19px; right: 4px; - z-index: -2; + z-index: -3; .inner { position: relative; width: 0; height: 0; color: $white; - left: -7px; - top: -6px; - border: 7px solid transparent; + left: -9px; + top: -8px; + border: 9px solid transparent; border-bottom-color: $white; - z-index: -1; + z-index: -2; } } } @@ -110,6 +139,8 @@ } } .records-display { + @import "pretty-json"; @import "records-table"; + @import "records-charter"; } -} \ No newline at end of file +} diff --git a/app/styles/components/validated-field-selection.scss b/app/styles/components/validated-field-selection.scss index 6e83a53d..3b557328 100644 --- a/app/styles/components/validated-field-selection.scss +++ b/app/styles/components/validated-field-selection.scss @@ -9,8 +9,8 @@ background-color: $background-box-color; padding: $spacing-vertical 0 $spacing-vertical 0; margin: 4px 0; - border: 1px solid $white; border-radius: 5px; + outline: none; &:hover { background-color: $background-box-hover-color; diff --git a/app/styles/result.scss b/app/styles/result.scss index 14f4f85a..ebc455bb 100644 --- a/app/styles/result.scss +++ b/app/styles/result.scss @@ -98,7 +98,6 @@ $information-wrapper-padding-right: 35px; } .result-container { - @import "components/pretty-json"; @import "components/records-viewer"; @import "components/no-results-help"; pre { diff --git a/app/styles/variables.scss b/app/styles/variables.scss index d9da782b..e838da10 100644 --- a/app/styles/variables.scss +++ b/app/styles/variables.scss @@ -37,6 +37,10 @@ $button-radius-regular: 4px; $button-radius-large: 5px; $button-radius-circle: 30px; +$special-button-active-color: #6E54A2; +$special-button-inactive-hover-color: #E9E9E9; +$special-button-inactive-color: #D3D3D3; + $font-size-small: 11px; $font-size-regular-small: 13px; $font-size-regular: 15px; diff --git a/app/templates/components/cells/query-name-entry.hbs b/app/templates/components/cells/query-name-entry.hbs index 6a474e34..696ca282 100644 --- a/app/templates/components/cells/query-name-entry.hbs +++ b/app/templates/components/cells/query-name-entry.hbs @@ -7,7 +7,7 @@ {{#if row.name}}

{{row.name}}

{{else}} - {{query-blurb query=row}} + {{query-blurb summary=row}} {{/if}}
@@ -20,4 +20,4 @@ {{#tooltip-on-element}}Delete Query and Historical Results{{/tooltip-on-element}} -
\ No newline at end of file + diff --git a/app/templates/components/output-data-input.hbs b/app/templates/components/output-data-input.hbs index 527b047c..63b0e567 100644 --- a/app/templates/components/output-data-input.hbs +++ b/app/templates/components/output-data-input.hbs @@ -4,6 +4,7 @@ See the LICENSE file associated with the project for terms. --}} +
Select your Output Type
{{#radio-button radioId="raw" value=AGGREGATIONS.RAW groupValue=outputDataType changed=(action "addRawAggregation" false) disabled=disabled}} Raw Data @@ -29,7 +30,7 @@
{{#if isRawAggregation}} -
Choose your Fields
+
Select your Fields
{{#radio-button radioId="all" value=RAWS.ALL groupValue=rawType changed=(action "deleteProjections") disabled=disabled}} Show All Fields @@ -58,7 +59,7 @@ {{#if isGroupAggregation}}
-
Choose Group Fields
+
Select Group Fields
{{#each query.aggregation.groups as |group|}} {{validated-field-selection model=group columns=columns subfieldSuffix=subfieldSuffix subfieldSeparator=subfieldSeparator disabled=disabled forceDirty=forceDirty fieldClasses="col-xs-6" nameClasses="col-xs-5" @@ -73,7 +74,7 @@
-
Choose Metric Fields
+
Select Metric Fields
{{#each query.aggregation.metrics as |metric|}} {{#validated-field-selection model=metric columns=columns subfieldSuffix=subfieldSuffix subfieldSeparator=subfieldSeparator disabled=disabled forceDirty=forceDirty fieldClasses="col-xs-5" nameClasses="col-xs-3" disableField=metric.hasNoField @@ -97,7 +98,7 @@ {{#if isCountDistinctAggregation}}
-
Choose your Fields
+
Select your Fields
{{#each query.aggregation.groups as |field|}} {{validated-field-selection model=field columns=columns subfieldSuffix=subfieldSuffix subfieldSeparator=subfieldSeparator @@ -119,7 +120,7 @@ {{#if isDistributionAggregation}}
-
Choose Type
+
Select Distribution
{{#radio-button radioId="quantile" value=DISTRIBUTIONS.QUANTILE groupValue=distributionType changed=(action "modifyDistributionType" DISTRIBUTIONS.QUANTILE) disabled=disabled}} @@ -135,7 +136,7 @@ {{/radio-button}}
-
Choose a Field
+
Select a Field
{{#each query.aggregation.groups as |field|}} {{validated-field-selection model=field columns=columns subfieldSuffix=subfieldSuffix subfieldSeparator=subfieldSeparator @@ -144,7 +145,7 @@ {{/each}}
-
Choose how your Points are created
+
Select how your Points are created
{{#radio-button radioId="number-points" value=DISTRIBUTION_POINTS.NUMBER groupValue=pointType changed=(action "modifyDistributionPointType" DISTRIBUTION_POINTS.NUMBER) disabled=disabled}} @@ -180,7 +181,7 @@ {{#if isTopKAggregation}}
-
Choose Fields
+
Select Fields
{{#each query.aggregation.groups as |field|}} {{validated-field-selection model=field columns=columns subfieldSuffix=subfieldSuffix subfieldSeparator=subfieldSeparator disabled=disabled @@ -192,7 +193,7 @@ Field
-
Choose the maximum number of results
+
Select the maximum number of results
{{validated-input inputClassNames="col-xs-2" model=query.aggregation valuePath="size" fieldName="K" disabled=isListening}}
diff --git a/app/templates/components/pivot-table.hbs b/app/templates/components/pivot-table.hbs new file mode 100644 index 00000000..2e82966b --- /dev/null +++ b/app/templates/components/pivot-table.hbs @@ -0,0 +1 @@ +
diff --git a/app/templates/components/query-blurb.hbs b/app/templates/components/query-blurb.hbs index eed42928..3d2cfee2 100644 --- a/app/templates/components/query-blurb.hbs +++ b/app/templates/components/query-blurb.hbs @@ -4,8 +4,8 @@ See the LICENSE file associated with the project for terms. --}}
- Filters: {{query.filterSummary}} + Filters: {{summary.filterSummary}}
- Fields: {{query.fieldsSummary}} -
\ No newline at end of file + Fields: {{summary.fieldsSummary}} +
diff --git a/app/templates/components/records-charter.hbs b/app/templates/components/records-charter.hbs new file mode 100644 index 00000000..69beea17 --- /dev/null +++ b/app/templates/components/records-charter.hbs @@ -0,0 +1,18 @@ +{{!-- + Copyright 2016, Yahoo Inc. + Licensed under the terms of the Apache License, Version 2.0. + See the LICENSE file associated with the project for terms. + --}} + {{#if canModeSwitch}} +
+ + +
+ {{/if}} +
+ {{#if pivotMode}} + {{pivot-table rows=rows columns=columns initialOptions=pivotOptions onRefresh=(action "saveOptions")}} + {{else}} + {{ember-chart type=chartType data=data options=options}} + {{/if}} +
diff --git a/app/templates/components/records-viewer.hbs b/app/templates/components/records-viewer.hbs index 11b57a91..902482ef 100644 --- a/app/templates/components/records-viewer.hbs +++ b/app/templates/components/records-viewer.hbs @@ -17,17 +17,22 @@
- {{#if showTable}} - - {{else}} - {{/if}} + +
- {{#if showTable}} - {{records-table columnNames=columns rawRows=records}} - {{else}} + {{#if showRawData}} {{pretty-json data=records}} + {{else if showTable}} + {{records-table columnNames=columns rawRows=records}} + {{else if showChart}} + {{records-charter model=model columns=columns rows=records}} {{/if}} -
\ No newline at end of file +
diff --git a/app/templates/partials/-results-help.hbs b/app/templates/partials/-results-help.hbs index 33b15c96..f252304c 100644 --- a/app/templates/partials/-results-help.hbs +++ b/app/templates/partials/-results-help.hbs @@ -4,30 +4,46 @@ See the LICENSE file associated with the project for terms. --}}

- Once Bullet has finished running the query, all the matched records are returned to the browser. The results can be displayed either as raw JSON format or formatted in a tabular view. Toggle between the views by clicking Show Raw Data View or Show Table View icons. + Once Bullet has finished running the query, all the matched records are returned to the browser. The results can be displayed either as raw JSON format, formatted in a tabular view, displayed as a chart or pivoted on. Toggle between the views by clicking Show Raw Data or Show as Table or Show as Chart icons.

Saving and Sharing
-

- The output can be saved locally as JSON, CSV, or a flattened CSV (where any map fields and lists are exploded till the base elements) -

-

- It is not possible to natively share query or result URLs from Bullet since all queries and results are stored on the local browser only. -

+

The output can be saved locally as JSON, CSV, or a flattened CSV (where any map fields and lists are exploded till the base elements)

+

It is not possible to natively share query or result URLs from Bullet since all queries and results are stored on the local browser only.

Raw JSON View
-If you selected the "Show All Fields” option under the Raw Data selection on the “Edit Query” page, or clicked on Show Raw Data View toggle at the top right of the page, the data is displayed in a raw JSON format. +

If you selected the Show All Fields option under the Raw Data selection on the Query page, or clicked on Show Raw Data view toggle at the top right of the page, the data is displayed in a raw JSON format.

Table View
+

If you elected to aggregate data or chose to Select Fields under the Raw Data selection on the query page, or clicked on the Show as Table button, the data is displayed in a table format.

+
    +
  • If a table cell is clicked on, a popup window appears with a JSON representation of the cell data. This is particularly useful for long strings, maps, and other complex data types
  • +
  • Columns width can be adjusted by dragging the vertical column divider at the top of the table
  • +
  • Columns can be rearranged by dragging and dropping them in the desired location
  • +
  • Columns can be also be sorted by clicking on their headers
  • +
+ +
Chart View

- If you elected to Aggregate Data or chose “Select Fields” under the Raw Data selection on the “Edit Query” page, or clicked on the Show as Table at the top right of the page, the data is displayed in a table format. If a table cell is clicked on, a popup window appears with a JSON representation of the cell data. This is particularly useful for long strings, maps, and other complex data types. + Unless you performed a Group All query (a Group query with no Group Fields) or a Count Distinct query, you will see a Show as Chart option. This option graphs your data as a chart with a + guess as to what your independent and dependent fields are. There are two sub-options that appear in this mode. You can see the pre-determined chart as described before by default or + by clicking the Chart sub-option. You can also click the Pivot option to to pivot and aggregate your result data in various forms by using various aggregations and dragging/dropping + various fields to slice and dice.

+ +
Simple Chart

- Columns width can be adjusted by dragging the vertical column divider at the top of the table. + For all queries besides Raw Data queries with Select all Fields, Bullet will try to find your independent fields by finding the string fields in your result and your dependent fields by using the numeric fields in this view. + If there are multiple independent fields, Bullet will separate them using a / in the chart. The chart is a Bar chart.

+ +

If you performed a Raw data query and chose to Show all Data, the Chart sub-option is disabled. You are only able to pivot on the data.

+ +
Pivot

- Columns can be rearranged by dragging and dropping them in the desired location. + If you find that the Chart option is not sufficient and you want aggregate your data some more, you can click the Pivot option. This will launch a UI where you can choose various + ways to display the data (Table by default) and choose various aggregation operations (Count by default). You can also drag and drop fields from a unused region to either a horizontal + or a vertical region to pivot on the data as you would in most spreadsheet applications.

-

- Columns can be also be sorted by clicking on their headers -

\ No newline at end of file + +

You can also click the dropdown next to each field to include or exclude various values for the visualization.

diff --git a/app/templates/result.hbs b/app/templates/result.hbs index ce07d52b..3872bd06 100644 --- a/app/templates/result.hbs +++ b/app/templates/result.hbs @@ -13,7 +13,7 @@

Query Definition

- {{query-blurb query=model.query}} + {{query-blurb query=model.query summary=model.querySnapshot}}
- {{#if model.records}} - {{#if model.query.isRawWithoutFields}} - {{records-viewer records=model.records showTable=false}} - {{else}} - {{records-viewer records=model.records showTable=true}} - {{/if}} + {{#if model.records}} + {{#if model.isReallyRaw}} + {{records-viewer model=model records=model.records metadata=model.metadata showRawData=true}} {{else}} - {{partial "partials/no-results-help"}} + {{records-viewer model=model records=model.records metadata=model.metadata showTable=true}} + {{/if}} + {{else}} + {{partial "partials/no-results-help"}} {{/if}}
-
\ No newline at end of file +
diff --git a/bower.json b/bower.json index e51b4082..da59882a 100644 --- a/bower.json +++ b/bower.json @@ -1,10 +1,14 @@ { - "name": "bullet-ui", - "dependencies": { - "filesaver": "1.3.2", - "interact": "^1.2.8", - "jQuery-QueryBuilder": "2.4.3", - "jQuery-QueryBuilder-Subfield": "yahoo/jQuery-QueryBuilder-Subfield#v1.0.1", - "jQuery-QueryBuilder-Placeholders": "yahoo/jQuery-QueryBuilder-Placeholders#v1.0.0" - } + "name": "bullet-ui", + "dependencies": { + "filesaver": "1.3.2", + "interact": "^1.2.8", + "jQuery-QueryBuilder": "2.4.3", + "jQuery-QueryBuilder-Subfield": "yahoo/jQuery-QueryBuilder-Subfield#v1.0.1", + "jQuery-QueryBuilder-Placeholders": "yahoo/jQuery-QueryBuilder-Placeholders#v1.0.0", + "chartjs": "2.5.0", + "pivottable": "^2.13.0", + "jquery-ui": "^1.12.1", + "c3": "^0.4.11" + } } diff --git a/config/env-settings.json b/config/env-settings.json index 9b8a2bcc..009d121f 100644 --- a/config/env-settings.json +++ b/config/env-settings.json @@ -12,7 +12,10 @@ } ], "bugLink": "https://github.com/yahoo/bullet-ui/issues", - "modelVersion": 1, + "modelVersion": 2, + "migrations": { + "deletions": "result" + }, "defaultValues": { "aggregationMaxSize": 512, "rawMaxSize": 100, diff --git a/ember-cli-build.js b/ember-cli-build.js index 2871f313..86b6091b 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -23,9 +23,6 @@ module.exports = function(defaults) { // along with the exports of each module as its value. // Query Builder - // app.import('bower_components/jquery/dist/jquery.js'); - // app.import('bower_components/bootstrap/dist/js/bootstrap.js'); - // app.import('bower_components/doT/doU.js'); app.import('bower_components/jquery-extendext/jQuery.extendext.js'); app.import('bower_components/doT/doT.js'); app.import('bower_components/interact/interact.js'); @@ -36,7 +33,27 @@ module.exports = function(defaults) { app.import('bower_components/jQuery-QueryBuilder-Subfield/query-builder-subfield.css'); app.import('bower_components/jQuery-QueryBuilder-Placeholders/query-builder-placeholders.js'); + // FileSaver app.import('bower_components/filesaver/FileSaver.js'); + // pivottable + app.import('bower_components/c3/c3.js'); + app.import('bower_components/c3/c3.css'); + app.import('bower_components/d3/d3.js'); + // Manually importing jquery-ui dependencies to keep clashes with bootstrap a minimum + // Just need the sortable plugin and all of its dependency chain + app.import('bower_components/jquery-ui/ui/version.js'); + app.import('bower_components/jquery-ui/ui/widget.js'); + app.import('bower_components/jquery-ui/ui/data.js'); + app.import('bower_components/jquery-ui/ui/ie.js'); + app.import('bower_components/jquery-ui/ui/plugin.js'); + app.import('bower_components/jquery-ui/ui/scroll-parent.js'); + app.import('bower_components/jquery-ui/ui/widgets/mouse.js'); + app.import('bower_components/jquery-ui/ui/widgets/sortable.js'); + // Core pivottable + app.import('bower_components/pivottable/dist/pivot.js'); + app.import('bower_components/pivottable/dist/pivot.css'); + app.import('bower_components/pivottable/dist/c3_renderers.js'); + return app.toTree(); }; diff --git a/package.json b/package.json index 9faa6005..c6ce4200 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "ember-cli-app-version": "^2.0.0", "ember-cli-babel": "^5.1.7", "ember-cli-bootstrap-sassy": "0.5.5", + "ember-cli-chart": "^3.2.0", "ember-cli-code-coverage": "0.3.11", "ember-cli-dependency-checker": "^1.3.0", "ember-cli-htmlbars": "^1.1.1", diff --git a/tests/acceptance/navigation-test.js b/tests/acceptance/navigation-test.js index 0a489031..451505fd 100644 --- a/tests/acceptance/navigation-test.js +++ b/tests/acceptance/navigation-test.js @@ -12,6 +12,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | navigation', { + suppressLogging: true, + beforeEach() { server = mockAPI(RESULTS.MULTIPLE, COLUMNS.BASIC); }, diff --git a/tests/acceptance/query-default-api-filter-test.js b/tests/acceptance/query-default-api-filter-test.js index 98603c48..a3d069ad 100644 --- a/tests/acceptance/query-default-api-filter-test.js +++ b/tests/acceptance/query-default-api-filter-test.js @@ -16,6 +16,8 @@ let url = 'http://foo.bar.com/api/filter'; let hit = 0; moduleForAcceptance('Acceptance | query default api filter', { + suppressLogging: true, + beforeEach() { // Inject into defaultValues in routes, our mock filter values this.application.register('settings:mocked', Ember.Object.create({ defaultFilter: url }), { instantiate: false }); diff --git a/tests/acceptance/query-default-filter-test.js b/tests/acceptance/query-default-filter-test.js index 021ba967..6e3df675 100644 --- a/tests/acceptance/query-default-filter-test.js +++ b/tests/acceptance/query-default-filter-test.js @@ -14,6 +14,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | query default filter', { + suppressLogging: true, + beforeEach() { // Inject into defaultValues in routes, our mock filter values this.application.register('settings:mocked', Ember.Object.create({ defaultFilter: FILTERS.AND_LIST }), { instantiate: false }); diff --git a/tests/acceptance/query-error-test.js b/tests/acceptance/query-error-test.js index 3e2580e5..0a509ba7 100644 --- a/tests/acceptance/query-error-test.js +++ b/tests/acceptance/query-error-test.js @@ -12,6 +12,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | query error', { + suppressLogging: true, + beforeEach() { server = mockAPI(RESULTS.MULTIPLE, COLUMNS.BASIC); }, diff --git a/tests/acceptance/query-firing-test.js b/tests/acceptance/query-firing-test.js index e7ccc405..08635f96 100644 --- a/tests/acceptance/query-firing-test.js +++ b/tests/acceptance/query-firing-test.js @@ -12,6 +12,8 @@ import { mockAPI, failAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | query firing', { + suppressLogging: true, + afterEach() { // Wipe out localstorage because we are creating here if (server) { diff --git a/tests/acceptance/query-lifecycle-test.js b/tests/acceptance/query-lifecycle-test.js index 41cda7df..dfd1e512 100644 --- a/tests/acceptance/query-lifecycle-test.js +++ b/tests/acceptance/query-lifecycle-test.js @@ -12,6 +12,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | query lifecycle', { + suppressLogging: true, + beforeEach() { // Wipe out localstorage because we are creating queries here window.localStorage.clear(); diff --git a/tests/acceptance/query-results-lifecycle-test.js b/tests/acceptance/query-results-lifecycle-test.js index 40f6b165..4a292e6d 100644 --- a/tests/acceptance/query-results-lifecycle-test.js +++ b/tests/acceptance/query-results-lifecycle-test.js @@ -13,6 +13,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | query results lifecycle', { + suppressLogging: true, + beforeEach() { // Wipe out localstorage because we are creating queries here window.localStorage.clear(); diff --git a/tests/acceptance/query-summarization-test.js b/tests/acceptance/query-summarization-test.js index 448358c9..425632be 100644 --- a/tests/acceptance/query-summarization-test.js +++ b/tests/acceptance/query-summarization-test.js @@ -12,6 +12,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | query summarization', { + suppressLogging: true, + beforeEach() { // Wipe out localstorage because we are creating queries here window.localStorage.clear(); diff --git a/tests/acceptance/query-validation-test.js b/tests/acceptance/query-validation-test.js index 450f880c..eff4714a 100644 --- a/tests/acceptance/query-validation-test.js +++ b/tests/acceptance/query-validation-test.js @@ -12,6 +12,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | query validation', { + suppressLogging: true, + beforeEach() { // Wipe out localstorage because we are creating here window.localStorage.clear(); diff --git a/tests/acceptance/result-error-test.js b/tests/acceptance/result-error-test.js index 036d4c65..075ace5c 100644 --- a/tests/acceptance/result-error-test.js +++ b/tests/acceptance/result-error-test.js @@ -6,7 +6,9 @@ import { test } from 'qunit'; import moduleForAcceptance from 'bullet-ui/tests/helpers/module-for-acceptance'; -moduleForAcceptance('Acceptance | result error'); +moduleForAcceptance('Acceptance | result error', { + suppressLogging: true +}); test('visiting a non-existant result', function(assert) { visit('/result/foo'); @@ -15,4 +17,3 @@ test('visiting a non-existant result', function(assert) { assert.equal(currentURL(), '/not-found'); }); }); - diff --git a/tests/acceptance/result-lifecycle-test.js b/tests/acceptance/result-lifecycle-test.js index 6f422181..0dd125fc 100644 --- a/tests/acceptance/result-lifecycle-test.js +++ b/tests/acceptance/result-lifecycle-test.js @@ -12,6 +12,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | result lifecycle', { + suppressLogging: true, + beforeEach() { // Wipe out localstorage because we are creating queries here window.localStorage.clear(); @@ -82,3 +84,111 @@ test('it lets you expand metadata in results', function(assert) { }); }); }); + +test('it lets swap between a row, tabular and advanced chart views when it is a raw query', function(assert) { + assert.expect(12); + + server = mockAPI(RESULTS.MULTIPLE, COLUMNS.BASIC); + + visit('/queries/new'); + click('.submit-button'); + click('.table-view'); + andThen(() => { + assert.equal(find('.records-charter').length, 0); + assert.equal(find('.pretty-json-container').length, 0); + assert.equal(find('.lt-body .lt-row .lt-cell').length, 9); + }); + click('.raw-view'); + andThen(() => { + assert.equal(find('.records-charter').length, 0); + assert.equal(find('.lt-body .lt-row .lt-cell').length, 0); + assert.equal(find('.pretty-json-container').length, 1); + }); + click('.chart-view'); + andThen(() => { + assert.equal(find('.lt-body .lt-row .lt-cell').length, 0); + assert.equal(find('.pretty-json-container').length, 0); + assert.equal(find('.records-charter').length, 1); + assert.equal(find('.pivot-table-container').length, 1); + assert.equal(find('.pvtUi').length, 1); + // Only pivot view + assert.equal(find('.records-chater .mode-toggle').length, 0); + }); +}); + +test('it lets swap between a row, tabular, simple and advanced chart views when it is not a raw query', function(assert) { + assert.expect(15); + + server = mockAPI(RESULTS.DISTRIBUTION, COLUMNS.BASIC); + + visit('/queries/new'); + click('.output-options #distribution'); + click('.output-container .distribution-point-options #points'); + selectChoose('.output-container .field-selection-container .field-selection', 'simple_column'); + fillIn('.output-container .distribution-type-points input', '0,0.2,1'); + click('.submit-button'); + + click('.raw-view'); + andThen(() => { + assert.equal(find('.records-charter').length, 0); + assert.equal(find('.lt-body .lt-row .lt-cell').length, 0); + assert.equal(find('.pretty-json-container').length, 1); + }); + click('.table-view'); + andThen(() => { + assert.equal(find('.records-charter').length, 0); + assert.equal(find('.pretty-json-container').length, 0); + assert.equal(find('.lt-body .lt-row .lt-cell').length, 9); + }); + click('.chart-view'); + andThen(() => { + assert.equal(find('.lt-body .lt-row .lt-cell').length, 0); + assert.equal(find('.pretty-json-container').length, 0); + assert.equal(find('.records-charter').length, 1); + assert.equal(find('.records-charter .mode-toggle').length, 1); + assert.ok(find('.mode-toggle .simple-view').hasClass('selected')); + assert.equal(find('.records-charter canvas').length, 1); + }); + click('.mode-toggle .advanced-view'); + andThen(() => { + assert.ok(find('.mode-toggle .advanced-view').hasClass('selected')); + assert.equal(find('.pivot-table-container').length, 1); + assert.equal(find('.pvtUi').length, 1); + }); +}); + +test('it saves and restores pivot table options', function(assert) { + assert.expect(7); + + server = mockAPI(RESULTS.DISTRIBUTION, COLUMNS.BASIC); + + visit('/queries/new'); + click('.output-options #distribution'); + click('.output-container .distribution-point-options #points'); + selectChoose('.output-container .field-selection-container .field-selection', 'simple_column'); + fillIn('.output-container .distribution-type-points input', '0,0.2,1'); + click('.submit-button'); + + click('.chart-view'); + click('.mode-toggle .advanced-view'); + andThen(() => { + assert.ok(find('.mode-toggle .advanced-view').hasClass('selected')); + assert.equal(find('.pivot-table-container').length, 1); + assert.equal(find('.pvtUi').length, 1); + assert.equal(find('.pvtUi select.pvtRenderer').val(), 'Table'); + find('.pivot-table-container select.pvtRenderer').val('Bar Chart').trigger('change'); + find('.pivot-table-container select.pvtAggregator').val('Sum').trigger('change'); + }); + visit('queries'); + andThen(() => { + assert.equal(find('.queries-table .query-results-entry .length-entry').text().trim(), '1 Results'); + }); + click('.queries-table .query-results-entry'); + click('.query-results-entry-popover .results-table .result-date-entry'); + click('.chart-view'); + click('.mode-toggle .advanced-view'); + andThen(() => { + assert.equal(find('.pvtUi select.pvtRenderer').val(), 'Bar Chart'); + assert.equal(find('.pvtUi select.pvtAggregator').val(), 'Sum'); + }); +}); diff --git a/tests/acceptance/schema-test.js b/tests/acceptance/schema-test.js index 6083e960..54395d61 100644 --- a/tests/acceptance/schema-test.js +++ b/tests/acceptance/schema-test.js @@ -12,6 +12,8 @@ import { mockAPI } from '../helpers/pretender'; let server; moduleForAcceptance('Acceptance | schema', { + suppressLogging: true, + beforeEach() { server = mockAPI(RESULTS.SINGLE, COLUMNS.BASIC); }, diff --git a/tests/fixtures/results.js b/tests/fixtures/results.js index afea66b2..8fdd4595 100644 --- a/tests/fixtures/results.js +++ b/tests/fixtures/results.js @@ -130,6 +130,49 @@ const RESULTS = { } ] }, + GROUP_MULTIPLE_METRICS: { + meta: { + wasEstimated: false, + standardDeviations: { + 1: { + upperBound: 3, + lowerBound: 3 + }, + 2: { + upperBound: 3, + lowerBound: 3 + }, + 3: { + upperBound: 3, + lowerBound: 3 + } + }, + uniquesEstimate: 3 + }, + records: [ + { + avg_bar: 42.3424, + sum_foo: 12, + COUNT: 1342, + foo: 'value1', + bar: 'value2' + }, + { + avg_bar: 22.02, + sum_foo: 92, + COUNT: 302, + foo: 'value1', + bar: null + }, + { + avg_bar: 92.3424, + sum_foo: 123192, + COUNT: 234342, + foo: null, + bar: 'value2' + } + ] + }, DISTRIBUTION: { meta: { wasEstimated: false, diff --git a/tests/helpers/mocked-query.js b/tests/helpers/mocked-query.js index 597b46c7..6b0ce146 100644 --- a/tests/helpers/mocked-query.js +++ b/tests/helpers/mocked-query.js @@ -39,8 +39,11 @@ export let MockMetric = Ember.Object.extend({ }); export let MockResult = Ember.Object.extend({ + metadata: null, records: null, - created: null + created: null, + querySnapshot: null, + pivotOptions: null }); export default Ember.Object.extend({ @@ -110,8 +113,8 @@ export default Ember.Object.extend({ this.nestedPropertyAsPromise('aggregation', 'metrics'); }, - addResult(records, created = new Date(Date.now())) { - this.get('_results').pushObject(MockResult.create({ records, created })); + addResult(records, created = new Date(Date.now()), metadata = null, querySnapshot = null, pivotOptions = null) { + this.get('_results').pushObject(MockResult.create({ records, created, metadata, querySnapshot, pivotOptions })); this.topLevelPropertyAsPromise('results'); }, diff --git a/tests/helpers/module-for-acceptance.js b/tests/helpers/module-for-acceptance.js index 930c5759..dc537825 100644 --- a/tests/helpers/module-for-acceptance.js +++ b/tests/helpers/module-for-acceptance.js @@ -11,16 +11,27 @@ import destroyApp from '../helpers/destroy-app'; const { RSVP: { Promise } } = Ember; export default function(name, options = {}) { + let logger; + const NOOP = () => { }; + const LOGGER = { log: NOOP, warn: NOOP, error: NOOP, assert: NOOP, debug: NOOP, info: NOOP }; + module(name, { beforeEach() { this.application = startApp(); + if (options.suppressLogging) { + [logger, Ember.Logger] = [Ember.Logger, LOGGER]; + } + if (options.beforeEach) { return options.beforeEach.apply(this, arguments); } }, afterEach() { + if (options.suppressLogging) { + Ember.Logger = logger; + } let afterEach = options.afterEach && options.afterEach.apply(this, arguments); return Promise.resolve(afterEach).then(() => destroyApp(this.application)); } diff --git a/tests/integration/components/pivot-table-test.js b/tests/integration/components/pivot-table-test.js new file mode 100644 index 00000000..003e0b0e --- /dev/null +++ b/tests/integration/components/pivot-table-test.js @@ -0,0 +1,52 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import RESULTS from '../../fixtures/results'; + +moduleForComponent('pivot-table', 'Integration | Component | pivot table', { + integration: true +}); + +test('it renders a pivot table', function(assert) { + assert.expect(1); + this.set('mockRows', RESULTS.GROUP_MULTIPLE_METRICS.records); + this.set('mockColumns', ['foo', 'bar', 'COUNT', 'avg_bar', 'sum_foo']); + this.set('mockOnRefresh', () => { }); + + this.render(hbs`{{pivot-table rows=mockRows columns=mockColumns initialOptions=mockOptions + onRefresh=(action mockOnRefresh)}}`); + assert.equal(this.$('.pvtUi select.pvtRenderer').val(), 'Table'); +}); + +test('it generates options by combining defaults with provided options', function(assert) { + assert.expect(1); + this.set('mockOnRefresh', (config) => { + assert.equal(config.rendererName, 'Heatmap'); + }); + this.set('mockRows', RESULTS.GROUP_MULTIPLE_METRICS.records); + this.set('mockColumns', ['foo', 'bar', 'COUNT', 'avg_bar', 'sum_foo']); + this.set('mockOptions', { rendererName: 'Heatmap' }); + + this.render(hbs`{{pivot-table rows=mockRows columns=mockColumns initialOptions=mockOptions + onRefresh=(action mockOnRefresh)}}`); +}); + +test('it removes certain options before returning the configuration', function(assert) { + assert.expect(4); + this.set('mockOnRefresh', (config) => { + assert.notOk(config.localeStrings); + assert.notOk(config.aggregators); + assert.notOk(config.renderers); + assert.notOk(config.rendererOptions); + }); + this.set('mockRows', RESULTS.GROUP_MULTIPLE_METRICS.records); + this.set('mockColumns', ['foo', 'bar', 'COUNT', 'avg_bar', 'sum_foo']); + this.set('mockOptions', { rendererOptions: { foo: 'bar' }, localeStrings: { bar: 'foo' } }); + + this.render(hbs`{{pivot-table rows=mockRows columns=mockColumns initialOptions=mockOptions + onRefresh=(action mockOnRefresh)}}`); +}); diff --git a/tests/integration/components/query-blurb-test.js b/tests/integration/components/query-blurb-test.js index e1fffc87..3016bcbb 100644 --- a/tests/integration/components/query-blurb-test.js +++ b/tests/integration/components/query-blurb-test.js @@ -37,7 +37,7 @@ test('it fully summarizes a query', function(assert) { query.addProjection('foo', 'f'); query.addProjection('bar', 'b'); this.set('mockedQuery', query); - this.render(hbs`{{query-blurb query=mockedQuery}}`); + this.render(hbs`{{query-blurb summary=mockedQuery}}`); let actualText = this.$().text(); let spaceLess = actualText.replace(/\s/g, ''); assert.equal(spaceLess, 'Filters:AnActualFilterSummaryFields:fb0.1,0.2'); diff --git a/tests/integration/components/records-charter-test.js b/tests/integration/components/records-charter-test.js new file mode 100644 index 00000000..f719095f --- /dev/null +++ b/tests/integration/components/records-charter-test.js @@ -0,0 +1,92 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the terms of the Apache License, Version 2.0. + * See the LICENSE file associated with the project for terms. + */ +import Ember from 'ember'; +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import wait from 'ember-test-helpers/wait'; +import RESULTS from '../../fixtures/results'; + +moduleForComponent('records-charter', 'Integration | Component | records charter', { + integration: true +}); + +test('it starts off in chart mode and allows you to switch to pivot mode', function(assert) { + assert.expect(5); + this.set('mockModel', Ember.Object.create({ isRaw: false, isDistribution: true, pivotOptions: null, save() { } })); + this.set('mockRows', RESULTS.DISTRIBUTION.records); + this.set('mockColumns', ['Probability', 'Count', 'Range']); + this.render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + + assert.ok(this.$('.mode-toggle .simple-view').hasClass('selected')); + assert.equal(this.$('.visual-container canvas').length, 1); + this.$('.mode-toggle .advanced-view').click(); + return wait().then(() => { + assert.ok(this.$('.mode-toggle .advanced-view').hasClass('selected')); + assert.equal(this.$('.visual-container .pivot-table-container').length, 1); + assert.equal(this.$('.visual-container .pivot-table-container .pvtUi').length, 1); + }); +}); + +test('it charts a single dependent column', function(assert) { + assert.expect(2); + this.set('mockModel', Ember.Object.create({ isRaw: false, pivotOptions: null, save() { } })); + this.set('mockRows', RESULTS.SINGLE.records); + this.set('mockColumns', ['foo', 'timestamp', 'domain']); + this.render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + assert.ok(this.$('.mode-toggle .simple-view').hasClass('selected')); + assert.equal(this.$('.visual-container canvas').length, 1); +}); + +test('it charts multiple dependent columns', function(assert) { + assert.expect(2); + this.set('mockModel', Ember.Object.create({ isRaw: false, pivotOptions: null, save() { } })); + this.set('mockRows', RESULTS.GROUP_MULTIPLE_METRICS.records); + this.set('mockColumns', ['foo', 'bar', 'COUNT', 'avg_bar', 'sum_foo']); + this.render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + assert.ok(this.$('.mode-toggle .simple-view').hasClass('selected')); + assert.equal(this.$('.visual-container canvas').length, 1); +}); + +test('it enables only the pivot mode if the results are raw', function(assert) { + assert.expect(3); + this.set('mockModel', Ember.Object.create({ isRaw: true, pivotOptions: null, save() { } })); + this.set('mockRows', RESULTS.SINGLE.records); + this.set('mockColumns', ['foo', 'timestamp', 'domain']); + this.render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + assert.equal(this.$('.mode-toggle').length, 0); + assert.equal(this.$('.visual-container .pivot-table-container').length, 1); + assert.equal(this.$('.visual-container .pivot-table-container .pvtUi').length, 1); +}); + +test('it saves pivot table configurations', function(assert) { + assert.expect(8); + this.set('mockModel', Ember.Object.create({ + isRaw: false, + isDistribution: true, + pivotOptions: null, + save() { + // Called twice + assert.ok(true); + } + })); + this.set('mockRows', RESULTS.DISTRIBUTION.records); + this.set('mockColumns', ['Probability', 'Count', 'Range']); + this.render(hbs`{{records-charter rows=mockRows columns=mockColumns model=mockModel}}`); + + assert.ok(this.$('.mode-toggle .simple-view').hasClass('selected')); + assert.equal(this.$('.visual-container canvas').length, 1); + this.$('.mode-toggle .advanced-view').click(); + return wait().then(() => { + assert.ok(this.$('.mode-toggle .advanced-view').hasClass('selected')); + assert.equal(this.$('.visual-container .pivot-table-container .pvtUi').length, 1); + assert.equal(this.$('.pvtUi select.pvtRenderer').val(), 'Table'); + this.$('.pivot-table-container select.pvtRenderer').val('Bar Chart').trigger('change'); + return wait().then(() => { + let options = JSON.parse(this.get('mockModel.pivotOptions')); + assert.equal(options.rendererName, 'Bar Chart'); + }); + }); +}); diff --git a/tests/integration/components/records-viewer-test.js b/tests/integration/components/records-viewer-test.js index b9d690f4..d47ea35e 100644 --- a/tests/integration/components/records-viewer-test.js +++ b/tests/integration/components/records-viewer-test.js @@ -3,6 +3,7 @@ * Licensed under the terms of the Apache License, Version 2.0. * See the LICENSE file associated with the project for terms. */ +import Ember from 'ember'; import { moduleForComponent, test } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; import wait from 'ember-test-helpers/wait'; @@ -36,22 +37,28 @@ moduleForComponent('records-viewer', 'Integration | Component | records viewer', test('it renders the data by default in a pre tag', function(assert) { this.set('mockRecords', RESULTS.SINGLE.records); - this.render(hbs`{{records-viewer records=mockRecords}}`); + this.set('rawMode', true); + this.render(hbs`{{records-viewer records=mockRecords showRawData=rawMode}}`); assert.equal(this.$('.pretty-json-container').text().trim(), JSON.stringify(RESULTS.SINGLE.records, null, 4).trim()); }); test('it allows swapping between table and the raw views', function(assert) { - assert.expect(6); + assert.expect(10); + this.set('mockModel', { isSingleRow: true }); this.set('mockRecords', RESULTS.SINGLE.records); - this.render(hbs`{{records-viewer records=mockRecords}}`); + this.render(hbs`{{records-viewer model=mockModel records=mockRecords}}`); this.$('.table-view').click(); return wait().then(() => { + assert.equal(this.$('.chart-view').length, 0); + assert.ok(this.$('.table-view').hasClass('active')); assert.equal(this.$('.pretty-json-container').length, 0); assert.equal(this.$('.records-table').length, 1); assert.equal(this.$('.lt-column').length, 3); assert.equal(this.$('.lt-body .lt-row .lt-cell').length, 3); this.$('.raw-view').click(); return wait().then(() => { + assert.equal(this.$('.chart-view').length, 0); + assert.ok(this.$('.raw-view').hasClass('active')); assert.equal(this.$('.records-table').length, 0); assert.equal(this.$('.pretty-json-container').length, 1); }); @@ -107,3 +114,81 @@ test('it handles missing columns across rows when making CSVs', function(assert) this.render(hbs`{{records-viewer records=mockRecords}}`); this.$('.download-option > a:eq(1)').click(); }); + +test('it enables charting mode if the results have more than one row', function(assert) { + assert.expect(9); + this.set('tableMode', true); + this.set('mockModel', { isSingleRow: false }); + this.set('mockRecords', RESULTS.GROUP.records); + this.render(hbs`{{records-viewer model=mockModel records=mockRecords showTable=tableMode}}`); + assert.equal(this.$('.chart-view').length, 1); + assert.equal(this.$('.pretty-json-container').length, 0); + assert.equal(this.$('.records-table').length, 1); + assert.equal(this.$('.lt-column').length, 4); + assert.equal(this.$('.lt-body .lt-row .lt-cell').length, 12); + this.$('.chart-view').click(); + return wait().then(() => { + assert.equal(this.$('.records-table').length, 0); + assert.equal(this.$('.pretty-json-container').length, 0); + // Defaults to simple chart view + assert.ok(this.$('.mode-toggle .simple-view').hasClass('selected')); + assert.equal(this.$('.visual-container canvas').length, 1); + }); +}); + +test('it allows you to switch to pivot mode', function(assert) { + assert.expect(12); + this.set('tableMode', true); + this.set('mockModel', Ember.Object.create({ + isSingleRow: false, + pivotOptions: null, + save() { + assert.ok(true); + } + })); + this.set('mockRecords', RESULTS.DISTRIBUTION.records); + this.render(hbs`{{records-viewer model=mockModel records=mockRecords showTable=tableMode}}`); + assert.equal(this.$('.chart-view').length, 1); + assert.equal(this.$('.pretty-json-container').length, 0); + assert.equal(this.$('.records-table').length, 1); + assert.equal(this.$('.lt-column').length, 3); + assert.equal(this.$('.lt-body .lt-row .lt-cell').length, 9); + this.$('.chart-view').click(); + return wait().then(() => { + assert.equal(this.$('.records-table').length, 0); + assert.equal(this.$('.pretty-json-container').length, 0); + assert.ok(this.$('.mode-toggle .simple-view').hasClass('selected')); + assert.equal(this.$('.visual-container canvas').length, 1); + this.$('.mode-toggle .advanced-view').click(); + return wait().then(() => { + assert.ok(this.$('.mode-toggle .advanced-view').hasClass('selected')); + assert.equal(this.$('.visual-container .pivot-table-container').length, 1); + }); + }); +}); + +test('it enables only pivot mode if the results are raw', function(assert) { + assert.expect(10); + this.set('tableMode', true); + this.set('mockModel', Ember.Object.create({ + isRaw: true, + pivotOptions: null, + save() { + assert.ok(true); + } + })); + this.set('mockRecords', RESULTS.GROUP.records); + this.render(hbs`{{records-viewer model=mockModel records=mockRecords showTable=tableMode}}`); + assert.equal(this.$('.chart-view').length, 1); + assert.equal(this.$('.pretty-json-container').length, 0); + assert.equal(this.$('.records-table').length, 1); + assert.equal(this.$('.lt-column').length, 4); + assert.equal(this.$('.lt-body .lt-row .lt-cell').length, 12); + this.$('.chart-view').click(); + return wait().then(() => { + assert.equal(this.$('.records-table').length, 0); + assert.equal(this.$('.pretty-json-container').length, 0); + assert.equal(this.$('.mode-toggle').length, 0); + assert.equal(this.$('.visual-container .pivot-table-container').length, 1); + }); +}); diff --git a/tests/integration/components/results-table-test.js b/tests/integration/components/results-table-test.js index bc5d5841..d5feb794 100644 --- a/tests/integration/components/results-table-test.js +++ b/tests/integration/components/results-table-test.js @@ -24,7 +24,7 @@ test('it displays a row with two cells in two columns', function(assert) { assert.equal(this.$('.lt-body .lt-row .lt-cell').eq(1).text().trim(), '3'); }); -test('it sorts a column on click', function(assert) { +test('it sorts by the number of records column on click', function(assert) { assert.expect(2); this.set('mockResults', Ember.A([ Ember.Object.create({ created: new Date(2014, 11, 31), records: Ember.A([1, 2, 3]) }), @@ -39,3 +39,34 @@ test('it sorts a column on click', function(assert) { assert.equal(spaceLess, '01Feb12:00AM031Dec12:00AM3'); }); }); + +test('it sorts by the date column on click', function(assert) { + assert.expect(2); + this.set('mockResults', Ember.A([ + Ember.Object.create({ created: new Date(2015, 1, 1), records: Ember.A() }), + Ember.Object.create({ created: new Date(2014, 11, 31), records: Ember.A([1, 2, 3]) }) + ])); + this.render(hbs`{{results-table results=mockResults}}`); + assert.equal(this.$('.lt-head .lt-column.is-sortable').length, 2); + this.$('.lt-head .lt-column.is-sortable').eq(0).click(); + return wait().then(() => { + let text = this.$('.lt-body .lt-row .lt-cell').text(); + let spaceLess = text.replace(/\s/g, ''); + assert.equal(spaceLess, '31Dec12:00AM301Feb12:00AM0'); + }); +}); + +test('it sends the resultClick action on click', function(assert) { + assert.expect(2); + this.set('mockResultClick', (result) => { + assert.equal(result.get('records.length'), 3); + }); + this.set('mockResults', Ember.A([ + Ember.Object.create({ created: new Date(2015, 1, 1), records: Ember.A() }), + Ember.Object.create({ created: new Date(2014, 11, 31), records: Ember.A([1, 2, 3]) }) + ])); + this.render(hbs`{{results-table results=mockResults resultClick=(action mockResultClick)}}`); + assert.equal(this.$('.lt-head .lt-column.is-sortable').length, 2); + this.$('.lt-body .lt-row .lt-cell .result-date-entry').eq(1).click(); + return wait(); +}); diff --git a/tests/unit/initializers/startup-test.js b/tests/unit/initializers/startup-test.js index 194e4a7a..594d1487 100644 --- a/tests/unit/initializers/startup-test.js +++ b/tests/unit/initializers/startup-test.js @@ -6,6 +6,7 @@ import Ember from 'ember'; import StartupInitializer from 'bullet-ui/initializers/startup'; import { module, test } from 'qunit'; +import ENV from 'bullet-ui/config/environment'; let application; @@ -19,6 +20,34 @@ module('Unit | Initializer | startup', { } }); +test('it can deep merge settings', function(assert) { + let overrides = { + modelVersion: 10, + helpLinks: [{ name: 'foo', link: 'http://foo.bar.com' }], + migrations: { deletions: 'query' }, + defaultValues: { + aggregationMaxSize: 200, + sketches: { countDistinctMaxEntries: 10 }, + metadataKeyMapping: { theta: 'foo', foo: 'bar' } + } + }; + let merged = StartupInitializer.deepMergeSettings(overrides); + + let expected = JSON.parse(JSON.stringify(ENV.APP.SETTINGS)); + expected.modelVersion = 10; + expected.helpLinks = [ + { name: 'Tutorials', link: 'https://yahoo.github.io/bullet-docs/ui/usage' }, + { name: 'foo', link: 'http://foo.bar.com' } + ]; + expected.migrations.deletions = 'query'; + expected.defaultValues.aggregationMaxSize = 200; + expected.defaultValues.sketches.countDistinctMaxEntries = 10; + expected.defaultValues.metadataKeyMapping.theta = 'foo'; + expected.defaultValues.metadataKeyMapping.foo = 'bar'; + + assert.deepEqual(merged, expected); +}); + test('it registers the settings factory', function(assert) { assert.ok(application.hasRegistration('settings:main')); }); diff --git a/tests/unit/instance-initializers/migrations-test.js b/tests/unit/instance-initializers/migrations-test.js new file mode 100644 index 00000000..b4f0748b --- /dev/null +++ b/tests/unit/instance-initializers/migrations-test.js @@ -0,0 +1,53 @@ +import Ember from 'ember'; +import StartupInitializer from 'bullet-ui/initializers/startup'; +import { initialize, applyMigrations } from 'bullet-ui/instance-initializers/migrations'; +import { module, test } from 'qunit'; +import destroyApp from '../../helpers/destroy-app'; + +let logger; + +module('Unit | Instance Initializer | migrations', { + beforeEach() { + Ember.run(() => { + this.application = Ember.Application.create(); + StartupInitializer.initialize(this.application); + this.appInstance = this.application.buildInstance(); + }); + logger = Ember.Logger.log; + Ember.Logger.log = function() { }; + }, + + afterEach() { + Ember.Logger.log = logger; + Ember.run(this.appInstance, 'destroy'); + destroyApp(this.application); + } +}); + +test('it initializes', function(assert) { + initialize(this.appInstance); + assert.ok(true); +}); + +test('it applies the delete results migration', function(assert) { + assert.expect(1); + + let manager = { + deleteAllResults() { + assert.ok(true); + } + }; + let migrations = { deletions: 'result' }; + + applyMigrations(manager, migrations); +}); + +test('it applies the delete queries migration', function(assert) { + assert.expect(1); + + let migrations = { deletions: 'query' }; + window.localStorage.foo = 'bar'; + + applyMigrations(null, migrations); + assert.notOk(window.localStorage.foo); +}); diff --git a/tests/unit/models/result-test.js b/tests/unit/models/result-test.js index 7fc2d2b8..011ec246 100644 --- a/tests/unit/models/result-test.js +++ b/tests/unit/models/result-test.js @@ -5,6 +5,7 @@ */ import Ember from 'ember'; import { moduleForModel, test } from 'ember-qunit'; +import { AGGREGATIONS } from 'bullet-ui/models/aggregation'; moduleForModel('result', 'Unit | Model | result', { needs: ['model:query'] @@ -20,3 +21,74 @@ test('it sets its default values right', function(assert) { assert.ok(parseInt(created.getTime()) >= now); }); +test('it recognizes a raw result type', function(assert) { + let model = this.subject(); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('RAW') }); + assert.ok(model.get('isRaw')); + assert.notOk(model.get('isReallyRaw')); + }); +}); + +test('it recognizes a really raw result type', function(assert) { + let model = this.subject(); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('RAW'), projectionsSize: 0 }); + assert.ok(model.get('isRaw')); + assert.ok(model.get('isReallyRaw')); + assert.notOk(model.get('isSingleRow')); + }); +}); + +test('it recognizes a count distinct result type', function(assert) { + let model = this.subject(); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('COUNT_DISTINCT') }); + assert.ok(model.get('isCountDistinct')); + assert.ok(model.get('isSingleRow')); + }); +}); + +test('it recognizes a group by result type', function(assert) { + let model = this.subject(); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('GROUP'), groupsSize: 2, metricsSize: 2 }); + assert.ok(model.get('isGroupBy')); + assert.notOk(model.get('isGroupAll')); + assert.notOk(model.get('isSingleRow')); + }); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('GROUP'), groupsSize: 1, metricsSize: 1 }); + assert.ok(model.get('isGroupBy')); + assert.notOk(model.get('isGroupAll')); + assert.notOk(model.get('isSingleRow')); + }); +}); + +test('it recognizes a group all result type', function(assert) { + let model = this.subject(); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('GROUP'), groupsSize: 0 }); + assert.notOk(model.get('isGroupBy')); + assert.ok(model.get('isGroupAll')); + assert.ok(model.get('isSingleRow')); + }); +}); + +test('it recognizes a distribution result type', function(assert) { + let model = this.subject(); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('DISTRIBUTION') }); + assert.ok(model.get('isDistribution')); + assert.notOk(model.get('isSingleRow')); + }); +}); + +test('it recognizes a top k result type', function(assert) { + let model = this.subject(); + Ember.run(() => { + model.set('querySnapshot', { type: AGGREGATIONS.get('TOP_K') }); + assert.ok(model.get('isTopK')); + assert.notOk(model.get('isSingleRow')); + }); +}); diff --git a/tests/unit/routes/query-test.js b/tests/unit/routes/query-test.js index ba6942a1..5a913c9f 100644 --- a/tests/unit/routes/query-test.js +++ b/tests/unit/routes/query-test.js @@ -6,7 +6,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('route:query', 'Unit | Route | query', { - needs: ['service:querier'] + needs: ['service:querier', 'service:queryManager'] }); test('it aborts a pending request if one exists', function(assert) {